feat(lancamentos): filtros de status e anexo; feedback visual de fatura paga

- Novos filtros no drawer: somente pagos, somente não pagos, com anexo
- Filtros de tipo/condição/pagamento agora usam slugs na URL (sem acentos)
- Coluna de liquidação: lançamentos de cartão com fatura paga exibem ícone
  verde com tooltip — diferenciando do estado pendente
- EstabelecimentoInput: popover respeita largura do input ao abrir
- slugify extraído para shared/utils/string.ts
- INVOICE_PAYMENT_CATEGORY_NAME adicionado em categories/constants.ts
- SETTLED_FILTER_VALUES adicionado em transactions/constants.ts
- establishment-logo.tsx removido (não utilizado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-11 17:50:59 +00:00
parent ffead579fa
commit dfb4126b12
12 changed files with 201 additions and 34 deletions

View File

@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
categoryFilter: null, categoryFilter: null,
accountCardFilter: null, accountCardFilter: null,
searchFilter: null, searchFilter: null,
settledFilter: null,
attachmentFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -31,6 +31,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
categoryFilter: z.string().nullable(), categoryFilter: z.string().nullable(),
accountCardFilter: z.string().nullable(), accountCardFilter: z.string().nullable(),
searchFilter: z.string().nullable(), searchFilter: z.string().nullable(),
settledFilter: z.string().nullable(),
attachmentFilter: z.string().nullable(),
}), }),
accountId: z.string().min(1).nullable().optional(), accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(), cardId: z.string().min(1).nullable().optional(),

View File

