diff --git a/src/app/(dashboard)/payers/[payerId]/page.tsx b/src/app/(dashboard)/payers/[payerId]/page.tsx index 9e3b1f6..31d86e1 100644 --- a/src/app/(dashboard)/payers/[payerId]/page.tsx +++ b/src/app/(dashboard)/payers/[payerId]/page.tsx @@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = { categoryFilter: null, accountCardFilter: null, searchFilter: null, + settledFilter: null, + attachmentFilter: null, }; const createEmptySlugMaps = (): SlugMaps => ({ diff --git a/src/features/transactions/actions/export-actions.ts b/src/features/transactions/actions/export-actions.ts index e74fe52..4926f8c 100644 --- a/src/features/transactions/actions/export-actions.ts +++ b/src/features/transactions/actions/export-actions.ts @@ -31,6 +31,8 @@ const exportTransactionsSchema: z.ZodType = z.object( categoryFilter: z.string().nullable(), accountCardFilter: z.string().nullable(), searchFilter: z.string().nullable(), + settledFilter: z.string().nullable(), + attachmentFilter: z.string().nullable(), }), accountId: z.string().min(1).nullable().optional(), cardId: z.string().min(1).nullable().optional(), diff --git a/src/features/transactions/components/shared/establishment-input.tsx b/src/features/transactions/components/shared/establishment-input.tsx index 772724f..1258eb5 100644 --- a/src/features/transactions/components/shared/establishment-input.tsx +++ b/src/features/transactions/components/shared/establishment-input.tsx @@ -38,6 +38,13 @@ export function EstabelecimentoInput({ }: EstabelecimentoInputProps) { const [open, setOpen] = React.useState(false); const [searchValue, setSearchValue] = React.useState(""); + const [width, setWidth] = React.useState(); + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!open || !containerRef.current) return; + setWidth(containerRef.current.offsetWidth); + }, [open]); const handleSelect = (selectedValue: string) => { onChange(selectedValue); @@ -50,7 +57,6 @@ export function EstabelecimentoInput({ onChange(newValue); setSearchValue(newValue); - // Open popover when user types and there are suggestions if (newValue.length > 0 && estabelecimentos.length > 0) { setOpen(true); } @@ -68,7 +74,7 @@ export function EstabelecimentoInput({ return ( -
+
{estabelecimentos.length > 0 && ( e.preventDefault()} > diff --git a/src/features/transactions/components/shared/establishment-logo.tsx b/src/features/transactions/components/shared/establishment-logo.tsx deleted file mode 100644 index d7df2ce..0000000 --- a/src/features/transactions/components/shared/establishment-logo.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from shared — componente movido para src/shared/components/entity-avatar/ -export { EstablishmentLogo as EstabelecimentoLogo } from "@/shared/components/entity-avatar"; diff --git a/src/features/transactions/components/table/transactions-columns.tsx b/src/features/transactions/components/table/transactions-columns.tsx index 4f498d1..1626040 100644 --- a/src/features/transactions/components/table/transactions-columns.tsx +++ b/src/features/transactions/components/table/transactions-columns.tsx @@ -210,7 +210,7 @@ function buildColumns({ - + {name} @@ -570,12 +570,44 @@ function buildColumns({ paymentMethod === "Transferência bancária" || paymentMethod === "Pré-Pago | VR/VA"; - if (!canToggleSettlement) + if (!canToggleSettlement) { + const invoicePaid = Boolean(row.original.isSettled); return ( - - - + + + + + + + + {invoicePaid + ? "Fatura paga" + : "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"} + + ); + } const readOnly = row.original.readonly; const loading = isSettlementLoading(row.original.id); diff --git a/src/features/transactions/components/table/transactions-filters.tsx b/src/features/transactions/components/table/transactions-filters.tsx index 709b171..983e129 100644 --- a/src/features/transactions/components/table/transactions-filters.tsx +++ b/src/features/transactions/components/table/transactions-filters.tsx @@ -15,6 +15,7 @@ import { } from "react"; import { PAYMENT_METHODS, + SETTLED_FILTER_VALUES, TRANSACTION_CONDITIONS, TRANSACTION_TYPES, } from "@/features/transactions/constants"; @@ -50,6 +51,8 @@ import { SelectLabel, SelectTrigger, } from "@/shared/components/ui/select"; +import { Switch } from "@/shared/components/ui/switch"; +import { slugify } from "@/shared/utils/string"; import { cn } from "@/shared/utils/ui"; import { AccountCardSelectContent, @@ -66,9 +69,6 @@ import type { const FILTER_EMPTY_VALUE = "__all"; -const buildStaticOptions = (values: readonly string[]) => - values.map((value) => ({ value, label: value })); - interface FilterSelectProps { param: string; placeholder: string; @@ -263,7 +263,9 @@ export function TransactionsFilters({ searchParams.get("payment") || searchParams.get("payer") || searchParams.get("category") || - searchParams.get("accountCard"); + searchParams.get("accountCard") || + searchParams.get("settled") || + searchParams.get("hasAttachment"); const handleResetFilters = () => { handleReset(); @@ -327,7 +329,10 @@ export function TransactionsFilters({ ({ + value: slugify(v), + label: v, + }))} widthClass="w-full border-dashed" disabled={isPending} getParamValue={getParamValue} @@ -345,7 +350,10 @@ export function TransactionsFilters({ ({ + value: slugify(v), + label: v, + }))} widthClass="w-full border-dashed" disabled={isPending} getParamValue={getParamValue} @@ -363,7 +371,10 @@ export function TransactionsFilters({ ({ + value: slugify(v), + label: v, + }))} widthClass="w-full border-dashed" disabled={isPending} getParamValue={getParamValue} @@ -547,6 +558,76 @@ export function TransactionsFilters({
+ +
+

Status

+
+
+ + { + handleFilterChange( + "settled", + checked ? SETTLED_FILTER_VALUES.PAID : null, + ); + }} + /> +
+
+ + { + handleFilterChange( + "settled", + checked ? SETTLED_FILTER_VALUES.UNPAID : null, + ); + }} + /> +
+
+
+ +
+ + { + handleFilterChange( + "hasAttachment", + checked ? "true" : null, + ); + }} + /> +
diff --git a/src/features/transactions/constants.ts b/src/features/transactions/constants.ts index 9d6821c..156c5b4 100644 --- a/src/features/transactions/constants.ts +++ b/src/features/transactions/constants.ts @@ -19,3 +19,8 @@ export const PAYMENT_METHODS = [ "Pré-Pago | VR/VA", "Transferência bancária", ] as const; + +export const SETTLED_FILTER_VALUES = { + PAID: "pago", + UNPAID: "nao-pago", +} as const; diff --git a/src/features/transactions/export-types.ts b/src/features/transactions/export-types.ts index 19e0e95..e02f4da 100644 --- a/src/features/transactions/export-types.ts +++ b/src/features/transactions/export-types.ts @@ -6,6 +6,8 @@ export type TransactionExportFilters = { categoryFilter: string | null; accountCardFilter: string | null; searchFilter: string | null; + settledFilter: string | null; + attachmentFilter: string | null; }; export type TransactionsExportContext = { diff --git a/src/features/transactions/hooks/use-transaction-attachments.ts b/src/features/transactions/hooks/use-transaction-attachments.ts index 778970b..cf1b82b 100644 --- a/src/features/transactions/hooks/use-transaction-attachments.ts +++ b/src/features/transactions/hooks/use-transaction-attachments.ts @@ -15,6 +15,7 @@ export function useTransactionAttachments(transactionId: string) { `/api/transactions/${transactionId}/attachments`, ), enabled: Boolean(transactionId), - staleTime: 30_000, + staleTime: 50 * 60 * 1000, // 50 min — presigned URLs duram 1h + gcTime: 60 * 60 * 1000, // 1h — mantém cache enquanto URL é válida }); } diff --git a/src/features/transactions/page-helpers.ts b/src/features/transactions/page-helpers.ts index fc6ddba..3db0ebd 100644 --- a/src/features/transactions/page-helpers.ts +++ b/src/features/transactions/page-helpers.ts @@ -1,15 +1,17 @@ import type { SQL } from "drizzle-orm"; -import { and, eq, ilike, isNotNull, or } from "drizzle-orm"; +import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm"; import { cards, type categories, financialAccounts, type payers, + transactionAttachments, transactions, } from "@/db/schema"; import type { SelectOption } from "@/features/transactions/components/types"; import { PAYMENT_METHODS, + SETTLED_FILTER_VALUES, TRANSACTION_CONDITIONS, TRANSACTION_TYPES, } from "@/features/transactions/constants"; @@ -19,6 +21,7 @@ import { PAYER_ROLE_THIRD_PARTY, } from "@/shared/lib/payers/constants"; import { toDateOnlyString } from "@/shared/utils/date"; +import { slugify } from "@/shared/utils/string"; type PayerRow = typeof payers.$inferSelect; type AccountRow = typeof financialAccounts.$inferSelect; @@ -40,6 +43,8 @@ export type TransactionSearchFilters = { categoryFilter: string | null; accountCardFilter: string | null; searchFilter: string | null; + settledFilter: string | null; + attachmentFilter: string | null; }; type BaseSluggedOption = { @@ -127,6 +132,8 @@ export const extractTransactionSearchFilters = ( categoryFilter: getSingleParam(params, "category"), accountCardFilter: getSingleParam(params, "accountCard"), searchFilter: getSingleParam(params, "q"), + settledFilter: getSingleParam(params, "settled"), + attachmentFilter: getSingleParam(params, "hasAttachment"), }); export const resolveTransactionPagination = ( @@ -152,15 +159,17 @@ export const resolveTransactionPagination = ( const normalizeLabel = (value: string | null | undefined) => value?.trim().length ? value.trim() : "Sem descrição"; -const slugify = (value: string) => { - const base = value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return base || "item"; -}; +const typeSlugToValue = Object.fromEntries( + TRANSACTION_TYPES.map((t) => [slugify(t), t]), +) as Record; + +const conditionSlugToValue = Object.fromEntries( + TRANSACTION_CONDITIONS.map((c) => [slugify(c), c]), +) as Record; + +const paymentSlugToValue = Object.fromEntries( + PAYMENT_METHODS.map((m) => [slugify(m), m]), +) as Record; const createSlugGenerator = () => { const seen = new Map(); @@ -338,16 +347,20 @@ export const buildTransactionWhere = ({ where.push(eq(transactions.accountId, accountId)); } - if (isValidTransaction(filters.transactionFilter)) { - where.push(eq(transactions.transactionType, filters.transactionFilter)); + const typeValue = typeSlugToValue[filters.transactionFilter ?? ""] ?? null; + if (isValidTransaction(typeValue)) { + where.push(eq(transactions.transactionType, typeValue)); } - if (isValidCondition(filters.conditionFilter)) { - where.push(eq(transactions.condition, filters.conditionFilter)); + const conditionValue = + conditionSlugToValue[filters.conditionFilter ?? ""] ?? null; + if (isValidCondition(conditionValue)) { + where.push(eq(transactions.condition, conditionValue)); } - if (isValidPaymentMethod(filters.paymentFilter)) { - where.push(eq(transactions.paymentMethod, filters.paymentFilter)); + const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null; + if (isValidPaymentMethod(paymentValue)) { + where.push(eq(transactions.paymentMethod, paymentValue)); } if (!payerId && filters.payerFilter) { @@ -377,6 +390,18 @@ export const buildTransactionWhere = ({ } } + if (filters.settledFilter === SETTLED_FILTER_VALUES.PAID) { + where.push(eq(transactions.isSettled, true)); + } else if (filters.settledFilter === SETTLED_FILTER_VALUES.UNPAID) { + where.push(eq(transactions.isSettled, false)); + } + + if (filters.attachmentFilter === "true") { + where.push( + sql`EXISTS (SELECT 1 FROM ${transactionAttachments} WHERE ${transactionAttachments.transactionId} = ${transactions.id})`, + ); + } + const searchPattern = buildSearchPattern(filters.searchFilter); if (searchPattern) { where.push( diff --git a/src/shared/lib/categories/constants.ts b/src/shared/lib/categories/constants.ts index ae34290..7347014 100644 --- a/src/shared/lib/categories/constants.ts +++ b/src/shared/lib/categories/constants.ts @@ -1,5 +1,7 @@ export const CATEGORY_TYPES = ["receita", "despesa"] as const; +export const INVOICE_PAYMENT_CATEGORY_NAME = "Pagamentos"; + export type CategoryType = (typeof CATEGORY_TYPES)[number]; export const CATEGORY_TYPE_LABEL: Record = { diff --git a/src/shared/utils/string.ts b/src/shared/utils/string.ts index 30fa588..2a420ca 100644 --- a/src/shared/utils/string.ts +++ b/src/shared/utils/string.ts @@ -2,6 +2,16 @@ * Utility functions for string normalization and manipulation */ +export function slugify(value: string): string { + const base = value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return base || "item"; +} + /** * Capitalizes the first letter of a string */