mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(anexos): página de galeria de comprovantes e documentos
Adiciona rota `/attachments` com visualização de todos os anexos do usuário em grade, visualização inline de imagem e PDF, navegação entre arquivos do mesmo lançamento e download direto. Inclui também: - API REST em `/api/attachments` para servir os arquivos - Actions `fetch-by-id` e `fetch-dialog-options` em transactions - Item "Anexos" adicionado à navbar - `formatBytes` extraído para `src/shared/utils/number.ts` - Migrations de banco atualizadas - Fix: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
src/features/transactions/actions/fetch-by-id.ts
Normal file
23
src/features/transactions/actions/fetch-by-id.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import { mapTransactionsData } from "@/features/transactions/page-helpers";
|
||||
import { fetchTransactionsWithRelations } from "@/features/transactions/queries";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import type { TransactionItem } from "../components/types";
|
||||
|
||||
export async function fetchTransactionByIdAction(
|
||||
transactionId: string,
|
||||
): Promise<TransactionItem | null> {
|
||||
const user = await getUser();
|
||||
const rows = await fetchTransactionsWithRelations({
|
||||
filters: [
|
||||
eq(transactions.id, transactionId),
|
||||
eq(transactions.userId, user.id),
|
||||
],
|
||||
excludeInitialBalanceFromIncome: false,
|
||||
});
|
||||
const mapped = mapTransactionsData(rows);
|
||||
return mapped[0] ?? null;
|
||||
}
|
||||
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal file
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
} from "@/features/transactions/page-helpers";
|
||||
import {
|
||||
fetchRecentEstablishments,
|
||||
fetchTransactionFilterSources,
|
||||
} from "@/features/transactions/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import type { SelectOption } from "../components/types";
|
||||
|
||||
export type TransactionDialogOptions = {
|
||||
payerOptions: SelectOption[];
|
||||
splitPayerOptions: SelectOption[];
|
||||
defaultPayerId: string | null;
|
||||
accountOptions: SelectOption[];
|
||||
cardOptions: SelectOption[];
|
||||
categoryOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
|
||||
export async function fetchTransactionDialogOptionsAction(): Promise<TransactionDialogOptions> {
|
||||
const userId = await getUserId();
|
||||
|
||||
const [filterSources, estabelecimentos] = await Promise.all([
|
||||
fetchTransactionFilterSources(userId),
|
||||
fetchRecentEstablishments(userId),
|
||||
]);
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
|
||||
const {
|
||||
payerOptions,
|
||||
splitPayerOptions,
|
||||
defaultPayerId,
|
||||
accountOptions,
|
||||
cardOptions,
|
||||
categoryOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
payerRows: filterSources.payerRows,
|
||||
});
|
||||
|
||||
return {
|
||||
payerOptions,
|
||||
splitPayerOptions,
|
||||
defaultPayerId,
|
||||
accountOptions,
|
||||
cardOptions,
|
||||
categoryOptions,
|
||||
estabelecimentos,
|
||||
};
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function AttachmentFilePicker({
|
||||
<RiAttachment2 className="size-4" />
|
||||
Adicionar anexo
|
||||
</span>
|
||||
<span className="text-[11px]">
|
||||
<span className="text-xs">
|
||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
RiDownloadLine,
|
||||
RiExternalLinkLine,
|
||||
RiFileImageLine,
|
||||
RiFileLine,
|
||||
RiFilePdfLine,
|
||||
RiFilePdf2Line,
|
||||
} from "@remixicon/react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -30,10 +29,9 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
function AttachmentIcon({ mimeType }: { mimeType: string }) {
|
||||
if (mimeType === "application/pdf")
|
||||
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
|
||||
return <RiFilePdf2Line className="size-4 text-red-500 shrink-0" />;
|
||||
if (mimeType.startsWith("image/"))
|
||||
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
|
||||
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
|
||||
}
|
||||
|
||||
function AttachmentPreview({
|
||||
|
||||
@@ -41,6 +41,7 @@ export function TransactionDetailsDialog({
|
||||
}: TransactionDetailsDialogProps) {
|
||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
||||
useEffect(() => {
|
||||
setAttachmentCount(null);
|
||||
}, [transaction?.id]);
|
||||
@@ -87,7 +88,7 @@ export function TransactionDetailsDialog({
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Resumo
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
<p className="mt-1 text-2xl font-medium">
|
||||
{currencyFormatter.format(valorTotal)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -235,14 +236,14 @@ export function TransactionDetailsDialog({
|
||||
<Separator />
|
||||
|
||||
<DialogFooter>
|
||||
{onEdit && !transaction.readonly && (
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button type="button">Fechar</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{onEdit && !transaction.readonly && (
|
||||
<Button onClick={handleEdit}>Editar</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -156,6 +156,7 @@ export function TransactionDialog({
|
||||
defaultTransactionType,
|
||||
isImporting,
|
||||
cardOptions,
|
||||
mode,
|
||||
]);
|
||||
|
||||
const primaryPayerId = formState.payerId;
|
||||
@@ -555,38 +556,23 @@ export function TransactionDialog({
|
||||
<AttachmentSection
|
||||
transactionId={transaction?.id ?? ""}
|
||||
maxSizeMb={maxSizeMb}
|
||||
pendingDetachIds={
|
||||
transaction?.seriesId ? pendingDetachIds : undefined
|
||||
pendingDetachIds={pendingDetachIds}
|
||||
onPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) => [...prev, id])
|
||||
}
|
||||
onPendingDetach={
|
||||
transaction?.seriesId
|
||||
? (id) => setPendingDetachIds((prev) => [...prev, id])
|
||||
: undefined
|
||||
onUndoPendingDetach={(id) =>
|
||||
setPendingDetachIds((prev) =>
|
||||
prev.filter((x) => x !== id),
|
||||
)
|
||||
}
|
||||
onUndoPendingDetach={
|
||||
transaction?.seriesId
|
||||
? (id) =>
|
||||
setPendingDetachIds((prev) =>
|
||||
prev.filter((x) => x !== id),
|
||||
)
|
||||
: undefined
|
||||
pendingUploadFiles={pendingUploadFiles}
|
||||
onPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) => [...prev, file])
|
||||
}
|
||||
pendingUploadFiles={
|
||||
transaction?.seriesId ? pendingUploadFiles : undefined
|
||||
}
|
||||
onPendingUpload={
|
||||
transaction?.seriesId
|
||||
? (file) =>
|
||||
setPendingUploadFiles((prev) => [...prev, file])
|
||||
: undefined
|
||||
}
|
||||
onCancelPendingUpload={
|
||||
transaction?.seriesId
|
||||
? (file) =>
|
||||
setPendingUploadFiles((prev) =>
|
||||
prev.filter((f) => f !== file),
|
||||
)
|
||||
: undefined
|
||||
onCancelPendingUpload={(file) =>
|
||||
setPendingUploadFiles((prev) =>
|
||||
prev.filter((f) => f !== file),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user