@@ -38,6 +38,13 @@ export function EstabelecimentoInput({
}: EstabelecimentoInputProps) { }: EstabelecimentoInputProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState(""); const [searchValue, setSearchValue] = React.useState("");
const [width, setWidth] = React.useState<number | undefined>();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open || !containerRef.current) return;
setWidth(containerRef.current.offsetWidth);
}, [open]);
const handleSelect = (selectedValue: string) => { const handleSelect = (selectedValue: string) => {
onChange(selectedValue); onChange(selectedValue);
@@ -50,7 +57,6 @@ export function EstabelecimentoInput({
onChange(newValue); onChange(newValue);
setSearchValue(newValue); setSearchValue(newValue);
// Open popover when user types and there are suggestions
if (newValue.length > 0 && estabelecimentos.length > 0) { if (newValue.length > 0 && estabelecimentos.length > 0) {
setOpen(true); setOpen(true);
} }
@@ -68,7 +74,7 @@ export function EstabelecimentoInput({
return ( return (
<Popover open={open} onOpenChange={setOpen} modal> <Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div className="relative"> <div ref={containerRef} className="relative w-full">
<Input <Input
id={id} id={id}
value={value} value={value}
@@ -86,7 +92,8 @@ export function EstabelecimentoInput({
</PopoverTrigger> </PopoverTrigger>
{estabelecimentos.length > 0 && ( {estabelecimentos.length > 0 && (
<PopoverContent <PopoverContent
className="p-0 w-[--radix-popover-trigger-width]" className="p-0"
style={width ? { width } : undefined}
align="start" align="start"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >

View File

@@ -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";

View File

@@ -210,7 +210,7 @@ function buildColumns({
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-medium truncate"> <span className="line-clamp-2 max-w-[180px] font-semibold truncate">
{name} {name}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -570,12 +570,44 @@ function buildColumns({
paymentMethod === "Transferência bancária" || paymentMethod === "Transferência bancária" ||
paymentMethod === "Pré-Pago | VR/VA"; paymentMethod === "Pré-Pago | VR/VA";
if (!canToggleSettlement) if (!canToggleSettlement) {
const invoicePaid = Boolean(row.original.isSettled);
return ( return (
<span className="flex size-7 shrink-0 items-center justify-center"> <Tooltip>
<RiBankCard2Line className="size-4 text-muted-foreground/30" /> <TooltipTrigger asChild>
</span> <span className="inline-flex">
<Button
variant="ghost"
size="icon-sm"
disabled
className={cn(
"transition-colors",
invoicePaid
? "bg-success/10 text-success"
: "text-muted-foreground/30",
)}
>
{invoicePaid ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiBankCard2Line className="size-4" />
)}
<span className="sr-only">
{invoicePaid
? "Fatura paga"
: "Lançamento de cartão de crédito"}
</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 text-center">
{invoicePaid
? "Fatura paga"
: "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"}
</TooltipContent>
</Tooltip>
); );
}
const readOnly = row.original.readonly; const readOnly = row.original.readonly;
const loading = isSettlementLoading(row.original.id); const loading = isSettlementLoading(row.original.id);

View File

@@ -15,6 +15,7 @@ import {
} from "react"; } from "react";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/constants"; } from "@/features/transactions/constants";
@@ -50,6 +51,8 @@ import {
SelectLabel, SelectLabel,
SelectTrigger, SelectTrigger,
} from "@/shared/components/ui/select"; } 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 { cn } from "@/shared/utils/ui";
import { import {
AccountCardSelectContent, AccountCardSelectContent,
@@ -66,9 +69,6 @@ import type {
const FILTER_EMPTY_VALUE = "__all"; const FILTER_EMPTY_VALUE = "__all";
const buildStaticOptions = (values: readonly string[]) =>
values.map((value) => ({ value, label: value }));
interface FilterSelectProps { interface FilterSelectProps {
param: string; param: string;
placeholder: string; placeholder: string;
@@ -263,7 +263,9 @@ export function TransactionsFilters({
searchParams.get("payment") || searchParams.get("payment") ||
searchParams.get("payer") || searchParams.get("payer") ||
searchParams.get("category") || searchParams.get("category") ||
searchParams.get("accountCard"); searchParams.get("accountCard") ||
searchParams.get("settled") ||
searchParams.get("hasAttachment");
const handleResetFilters = () => { const handleResetFilters = () => {
handleReset(); handleReset();
@@ -327,7 +329,10 @@ export function TransactionsFilters({
<FilterSelect <FilterSelect
param="type" param="type"
placeholder="Todos" placeholder="Todos"
options={buildStaticOptions(TRANSACTION_TYPES)} options={TRANSACTION_TYPES.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
@@ -345,7 +350,10 @@ export function TransactionsFilters({
<FilterSelect <FilterSelect
param="condition" param="condition"
placeholder="Todas" placeholder="Todas"
options={buildStaticOptions(TRANSACTION_CONDITIONS)} options={TRANSACTION_CONDITIONS.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
@@ -363,7 +371,10 @@ export function TransactionsFilters({
<FilterSelect <FilterSelect
param="payment" param="payment"
placeholder="Todos" placeholder="Todos"
options={buildStaticOptions(PAYMENT_METHODS)} options={PAYMENT_METHODS.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
@@ -547,6 +558,76 @@ export function TransactionsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-3">
<p className="text-sm font-medium">Status</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label
htmlFor="filter-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente pagos
</label>
<Switch
id="filter-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.PAID
}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.PAID : null,
);
}}
/>
</div>
<div className="flex items-center justify-between">
<label
htmlFor="filter-nao-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente não pagos
</label>
<Switch
id="filter-nao-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.UNPAID
}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
);
}}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<label
htmlFor="filter-has-attachment"
className="text-sm font-medium cursor-pointer"
>
Com anexo
</label>
<Switch
id="filter-has-attachment"
checked={searchParams.get("hasAttachment") === "true"}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"hasAttachment",
checked ? "true" : null,
);
}}
/>
</div>
</div> </div>
<DrawerFooter> <DrawerFooter>

View File

@@ -19,3 +19,8 @@ export const PAYMENT_METHODS = [
"Pré-Pago | VR/VA", "Pré-Pago | VR/VA",
"Transferência bancária", "Transferência bancária",
] as const; ] as const;
export const SETTLED_FILTER_VALUES = {
PAID: "pago",
UNPAID: "nao-pago",
} as const;

View File

@@ -6,6 +6,8 @@ export type TransactionExportFilters = {
categoryFilter: string | null; categoryFilter: string | null;
accountCardFilter: string | null; accountCardFilter: string | null;
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
}; };
export type TransactionsExportContext = { export type TransactionsExportContext = {

View File

@@ -15,6 +15,7 @@ export function useTransactionAttachments(transactionId: string) {
`/api/transactions/${transactionId}/attachments`, `/api/transactions/${transactionId}/attachments`,
), ),
enabled: Boolean(transactionId), 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
}); });
} }

View File

@@ -1,15 +1,17 @@
import type { SQL } from "drizzle-orm"; 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 { import {
cards, cards,
type categories, type categories,
financialAccounts, financialAccounts,
type payers, type payers,
transactionAttachments,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import type { SelectOption } from "@/features/transactions/components/types"; import type { SelectOption } from "@/features/transactions/components/types";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/constants"; } from "@/features/transactions/constants";
@@ -19,6 +21,7 @@ import {
PAYER_ROLE_THIRD_PARTY, PAYER_ROLE_THIRD_PARTY,
} from "@/shared/lib/payers/constants"; } from "@/shared/lib/payers/constants";
import { toDateOnlyString } from "@/shared/utils/date"; import { toDateOnlyString } from "@/shared/utils/date";
import { slugify } from "@/shared/utils/string";
type PayerRow = typeof payers.$inferSelect; type PayerRow = typeof payers.$inferSelect;
type AccountRow = typeof financialAccounts.$inferSelect; type AccountRow = typeof financialAccounts.$inferSelect;
@@ -40,6 +43,8 @@ export type TransactionSearchFilters = {
categoryFilter: string | null; categoryFilter: string | null;
accountCardFilter: string | null; accountCardFilter: string | null;
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
}; };
type BaseSluggedOption = { type BaseSluggedOption = {
@@ -127,6 +132,8 @@ export const extractTransactionSearchFilters = (
categoryFilter: getSingleParam(params, "category"), categoryFilter: getSingleParam(params, "category"),
accountCardFilter: getSingleParam(params, "accountCard"), accountCardFilter: getSingleParam(params, "accountCard"),
searchFilter: getSingleParam(params, "q"), searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"),
}); });
export const resolveTransactionPagination = ( export const resolveTransactionPagination = (
@@ -152,15 +159,17 @@ export const resolveTransactionPagination = (
const normalizeLabel = (value: string | null | undefined) => const normalizeLabel = (value: string | null | undefined) =>
value?.trim().length ? value.trim() : "Sem descrição"; value?.trim().length ? value.trim() : "Sem descrição";
const slugify = (value: string) => { const typeSlugToValue = Object.fromEntries(
const base = value TRANSACTION_TYPES.map((t) => [slugify(t), t]),
.normalize("NFD") ) as Record<string, (typeof TRANSACTION_TYPES)[number]>;
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase() const conditionSlugToValue = Object.fromEntries(
.replace(/[^a-z0-9]+/g, "-") TRANSACTION_CONDITIONS.map((c) => [slugify(c), c]),
.replace(/^-+|-+$/g, ""); ) as Record<string, (typeof TRANSACTION_CONDITIONS)[number]>;
return base || "item";
}; const paymentSlugToValue = Object.fromEntries(
PAYMENT_METHODS.map((m) => [slugify(m), m]),
) as Record<string, (typeof PAYMENT_METHODS)[number]>;
const createSlugGenerator = () => { const createSlugGenerator = () => {
const seen = new Map<string, number>(); const seen = new Map<string, number>();
@@ -338,16 +347,20 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.accountId, accountId)); where.push(eq(transactions.accountId, accountId));
} }
if (isValidTransaction(filters.transactionFilter)) { const typeValue = typeSlugToValue[filters.transactionFilter ?? ""] ?? null;
where.push(eq(transactions.transactionType, filters.transactionFilter)); if (isValidTransaction(typeValue)) {
where.push(eq(transactions.transactionType, typeValue));
} }
if (isValidCondition(filters.conditionFilter)) { const conditionValue =
where.push(eq(transactions.condition, filters.conditionFilter)); conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
if (isValidCondition(conditionValue)) {
where.push(eq(transactions.condition, conditionValue));
} }
if (isValidPaymentMethod(filters.paymentFilter)) { const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
where.push(eq(transactions.paymentMethod, filters.paymentFilter)); if (isValidPaymentMethod(paymentValue)) {
where.push(eq(transactions.paymentMethod, paymentValue));
} }
if (!payerId && filters.payerFilter) { 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); const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) { if (searchPattern) {
where.push( where.push(

View File

@@ -1,5 +1,7 @@
export const CATEGORY_TYPES = ["receita", "despesa"] as const; export const CATEGORY_TYPES = ["receita", "despesa"] as const;
export const INVOICE_PAYMENT_CATEGORY_NAME = "Pagamentos";
export type CategoryType = (typeof CATEGORY_TYPES)[number]; export type CategoryType = (typeof CATEGORY_TYPES)[number];
export const CATEGORY_TYPE_LABEL: Record<CategoryType, string> = { export const CATEGORY_TYPE_LABEL: Record<CategoryType, string> = {

View File

@@ -2,6 +2,16 @@
* Utility functions for string normalization and manipulation * 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 * Capitalizes the first letter of a string
*/ */