feat(finance): refina fluxos de transacoes e pagadores

This commit is contained in:
Felipe Coutinho
2026-03-09 17:13:44 +00:00
parent 69da27276c
commit ada1377640
58 changed files with 1288 additions and 1559 deletions

View File

@@ -20,7 +20,7 @@ import {
DAYS_IN_MONTH,
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
} from "./constants";
} from "@/lib/cartoes/constants";
import type { CardFormValues } from "./types";
interface AccountOption {

View File

@@ -7,7 +7,7 @@ import {
RiPencilLine,
} from "@remixicon/react";
import Image from "next/image";
import { useMemo } from "react";
import MoneyValues from "@/components/shared/money-values";
import {
Card,
CardContent,
@@ -20,8 +20,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
import { resolveLogoSrc } from "@/lib/logo";
import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values";
interface CardItemProps {
name: string;
@@ -40,26 +41,6 @@ interface CardItemProps {
onRemove?: () => void;
}
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
};
const formatDay = (value: string) => value.padStart(2, "0");
export function CardItem({
@@ -83,7 +64,7 @@ export function CardItem({
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable !== null
(limitTotal !== null && limitAvailable != null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
@@ -100,62 +81,38 @@ export function CardItem({
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
const logoPath = useMemo(() => {
if (!logo) {
return null;
}
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo";
const metrics =
limitTotal === null || used === null || available === null
? null
: [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
}, [logo]);
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
const isInactive = useMemo(
() => status?.toLowerCase() === "inativo",
[status],
);
const metrics = useMemo(() => {
if (limitTotal === null) return null;
return [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
}, [available, limitTotal, used]);
const actions = useMemo(
() => [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
],
[onEdit, onInvoice, onRemove],
);
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
];
return (
<Card className="flex flex-col p-6 w-full">

View File

@@ -2,35 +2,17 @@
import { RiBankLine } from "@remixicon/react";
import Image from "next/image";
import DotIcon from "@/components/dot-icon";
import StatusDot from "@/components/shared/status-dot";
import { resolveCardBrandLogoSrc } from "@/lib/cartoes/brand-assets";
import { resolveLogoSrc } from "@/lib/logo";
type SelectItemContentProps = {
label: string;
logo?: string | null;
};
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
const getBrandLogo = (brand: string): string | null => {
const brandMap: Record<string, string> = {
Visa: "visa.png",
Mastercard: "mastercard.png",
Elo: "elo.png",
};
return brandMap[brand] ?? null;
};
export function BrandSelectContent({ label }: { label: string }) {
const brandLogo = getBrandLogo(label);
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
const logoSrc = resolveCardBrandLogoSrc(label);
return (
<span className="flex items-center gap-2">
@@ -55,7 +37,7 @@ export function StatusSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon
<StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/>
<span>{label}</span>

View File

@@ -2,25 +2,27 @@
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Card as UiCard } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CardDialog } from "./card-dialog";
import { CardItem } from "./card-item";
import type { Card as CreditCard } from "./types";
type AccountOption = {
id: string;
name: string;
logo: string | null;
};
interface CardsPageProps {
cards: Card[];
archivedCards: Card[];
cards: CreditCard[];
archivedCards: CreditCard[];
accounts: AccountOption[];
logoOptions: string[];
}
@@ -34,56 +36,54 @@ export function CardsPage({
const router = useRouter();
const [activeTab, setActiveTab] = useState("ativos");
const [editOpen, setEditOpen] = useState(false);
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [selectedCard, setSelectedCard] = useState<CreditCard | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
const [cardToRemove, setCardToRemove] = useState<CreditCard | null>(null);
const sortCards = useCallback(
(list: Card[]) =>
[...list].sort((a, b) =>
const orderedCards = useMemo(
() =>
[...cards].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
),
[],
[cards],
);
const orderedCards = useMemo(() => sortCards(cards), [cards, sortCards]);
const orderedArchivedCards = useMemo(
() => sortCards(archivedCards),
[archivedCards, sortCards],
() =>
[...archivedCards].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
),
[archivedCards],
);
const handleEdit = useCallback((card: Card) => {
const handleEdit = (card: CreditCard) => {
setSelectedCard(card);
setEditOpen(true);
}, []);
};
const handleEditOpenChange = useCallback((open: boolean) => {
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCard(null);
}
}, []);
};
const handleRemoveRequest = useCallback((card: Card) => {
const handleRemoveRequest = (card: CreditCard) => {
setCardToRemove(card);
setRemoveOpen(true);
}, []);
};
const handleInvoice = useCallback(
(card: Card) => {
router.push(`/cartoes/${card.id}/fatura`);
},
[router],
);
const handleInvoice = (card: CreditCard) => {
router.push(`/cartoes/${card.id}/fatura`);
};
const handleRemoveOpenChange = useCallback((open: boolean) => {
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCardToRemove(null);
}
}, []);
};
const handleRemoveConfirm = useCallback(async () => {
const handleRemoveConfirm = async () => {
if (!cardToRemove) {
return;
}
@@ -97,16 +97,16 @@ export function CardsPage({
toast.error(result.error);
throw new Error(result.error);
}, [cardToRemove]);
};
const removeTitle = cardToRemove
? `Remover cartão "${cardToRemove.name}"?`
: "Remover cartão?";
const renderCardList = (list: Card[], isArchived: boolean) => {
const renderCardList = (list: CreditCard[], isArchived: boolean) => {
if (list.length === 0) {
return (
<Card className="flex w-full items-center justify-center py-12">
<UiCard className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiBankCard2Line className="size-6 text-primary" />}
title={
@@ -120,7 +120,7 @@ export function CardsPage({
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
}
/>
</Card>
</UiCard>
);
}

View File

@@ -1,7 +0,0 @@
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
String(index + 1).padStart(2, "0"),
);

View File

@@ -7,10 +7,10 @@ import {
RiPencilLine,
} from "@remixicon/react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -69,31 +69,31 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
return base;
}, [categories]);
const handleEdit = useCallback((category: Category) => {
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
}, []);
};
const handleEditOpenChange = useCallback((open: boolean) => {
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
}, []);
};
const handleRemoveRequest = useCallback((category: Category) => {
const handleRemoveRequest = (category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
}, []);
};
const handleRemoveOpenChange = useCallback((open: boolean) => {
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
}, []);
};
const handleRemoveConfirm = useCallback(async () => {
const handleRemoveConfirm = async () => {
if (!categoryToRemove) {
return;
}
@@ -107,7 +107,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
toast.error(result.error);
throw new Error(result.error);
}, [categoryToRemove]);
};
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`

View File

@@ -1,8 +1,9 @@
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
import { TypeBadge } from "@/components/shared/type-badge";
import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { formatPercentage } from "@/lib/utils/percentage";
import { cn } from "@/lib/utils/ui";
import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card";
import { CategoryIconBadge } from "./category-icon-badge";
@@ -61,9 +62,12 @@ export function CategoryDetailHeader({
const variationLabel =
typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
1,
)}%`
? formatPercentage(percentageChange, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
})
: "—";
return (

View File

@@ -1,13 +1,13 @@
"use client";
import DotIcon from "@/components/dot-icon";
import StatusDot from "@/components/shared/status-dot";
export function TypeSelectContent({ label }: { label: string }) {
const isReceita = label === "Receita";
return (
<span className="flex items-center gap-2">
<DotIcon color={isReceita ? "bg-success" : "bg-destructive"} />
<StatusDot color={isReceita ? "bg-success" : "bg-destructive"} />
<span>{label}</span>
</span>
);

View File

@@ -1,3 +1,5 @@
import type { CategoryType } from "@/lib/categorias/constants";
export type { CategoryType } from "@/lib/categorias/constants";
export {
CATEGORY_TYPE_LABEL,

View File

@@ -8,7 +8,7 @@ import {
} from "@remixicon/react";
import type React from "react";
import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values";
import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardFooter } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";

View File

@@ -1,13 +1,13 @@
"use client";
import DotIcon from "@/components/dot-icon";
import StatusDot from "@/components/shared/status-dot";
export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativa";
return (
<span className="flex items-center gap-2">
<DotIcon
<StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/>
<span>{label}</span>

View File

@@ -1,8 +1,8 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import { type ReactNode, useMemo } from "react";
import MoneyValues from "@/components/money-values";
import type { ReactNode } from "react";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -10,6 +10,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
type DetailValue = string | number | ReactNode;
@@ -27,22 +29,9 @@ type AccountStatementCardProps = {
actions?: React.ReactNode;
};
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getAccountStatusBadgeVariant = (
status: string,
): "success" | "secondary" => {
): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
@@ -62,13 +51,7 @@ export function AccountStatementCard({
logo,
actions,
}: AccountStatementCardProps) {
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
const logoPath = resolveLogoSrc(logo);
return (
<Card className="border">

View File

@@ -6,11 +6,12 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { AccountCard } from "@/components/contas/account-card";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { resolveLogoSrc } from "@/lib/logo";
import { getCurrentPeriod } from "@/lib/utils/period";
import { Card } from "../ui/card";
import { AccountDialog } from "./account-dialog";
@@ -23,15 +24,6 @@ interface AccountsPageProps {
logoOptions: string[];
}
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return undefined;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
export function AccountsPage({
accounts,
archivedAccounts,
@@ -135,7 +127,7 @@ export function AccountsPage({
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{list.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
const logoSrc = resolveLogoSrc(account.logo) ?? undefined;
return (
<AccountCard
@@ -229,6 +221,8 @@ export function AccountsPage({
...a,
balance: a.balance ?? a.initialBalance ?? 0,
excludeFromBalance: a.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
a.excludeInitialBalanceFromIncome ?? false,
}))}
fromAccountId={transferFromAccount.id}
currentPeriod={getCurrentPeriod()}

View File

@@ -3,17 +3,18 @@
import { RiEditLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
updateInvoicePaymentStatusAction,
updatePaymentDateAction,
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import DotIcon from "@/components/dot-icon";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import StatusDot from "@/components/shared/status-dot";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_BADGE_VARIANT,
@@ -21,6 +22,8 @@ import {
INVOICE_STATUS_LABEL,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
@@ -41,26 +44,6 @@ type InvoiceSummaryCardProps = {
actions?: React.ReactNode;
};
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
};
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
@@ -76,19 +59,6 @@ const actionVariantByStatus: Record<
const formatDay = (value: string) => value.padStart(2, "0");
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getCardStatusDotColor = (status: string | null) => {
if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase();
@@ -122,26 +92,13 @@ export function InvoiceSummaryCard({
// Atualizar estado quando initialPaymentDate mudar
useEffect(() => {
if (initialPaymentDate) {
setPaymentDate(initialPaymentDate);
}
setPaymentDate(initialPaymentDate ?? new Date());
}, [initialPaymentDate]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
const brandAsset = useMemo(
() => (cardBrand ? resolveBrandAsset(cardBrand) : null),
[cardBrand],
);
const limitLabel = useMemo(() => {
if (typeof limitAmount !== "number") return "—";
return limitAmount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
}, [limitAmount]);
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(cardBrand);
const limitLabel =
typeof limitAmount === "number" ? formatCurrency(limitAmount) : "—";
const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
@@ -286,7 +243,7 @@ export function InvoiceSummaryCard({
value={
cardStatus ? (
<div className="flex items-center gap-1.5">
<DotIcon color={getCardStatusDotColor(cardStatus)} />
<StatusDot color={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span>
</div>
) : (

View File

@@ -2,7 +2,7 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/table";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { formatCurrentInstallment } from "@/lib/installments/utils";
import { formatShortPeriodLabel } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
interface InstallmentSelectionTableProps {
@@ -43,12 +44,6 @@ export function InstallmentSelectionTable({
}
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
@@ -116,7 +111,7 @@ export function InstallmentSelectionTable({
</Badge>
</TableCell>
<TableCell className="font-medium">
{formatPeriod(inst.period)}
{formatShortPeriodLabel(inst.period)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
@@ -58,20 +58,18 @@ export function BulkImportDialog({
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId],
);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
};
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
@@ -88,111 +86,100 @@ export function BulkImportDialog({
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload: CreateLancamentoInput = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType:
item.transactionType as CreateLancamentoInput["transactionType"],
amount: sanitizedAmount,
condition: item.condition as CreateLancamentoInput["condition"],
paymentMethod:
item.paymentMethod as CreateLancamentoInput["paymentMethod"],
pagadorId: pagadorId ?? null,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? null : (contaId ?? null),
cartaoId: isCredit ? (cartaoId ?? null) : null,
categoriaId: categoriaId ?? null,
note: item.note ?? null,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
);
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
};
const itemCount = items.length;
const hasCredit = items.some(

View File

@@ -1,6 +1,5 @@
"use client";
import { useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -10,73 +9,48 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./lancamento-dialog-types";
function formatCurrency(value: number): string {
return value.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ConditionSection({
formState,
onFieldChange,
showInstallments,
showRecurrence,
}: ConditionSectionProps) {
const amount = useMemo(() => {
const value = Number(formState.amount);
return Number.isNaN(value) || value <= 0 ? null : value;
}, [formState.amount]);
const parsedAmount = Number(formState.amount);
const amount =
Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount;
const getInstallmentLabel = useCallback(
(count: number) => {
if (amount) {
const installmentValue = amount / count;
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
}
return `${count}x`;
},
[amount],
);
const getInstallmentLabel = (count: number) => {
if (amount) {
const installmentValue = amount / count;
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
}
const _getRecurrenceLabel = (count: number) => {
return `${count} meses`;
return `${count}x`;
};
const installmentSummary = useMemo(() => {
if (!showInstallments || !formState.installmentCount || !amount) {
return null;
}
const installmentCount = Number(formState.installmentCount);
const installmentSummary =
showInstallments &&
formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? getInstallmentLabel(installmentCount)
: null;
const count = Number(formState.installmentCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return getInstallmentLabel(count);
}, [
showInstallments,
formState.installmentCount,
amount,
getInstallmentLabel,
]);
const recurrenceSummary = useMemo(() => {
if (!showRecurrence || !formState.recurrenceCount) {
return null;
}
const count = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return `Por ${count} meses`;
}, [showRecurrence, formState.recurrenceCount]);
const recurrenceCount = Number(formState.recurrenceCount);
const recurrenceSummary =
showRecurrence &&
formState.recurrenceCount &&
!Number.isNaN(recurrenceCount) &&
recurrenceCount > 0
? `Por ${recurrenceCount} meses`
: null;
return (
<div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -1,6 +1,5 @@
"use client";
import { useCallback } from "react";
import { CurrencyInput } from "@/components/ui/currency-input";
import { Label } from "@/components/ui/label";
import {
@@ -20,25 +19,19 @@ export function PagadorSection({
secondaryPagadorOptions,
totalAmount,
}: PagadorSectionProps) {
const handlePrimaryAmountChange = useCallback(
(value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
const handlePrimaryAmountChange = (value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
};
const handleSecondaryAmountChange = useCallback(
(value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("primarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
const handleSecondaryAmountChange = (value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("primarySplitAmount", remaining.toFixed(2));
};
return (
<div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -16,7 +16,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { displayPeriod } from "@/lib/utils/period";
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
import {
ContaCartaoSelectContent,
@@ -24,17 +24,6 @@ import {
} from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function InlinePeriodPicker({
period,
onPeriodChange,

View File

@@ -33,9 +33,12 @@ import {
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import {
LANCAMENTO_PAYMENT_METHODS,
type LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period";
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
@@ -50,17 +53,8 @@ import type { SelectOption } from "../types";
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
(m) => m !== "Boleto",
);
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number];
type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number];
function InlinePeriodPicker({
period,
@@ -111,23 +105,9 @@ interface MassAddDialogProps {
defaultCartaoId?: string | null;
}
export interface MassAddFormData {
fixedFields: {
transactionType?: string;
paymentMethod?: string;
condition?: string;
period?: string;
contaId?: string;
cartaoId?: string;
};
transactions: Array<{
purchaseDate: string;
name: string;
amount: string;
categoriaId?: string;
pagadorId?: string;
}>;
}
export type MassAddFormData = Parameters<
typeof import("@/app/(dashboard)/lancamentos/actions").createMassLancamentosAction
>[0];
interface TransactionRow {
id: string;
@@ -154,8 +134,9 @@ export function MassAddDialog({
const [loading, setLoading] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<string>(
const [transactionType, setTransactionType] =
useState<MassAddTransactionType>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<MassAddPaymentMethod>(
LANCAMENTO_PAYMENT_METHODS[0],
);
const [period, setPeriod] = useState<string>(selectedPeriod);
@@ -257,7 +238,7 @@ export function MassAddDialog({
transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate,
name: t.name.trim(),
amount: t.amount.trim(),
amount: Number(t.amount.trim()),
categoriaId: t.categoriaId,
pagadorId: t.pagadorId,
})),
@@ -312,7 +293,9 @@ export function MassAddDialog({
<Label htmlFor="transaction-type">Tipo de Transação</Label>
<Select
value={transactionType}
onValueChange={setTransactionType}
onValueChange={(value) =>
setTransactionType(value as MassAddTransactionType)
}
>
<SelectTrigger id="transaction-type" className="w-full">
<SelectValue>
@@ -338,7 +321,7 @@ export function MassAddDialog({
<Select
value={paymentMethod}
onValueChange={(value) => {
setPaymentMethod(value);
setPaymentMethod(value as MassAddPaymentMethod);
// Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") {
setContaId(undefined);

View File

@@ -19,10 +19,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import { formatDateOnly, formatDateTime } from "@/lib/utils/date";
import {
getPrimaryPdfColor,
loadExportLogoDataUrl,
} from "@/lib/utils/export-branding";
import { displayPeriod } from "@/lib/utils/period";
import type { LancamentoItem } from "./types";
interface LancamentosExportProps {
@@ -41,12 +43,13 @@ export function LancamentosExport({
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return (
formatDateOnly(dateString, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? dateString
);
};
const getContaCartaoName = (lancamento: LancamentoItem) => {
@@ -190,8 +193,8 @@ export function LancamentosExport({
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/logo_small.png"),
loadExportLogoDataUrl("/logo_text.png"),
loadExportLogoDataUrl("/imagens/logo_small.png"),
loadExportLogoDataUrl("/imagens/logo_text.png"),
]);
let brandingEndX = 14;
@@ -212,28 +215,15 @@ export function LancamentosExport({
doc.text("Lançamentos", titleX, 15);
doc.setFontSize(10);
const periodParts = period.split("-");
const monthNames = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const formattedPeriod =
periodParts.length === 2
? `${monthNames[Number.parseInt(periodParts[1], 10) - 1]}/${periodParts[0]}`
: period;
doc.text(`Período: ${formattedPeriod}`, titleX, 22);
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
doc.text(
`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`,
`Gerado em: ${
formatDateTime(new Date(), {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
}`,
titleX,
27,
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import {
createMassLancamentosAction,
@@ -10,7 +10,7 @@ import {
toggleLancamentoSettlementAction,
updateLancamentoBulkAction,
} from "@/app/(dashboard)/lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
@@ -139,7 +139,7 @@ export function LancamentosPage({
LancamentoItem[]
>([]);
const handleToggleSettlement = useCallback(async (item: LancamentoItem) => {
const handleToggleSettlement = async (item: LancamentoItem) => {
if (item.paymentMethod === "Cartão de crédito") {
toast.info(
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
@@ -182,9 +182,9 @@ export function LancamentosPage({
} finally {
setSettlementLoadingId(null);
}
}, []);
};
const handleDelete = useCallback(async () => {
const handleDelete = async () => {
if (!lancamentoToDelete) {
return;
}
@@ -200,91 +200,82 @@ export function LancamentosPage({
toast.success(result.message);
setDeleteOpen(false);
}, [lancamentoToDelete]);
};
const handleBulkDelete = useCallback(
async (scope: BulkActionScope) => {
if (!pendingDeleteData) {
return;
}
const handleBulkDelete = async (scope: BulkActionScope) => {
if (!pendingDeleteData) {
return;
}
const result = await deleteLancamentoBulkAction({
id: pendingDeleteData.id,
scope,
});
const result = await deleteLancamentoBulkAction({
id: pendingDeleteData.id,
scope,
});
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
toast.success(result.message);
setBulkDeleteOpen(false);
setPendingDeleteData(null);
},
[pendingDeleteData],
);
toast.success(result.message);
setBulkDeleteOpen(false);
setPendingDeleteData(null);
};
const handleBulkEditRequest = useCallback(
(data: {
id: string;
name: string;
categoriaId: string | undefined;
note: string;
pagadorId: string | undefined;
contaId: string | undefined;
cartaoId: string | undefined;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
}) => {
if (!selectedLancamento) {
return;
}
const handleBulkEditRequest = (data: {
id: string;
name: string;
categoriaId: string | undefined;
note: string;
pagadorId: string | undefined;
contaId: string | undefined;
cartaoId: string | undefined;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
}) => {
if (!selectedLancamento) {
return;
}
setPendingEditData({
...data,
lancamento: selectedLancamento,
});
setEditOpen(false);
setBulkEditOpen(true);
},
[selectedLancamento],
);
setPendingEditData({
...data,
lancamento: selectedLancamento,
});
setEditOpen(false);
setBulkEditOpen(true);
};
const handleBulkEdit = useCallback(
async (scope: BulkActionScope) => {
if (!pendingEditData) {
return;
}
const handleBulkEdit = async (scope: BulkActionScope) => {
if (!pendingEditData) {
return;
}
const result = await updateLancamentoBulkAction({
id: pendingEditData.id,
scope,
name: pendingEditData.name,
categoriaId: pendingEditData.categoriaId,
note: pendingEditData.note,
pagadorId: pendingEditData.pagadorId,
contaId: pendingEditData.contaId,
cartaoId: pendingEditData.cartaoId,
amount: pendingEditData.amount,
dueDate: pendingEditData.dueDate,
boletoPaymentDate: pendingEditData.boletoPaymentDate,
});
const result = await updateLancamentoBulkAction({
id: pendingEditData.id,
scope,
name: pendingEditData.name,
categoriaId: pendingEditData.categoriaId,
note: pendingEditData.note,
pagadorId: pendingEditData.pagadorId,
contaId: pendingEditData.contaId,
cartaoId: pendingEditData.cartaoId,
amount: pendingEditData.amount,
dueDate: pendingEditData.dueDate,
boletoPaymentDate: pendingEditData.boletoPaymentDate,
});
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
toast.success(result.message);
setBulkEditOpen(false);
setPendingEditData(null);
},
[pendingEditData],
);
toast.success(result.message);
setBulkEditOpen(false);
setPendingEditData(null);
};
const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => {
const handleMassAddSubmit = async (data: MassAddFormData) => {
const result = await createMassLancamentosAction(data);
if (!result.success) {
@@ -293,9 +284,9 @@ export function LancamentosPage({
}
toast.success(result.message);
}, []);
};
const handleMultipleBulkDelete = useCallback((items: LancamentoItem[]) => {
const handleMultipleBulkDelete = (items: LancamentoItem[]) => {
// Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo
const withSeries = items.filter((i) => i.seriesId);
const sameSeries =
@@ -309,9 +300,9 @@ export function LancamentosPage({
}
setPendingMultipleDeleteData(items);
setMultipleBulkDeleteOpen(true);
}, []);
};
const confirmMultipleBulkDelete = useCallback(async () => {
const confirmMultipleBulkDelete = async () => {
if (pendingMultipleDeleteData.length === 0) {
return;
}
@@ -327,42 +318,42 @@ export function LancamentosPage({
toast.success(result.message);
setMultipleBulkDeleteOpen(false);
setPendingMultipleDeleteData([]);
}, [pendingMultipleDeleteData]);
};
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
"Despesa" | "Receita" | null
>(null);
const handleCreate = useCallback((type: "Despesa" | "Receita") => {
const handleCreate = (type: "Despesa" | "Receita") => {
setTransactionTypeForCreate(type);
setCreateOpen(true);
}, []);
};
const handleMassAdd = useCallback(() => {
const handleMassAdd = () => {
setMassAddOpen(true);
}, []);
};
const handleEdit = useCallback((item: LancamentoItem) => {
const handleEdit = (item: LancamentoItem) => {
setSelectedLancamento(item);
setEditOpen(true);
}, []);
};
const handleCopy = useCallback((item: LancamentoItem) => {
const handleCopy = (item: LancamentoItem) => {
setLancamentoToCopy(item);
setCopyOpen(true);
}, []);
};
const handleImport = useCallback((item: LancamentoItem) => {
const handleImport = (item: LancamentoItem) => {
setLancamentoToImport(item);
setImportOpen(true);
}, []);
};
const handleBulkImport = useCallback((items: LancamentoItem[]) => {
const handleBulkImport = (items: LancamentoItem[]) => {
setLancamentosToImport(items);
setBulkImportOpen(true);
}, []);
};
const handleConfirmDelete = useCallback((item: LancamentoItem) => {
const handleConfirmDelete = (item: LancamentoItem) => {
if (item.seriesId) {
setPendingDeleteData(item);
setBulkDeleteOpen(true);
@@ -370,22 +361,22 @@ export function LancamentosPage({
setLancamentoToDelete(item);
setDeleteOpen(true);
}
}, []);
};
const handleViewDetails = useCallback((item: LancamentoItem) => {
const handleViewDetails = (item: LancamentoItem) => {
setSelectedLancamento(item);
setDetailsOpen(true);
}, []);
};
const handleAnticipate = useCallback((item: LancamentoItem) => {
const handleAnticipate = (item: LancamentoItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
}, []);
};
const handleViewAnticipationHistory = useCallback((item: LancamentoItem) => {
const handleViewAnticipationHistory = (item: LancamentoItem) => {
setSelectedForAnticipation(item);
setAnticipationHistoryOpen(true);
}, []);
};
return (
<>

View File

@@ -3,8 +3,9 @@
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { CategoryIcon } from "@/components/categorias/category-icon";
import DotIcon from "@/components/dot-icon";
import StatusDot from "@/components/shared/status-dot";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { resolveLogoSrc } from "@/lib/logo";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -56,7 +57,7 @@ export function TransactionTypeSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon color={colorMap[label]} />
<StatusDot color={colorMap[label]} />
<span>{label}</span>
</span>
);
@@ -89,15 +90,6 @@ export function ContaCartaoSelectContent({
logo,
isCartao,
}: SelectItemContentProps & { isCartao?: boolean }) {
const resolveLogoSrc = (logoPath: string | null) => {
if (!logoPath) {
return null;
}
const fileName = logoPath.split("/").filter(Boolean).pop() ?? logoPath;
return `/logos/${fileName}`;
};
const logoSrc = resolveLogoSrc(logo);
const Icon = isCartao ? RiBankCard2Line : RiBankLine;

View File

@@ -6,8 +6,8 @@ import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import MoneyValues from "@/components/money-values";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {

View File

@@ -31,9 +31,9 @@ import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { CategoryIcon } from "@/components/categorias/category-icon";
import MoneyValues from "@/components/money-values";
import { EmptyState } from "@/components/shared/empty-state";
import { TypeBadge } from "@/components/type-badge";
import MoneyValues from "@/components/shared/money-values";
import { TypeBadge } from "@/components/shared/type-badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -69,6 +69,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
import { resolveLogoSrc } from "@/lib/logo";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -82,15 +83,6 @@ import type {
} from "../types";
import { LancamentosFilters } from "./lancamentos-filters";
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
type BuildColumnsArgs = {
currentUserId: string;
noteAsColumn: boolean;
@@ -386,7 +378,7 @@ const buildColumns = ({
cell: ({ row }) => {
const { pagadorId, pagadorName, pagadorAvatar } = row.original;
const label = pagadorName.trim() || "Sem pagador";
const label = pagadorName?.trim() || "Sem pagador";
const displayName = label.split(/\s+/)[0] ?? label;
const avatarSrc = getAvatarSrc(pagadorAvatar);
const initial = displayName.charAt(0).toUpperCase() || "?";

View File

@@ -38,6 +38,10 @@ import type {
BudgetNotification,
DashboardNotification,
} from "@/lib/dashboard/notifications";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateOnly } from "@/lib/utils/date";
import { formatPercentage } from "@/lib/utils/percentage";
import { cn } from "@/lib/utils/ui";
type NotificationBellProps = {
@@ -47,27 +51,13 @@ type NotificationBellProps = {
preLancamentosCount?: number;
};
const resolveLogoPath = (logo: string | null | undefined) => {
if (!logo) return null;
if (/^(https?:\/\/|data:)/.test(logo)) return logo;
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
function formatDate(dateString: string): string {
const [year, month, day] = dateString.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
timeZone: "UTC",
});
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(amount);
return (
formatDateOnly(dateString, {
day: "2-digit",
month: "short",
}) ?? dateString
);
}
function SectionLabel({
@@ -115,9 +105,9 @@ export function NotificationBell({
aria-expanded={open}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
"group relative border border-black/10 text-black/75 shadow-none transition-all duration-200",
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
)}
>
<RiNotification3Line
@@ -130,7 +120,7 @@ export function NotificationBell({
<>
<span
aria-hidden
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground shadow-xs ring-2 ring-background"
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-destructive-foreground "
>
{displayCount}
</span>
@@ -148,7 +138,7 @@ export function NotificationBell({
<DropdownMenuContent
align="end"
sideOffset={12}
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
>
{/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
@@ -223,13 +213,22 @@ export function NotificationBell({
excedido {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} (
{Math.round(n.usedPercentage)}%)
{formatPercentage(n.usedPercentage, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
)
</>
) : (
<>
<strong>{n.categoryName}</strong> atingiu{" "}
<strong>{Math.round(n.usedPercentage)}%</strong> do
orçamento {" "}
<strong>
{formatPercentage(n.usedPercentage, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</strong>{" "}
do orçamento {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)}
</>
@@ -250,7 +249,7 @@ export function NotificationBell({
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{invoiceNotifications.map((n) => {
const logo = resolveLogoPath(n.cardLogo);
const logo = resolveLogoSrc(n.cardLogo);
return (
<div
key={n.id}

View File

@@ -7,7 +7,7 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils/ui";

View File

@@ -1,13 +1,13 @@
"use client";
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
import { useCallback, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import {
deleteBudgetAction,
duplicatePreviousMonthBudgetsAction,
} from "@/app/(dashboard)/orcamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "../ui/card";
@@ -36,31 +36,31 @@ export function BudgetsPage({
const hasBudgets = budgets.length > 0;
const handleEdit = useCallback((budget: Budget) => {
const handleEdit = (budget: Budget) => {
setSelectedBudget(budget);
setEditOpen(true);
}, []);
};
const handleEditOpenChange = useCallback((open: boolean) => {
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedBudget(null);
}
}, []);
};
const handleRemoveRequest = useCallback((budget: Budget) => {
const handleRemoveRequest = (budget: Budget) => {
setBudgetToRemove(budget);
setRemoveOpen(true);
}, []);
};
const handleRemoveOpenChange = useCallback((open: boolean) => {
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setBudgetToRemove(null);
}
}, []);
};
const handleRemoveConfirm = useCallback(async () => {
const handleRemoveConfirm = async () => {
if (!budgetToRemove) {
return;
}
@@ -74,9 +74,9 @@ export function BudgetsPage({
toast.error(result.error);
throw new Error(result.error);
}, [budgetToRemove]);
};
const handleDuplicateConfirm = useCallback(async () => {
const handleDuplicateConfirm = async () => {
const result = await duplicatePreviousMonthBudgetsAction({
period: selectedPeriod,
});
@@ -89,7 +89,7 @@ export function BudgetsPage({
toast.error(result.error);
throw new Error(result.error);
}, [selectedPeriod]);
};
const removeTitle = budgetToRemove
? `Remover orçamento de "${

View File

@@ -1,22 +1,11 @@
import { RiBankCard2Line } from "@remixicon/react";
import Image from "next/image";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import { resolveLogoSrc } from "@/lib/logo";
import type { PagadorCardUsageItem } from "@/lib/pagadores/details";
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CC";
@@ -50,7 +39,7 @@ export function PagadorCardUsageCard({ items }: PagadorCardUsageCardProps) {
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{items.map((item) => {
const logoPath = resolveLogoPath(item.logo);
const logoPath = resolveLogoSrc(item.logo);
const initials = buildInitials(item.name);
return (
<li

View File

@@ -0,0 +1,365 @@
"use client";
import {
RiBankCard2Line,
RiBillLine,
RiExchangeDollarLine,
RiMailLine,
RiMailSendLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
import type { PagadorInfo, PagadorSummaryPreview } from "./types";
type PagadorHeaderCardProps = {
pagador: PagadorInfo;
selectedPeriod: string;
summary: PagadorSummaryPreview;
};
export function PagadorHeaderCard({
pagador,
selectedPeriod,
summary,
}: PagadorHeaderCardProps) {
const router = useRouter();
const [isSending, startTransition] = useTransition();
const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
const createdAtLabel = formatDate(pagador.createdAt);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const lastMailLabel =
formatDateTime(pagador.lastMailAt, {
dateStyle: "short",
timeStyle: "short",
}) ?? "Nunca enviado";
const disableSend = isSending || !pagador.email || !pagador.canEdit;
const openConfirmDialog = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
setConfirmOpen(true);
};
const handleSendSummary = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
startTransition(async () => {
const result = await sendPagadorSummaryAction({
pagadorId: pagador.id,
period: selectedPeriod,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConfirmOpen(false);
router.refresh();
});
};
const getStatusBadgeVariant = (status: string): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo") {
return "success";
}
return "outline";
};
return (
<Card className="mb-2 border gap-4">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
<Image
src={avatarSrc}
alt={`Avatar de ${pagador.name}`}
width={64}
height={64}
className="h-full w-full rounded-full object-cover"
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-semibold text-foreground">
{pagador.name}
</CardTitle>
{isAdmin ? (
<RiVerifiedBadgeFill
className="size-4 text-sky-500"
aria-hidden
/>
) : null}
<Badge
variant={getStatusBadgeVariant(pagador.status)}
className="text-xs"
>
{pagador.status}
</Badge>
{pagador.isAutoSend ? (
<Badge variant="info" className="gap-1 text-xs">
<RiMailSendLine className="size-3.5" aria-hidden />
Envio automático
</Badge>
) : null}
</div>
<CardDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span>Criado em {createdAtLabel}</span>
<span className="hidden text-border/80 sm:inline"></span>
{pagador.email ? (
<Link
prefetch
href={`mailto:${pagador.email}`}
className="inline-flex items-center gap-1.5 text-primary"
>
<RiMailLine className="size-4" aria-hidden />
{pagador.email}
</Link>
) : (
<span>Sem e-mail cadastrado</span>
)}
</CardDescription>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
{pagador.canEdit ? (
<>
<Button
type="button"
size="sm"
onClick={openConfirmDialog}
disabled={disableSend}
className="w-full min-w-[180px] lg:w-auto"
>
{isSending ? "Enviando..." : "Enviar resumo"}
</Button>
<span className="text-xs text-muted-foreground">
Último envio: {lastMailLabel}
</span>
</>
) : (
<Badge variant="outline" className="justify-center text-xs">
Acesso somente leitura
</Badge>
)}
</div>
</CardHeader>
{pagador.canEdit ? (
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (isSending) return;
setConfirmOpen(open);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Confirmar envio do resumo</DialogTitle>
<DialogDescription>
Resumo de{" "}
<span className="font-semibold text-foreground">
{summary.periodLabel}
</span>{" "}
para{" "}
<span className="font-medium text-foreground">
{pagador.email}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiExchangeDollarLine className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Total de Despesas
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(summary.totalExpenses)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">
{summary.lancamentoCount} lançamentos
</p>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBankCard2Line className="size-4" />
<span className="text-xs font-semibold uppercase">
Cartões
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.card)}
</p>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBillLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Boletos
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.boleto)}
</p>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiExchangeDollarLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Pix/Débito
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.instant)}
</p>
</div>
</div>
<div className="space-y-3">
{summary.cardUsage.length > 0 && (
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<RiBankCard2Line className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Cartões Utilizados
</span>
</div>
<div className="space-y-1">
{summary.cardUsage.map((card, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-foreground">{card.name}</span>
<span className="font-medium text-foreground">
{formatCurrency(card.amount)}
</span>
</div>
))}
</div>
</div>
)}
{(summary.boletoStats.paidCount > 0 ||
summary.boletoStats.pendingCount > 0) && (
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<RiBillLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Status de Boletos
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Pagos</p>
<p className="text-sm font-semibold text-success">
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.paidCount})
</span>
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Pendentes
</p>
<p className="text-sm font-semibold text-warning">
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.pendingCount})
</span>
</p>
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSending}
onClick={() => setConfirmOpen(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleSendSummary}
disabled={disableSend}
>
{isSending ? "Enviando..." : "Confirmar envio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</Card>
);
}
const formatDate = (value: string) => {
return (
formatDateTime(value, {
day: "2-digit",
month: "long",
year: "numeric",
}) ?? "—"
);
};

View File

@@ -15,7 +15,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { PagadorHistoryPoint } from "@/lib/pagadores/details";

View File

@@ -1,136 +1,26 @@
"use client";
import {
RiBankCard2Line,
RiBillLine,
RiExchangeDollarLine,
RiMailLine,
RiMailSendLine,
RiUser3Line,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ReactNode, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
import { RiUser3Line } from "@remixicon/react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDateTime } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui";
type PagadorInfo = {
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
status: string;
note: string | null;
role: string | null;
isAutoSend: boolean;
createdAt: string;
lastMailAt: string | null;
shareCode: string | null;
canEdit: boolean;
};
type PagadorSummaryPreview = {
periodLabel: string;
totalExpenses: number;
paymentSplits: {
card: number;
boleto: number;
instant: number;
};
cardUsage: { name: string; amount: number }[];
boletoStats: {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
lancamentoCount: number;
};
import type { PagadorInfo } from "./types";
type PagadorInfoCardProps = {
pagador: PagadorInfo;
selectedPeriod: string;
summary: PagadorSummaryPreview;
};
export function PagadorInfoCard({
pagador,
selectedPeriod,
summary,
}: PagadorInfoCardProps) {
const router = useRouter();
const [isSending, startTransition] = useTransition();
const [confirmOpen, setConfirmOpen] = useState(false);
export function PagadorInfoCard({ pagador }: PagadorInfoCardProps) {
const showSensitiveDetails = pagador.canEdit;
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
const createdAtLabel = formatDate(pagador.createdAt);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const lastMailLabel = useMemo(() => {
if (!pagador.lastMailAt) {
return "Nunca enviado";
}
const date = new Date(pagador.lastMailAt);
if (Number.isNaN(date.getTime())) {
return "Nunca enviado";
}
return date.toLocaleString("pt-BR", {
dateStyle: "short",
timeStyle: "short",
});
}, [pagador.lastMailAt]);
const disableSend = isSending || !pagador.email || !pagador.canEdit;
const openConfirmDialog = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
setConfirmOpen(true);
};
const handleSendSummary = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
startTransition(async () => {
const result = await sendPagadorSummaryAction({
pagadorId: pagador.id,
period: selectedPeriod,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConfirmOpen(false);
router.refresh();
});
};
const getStatusBadgeVariant = (status: string): "success" | "secondary" => {
const getStatusBadgeVariant = (status: string): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo") {
return "success";
@@ -140,84 +30,18 @@ export function PagadorInfoCard({
return (
<Card className="border gap-4">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
<Image
src={avatarSrc}
alt={`Avatar de ${pagador.name}`}
width={64}
height={64}
className="h-full w-full object-cover rounded-full"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-semibold text-foreground">
{pagador.name}
</CardTitle>
{isAdmin ? (
<RiVerifiedBadgeFill
className="size-4 text-sky-500"
aria-hidden
/>
) : null}
{pagador.isAutoSend ? (
<RiMailSendLine
className="size-4 text-primary"
aria-label="Envio automático habilitado"
/>
) : null}
</div>
<span className="text-sm text-muted-foreground">
Criado em {createdAtLabel}
</span>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
{pagador.canEdit ? (
<>
<Button
type="button"
size="sm"
onClick={openConfirmDialog}
disabled={disableSend}
className="w-full min-w-[180px] lg:w-auto"
>
{isSending ? "Enviando..." : "Enviar resumo"}
</Button>
<span className="text-xs text-muted-foreground">
Último envio: {lastMailLabel}
</span>
</>
) : (
<span className="text-xs font-medium text-warning">
Acesso somente leitura
</span>
)}
</div>
<CardHeader className="gap-1.5">
<CardTitle className="text-lg font-semibold">
Detalhes do pagador
</CardTitle>
<CardDescription>
{showSensitiveDetails
? "Informações cadastrais e preferências de envio."
: "Informações cadastrais visíveis para este compartilhamento."}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 border-t border-dashed border-border/60 pt-6 text-sm sm:grid-cols-2">
<InfoItem
label="E-mail"
value={
pagador.email ? (
<Link
prefetch
href={`mailto:${pagador.email}`}
className="inline-flex items-center gap-2 text-primary"
>
<RiMailLine className="size-4" aria-hidden />
{pagador.email}
</Link>
) : (
"Sem e-mail cadastrado"
)
}
/>
<InfoItem
label="Status"
value={
@@ -239,11 +63,19 @@ export function PagadorInfoCard({
</span>
}
/>
<InfoItem
label="Envio automático"
value={pagador.isAutoSend ? "Ativado" : "Desativado"}
/>
{!pagador.email ? (
{showSensitiveDetails ? (
<InfoItem
label="Envio automático"
value={pagador.isAutoSend ? "Ativado" : "Desativado"}
/>
) : null}
{showSensitiveDetails ? (
<InfoItem
label="Último envio"
value={formatDateTime(pagador.lastMailAt) ?? "Nunca enviado"}
/>
) : null}
{showSensitiveDetails && !pagador.email ? (
<InfoItem
label="Aviso"
value={
@@ -254,219 +86,29 @@ export function PagadorInfoCard({
className="sm:col-span-2"
/>
) : null}
<InfoItem
label="Observações"
value={
pagador.note ? (
<span className="text-muted-foreground">{pagador.note}</span>
) : (
"Sem observações"
)
}
className="sm:col-span-2"
/>
{showSensitiveDetails ? (
<InfoItem
label="Observações"
value={
pagador.note ? (
<span className="text-muted-foreground">{pagador.note}</span>
) : (
"Sem observações"
)
}
className="sm:col-span-2"
/>
) : null}
</CardContent>
{pagador.canEdit ? (
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (isSending) return;
setConfirmOpen(open);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Confirmar envio do resumo</DialogTitle>
<DialogDescription>
Resumo de{" "}
<span className="font-semibold text-foreground">
{summary.periodLabel}
</span>{" "}
para{" "}
<span className="font-medium text-foreground">
{pagador.email}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Total Geral */}
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiExchangeDollarLine className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Total de Despesas
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(summary.totalExpenses)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">
{summary.lancamentoCount} lançamentos
</p>
</div>
</div>
</div>
{/* Grid de Formas de Pagamento */}
<div className="grid gap-3 sm:grid-cols-3">
{/* Cartões */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiBankCard2Line className="size-4" />
<span className="text-xs font-semibold uppercase">
Cartões
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.card)}
</p>
</div>
{/* Boletos */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiBillLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Boletos
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.boleto)}
</p>
</div>
{/* Instantâneo */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiExchangeDollarLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Pix/Débito
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.instant)}
</p>
</div>
</div>
{/* Detalhes Adicionais */}
<div className="space-y-3">
{/* Cartões Utilizados */}
{summary.cardUsage.length > 0 && (
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-2">
<RiBankCard2Line className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Cartões Utilizados
</span>
</div>
<div className="space-y-1">
{summary.cardUsage.map((card, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-foreground">{card.name}</span>
<span className="font-medium text-foreground">
{formatCurrency(card.amount)}
</span>
</div>
))}
</div>
</div>
)}
{/* Status de Boletos */}
{(summary.boletoStats.paidCount > 0 ||
summary.boletoStats.pendingCount > 0) && (
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-2">
<RiBillLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Status de Boletos
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Pagos</p>
<p className="text-sm font-semibold text-success">
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.paidCount})
</span>
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Pendentes
</p>
<p className="text-sm font-semibold text-warning">
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.pendingCount})
</span>
</p>
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSending}
onClick={() => setConfirmOpen(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleSendSummary}
disabled={disableSend}
>
{isSending ? "Enviando..." : "Confirmar envio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</Card>
);
}
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
const resolveRoleLabel = (role: string | null) => {
if (role === PAGADOR_ROLE_ADMIN) return "Administrador";
return "Pagador";
};
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
type InfoItemProps = {
label: string;
value: ReactNode;

View File

@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDateTime } from "@/lib/utils/date";
interface PagadorLeaveShareCardProps {
shareId: string;
@@ -37,11 +38,12 @@ export function PagadorLeaveShareCard({
});
};
const formattedDate = new Date(createdAt).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
const formattedDate =
formatDateTime(createdAt, {
day: "2-digit",
month: "long",
year: "numeric",
}) ?? "—";
return (
<Card className="border">

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details";
import { cn } from "@/lib/utils/ui";

View File

@@ -5,40 +5,17 @@ import {
RiWallet3Line,
} from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import { buildBillStatusLabel } from "@/lib/dashboard/bills-helpers";
import type {
PagadorBoletoItem,
PagadorPaymentStatusData,
} from "@/lib/pagadores/details";
import { cn } from "@/lib/utils/ui";
// --- Boleto helpers ---
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDateLabel = (value: string | null, prefix?: string) => {
if (!value) return null;
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) return null;
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day)),
);
return prefix ? `${prefix} ${formatted}` : formatted;
};
const buildStatusLabel = (item: PagadorBoletoItem) => {
if (item.isSettled) return buildDateLabel(item.boletoPaymentDate, "Pago em");
return buildDateLabel(item.dueDate, "Vence em");
};
// --- PagadorBoletoCard ---
type PagadorBoletoCardProps = {
@@ -62,7 +39,7 @@ export function PagadorBoletoCard({ items }: PagadorBoletoCardProps) {
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{items.map((item) => {
const statusLabel = buildStatusLabel(item);
const statusLabel = buildBillStatusLabel(item);
return (
<li
key={item.id}

View File

@@ -79,7 +79,7 @@ export function PagadorSharingCard({
return (
<Card className="border">
<CardHeader>
<CardTitle className="text-base font-semibold">
<CardTitle className="text-lg font-semibold">
Compartilhamentos
</CardTitle>
<p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,33 @@
export type PagadorInfo = {
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
status: string;
note: string | null;
role: string | null;
isAutoSend: boolean;
createdAt: string;
lastMailAt: string | null;
shareCode: string | null;
canEdit: boolean;
};
export type PagadorSummaryPreview = {
periodLabel: string;
totalExpenses: number;
paymentSplits: {
card: number;
boleto: number;
instant: number;
};
cardUsage: { name: string; amount: number }[];
boletoStats: {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
lancamentoCount: number;
};

View File

@@ -9,7 +9,6 @@ import {
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
@@ -23,11 +22,7 @@ interface PagadorCardProps {
}
export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
const avatarSrc = useMemo(
() => getAvatarSrc(pagador.avatarUrl),
[pagador.avatarUrl],
);
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const isReadOnly = !pagador.canEdit;

View File

@@ -1,13 +1,13 @@
"use client";
import DotIcon from "@/components/dot-icon";
import StatusDot from "@/components/shared/status-dot";
export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativo";
return (
<span className="flex items-center gap-2">
<DotIcon
<StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/>
<span>{label}</span>

View File

@@ -2,15 +2,15 @@
import { RiAddCircleLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deletePagadorAction,
joinPagadorByShareCodeAction,
} from "@/app/(dashboard)/pagadores/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { PagadorCard } from "@/components/pagadores/pagador-card";
import { PagadorDialog } from "@/components/pagadores/pagador-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
@@ -49,35 +49,35 @@ export function PagadoresPage({
[pagadores],
);
const handleEdit = useCallback((pagador: Pagador) => {
const handleEdit = (pagador: Pagador) => {
setSelectedPagador(pagador);
setEditOpen(true);
}, []);
};
const handleEditOpenChange = useCallback((open: boolean) => {
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedPagador(null);
}
}, []);
};
const handleRemoveRequest = useCallback((pagador: Pagador) => {
const handleRemoveRequest = (pagador: Pagador) => {
if (pagador.role === PAGADOR_ROLE_ADMIN) {
toast.error("Pagadores administradores não podem ser removidos.");
return;
}
setPagadorToRemove(pagador);
setRemoveOpen(true);
}, []);
};
const handleRemoveOpenChange = useCallback((open: boolean) => {
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setPagadorToRemove(null);
}
}, []);
};
const handleRemoveConfirm = useCallback(async () => {
const handleRemoveConfirm = async () => {
if (!pagadorToRemove) {
return;
}
@@ -91,37 +91,34 @@ export function PagadoresPage({
toast.error(result.error);
throw new Error(result.error);
}, [pagadorToRemove]);
};
const removeTitle = pagadorToRemove
? `Remover pagador "${pagadorToRemove.name}"?`
: "Remover pagador?";
const handleJoinByCode = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!shareCodeInput.trim()) {
toast.error("Informe um código válido.");
const handleJoinByCode = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!shareCodeInput.trim()) {
toast.error("Informe um código válido.");
return;
}
startJoin(async () => {
const result = await joinPagadorByShareCodeAction({
code: shareCodeInput.trim(),
});
if (!result.success) {
toast.error(result.error);
return;
}
startJoin(async () => {
const result = await joinPagadorByShareCodeAction({
code: shareCodeInput.trim(),
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setShareCodeInput("");
router.refresh();
});
},
[shareCodeInput, router],
);
toast.success(result.message);
setShareCodeInput("");
router.refresh();
});
};
return (
<>

View File

@@ -10,8 +10,7 @@ import {
import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useMemo } from "react";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -28,6 +27,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { resolveLogoSrc } from "@/lib/logo";
import type { InboxItem } from "./types";
interface InboxCardProps {
@@ -41,17 +41,6 @@ interface InboxCardProps {
onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
}
function resolveLogoPath(logo: string): string {
if (
logo.startsWith("http") ||
logo.startsWith("data:") ||
logo.startsWith("/")
) {
return logo;
}
return `/logos/${logo}`;
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
@@ -61,12 +50,12 @@ function findMatchingLogo(
const appName = sourceAppName.toLowerCase();
// Exact match first
if (appLogoMap[appName]) return resolveLogoPath(appLogoMap[appName]);
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
// Partial match: card/account name contains app name or vice versa
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoPath(logo);
return resolveLogoSrc(logo);
}
}
@@ -83,11 +72,9 @@ export function InboxCard({
onDelete,
onRestoreToPending,
}: InboxCardProps) {
const matchedLogo = useMemo(
() =>
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null,
[item.sourceAppName, appLogoMap],
);
const matchedLogo = appLogoMap
? findMatchingLogo(item.sourceAppName, appLogoMap)
: null;
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;

View File

@@ -2,7 +2,7 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {

View File

@@ -1,7 +1,7 @@
"use client";
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
bulkDeleteInboxItemsAction,
@@ -10,8 +10,8 @@ import {
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/app/(dashboard)/pre-lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -67,66 +67,71 @@ export function InboxPage({
"processed" | "discarded"
>("processed");
const sortByTimestamp = useCallback(
(list: InboxItem[]) =>
[...list].sort(
const sortedPending = useMemo(
() =>
[...pendingItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[],
);
const sortedPending = useMemo(
() => sortByTimestamp(pendingItems),
[pendingItems, sortByTimestamp],
[pendingItems],
);
const sortedProcessed = useMemo(
() => sortByTimestamp(processedItems),
[processedItems, sortByTimestamp],
() =>
[...processedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[processedItems],
);
const sortedDiscarded = useMemo(
() => sortByTimestamp(discardedItems),
[discardedItems, sortByTimestamp],
() =>
[...discardedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[discardedItems],
);
const handleProcessOpenChange = useCallback((open: boolean) => {
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) {
setItemToProcess(null);
}
}, []);
};
const handleDetailsOpenChange = useCallback((open: boolean) => {
const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open);
if (!open) {
setItemDetails(null);
}
}, []);
};
const handleDiscardOpenChange = useCallback((open: boolean) => {
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) {
setItemToDiscard(null);
}
}, []);
};
const handleProcessRequest = useCallback((item: InboxItem) => {
const handleProcessRequest = (item: InboxItem) => {
setItemToProcess(item);
setProcessOpen(true);
}, []);
};
const handleDetailsRequest = useCallback((item: InboxItem) => {
const handleDetailsRequest = (item: InboxItem) => {
setItemDetails(item);
setDetailsOpen(true);
}, []);
};
const handleDiscardRequest = useCallback((item: InboxItem) => {
const handleDiscardRequest = (item: InboxItem) => {
setItemToDiscard(item);
setDiscardOpen(true);
}, []);
};
const handleDiscardConfirm = useCallback(async () => {
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
@@ -140,21 +145,21 @@ export function InboxPage({
toast.error(result.error);
throw new Error(result.error);
}, [itemToDiscard]);
};
const handleDeleteOpenChange = useCallback((open: boolean) => {
const handleDeleteOpenChange = (open: boolean) => {
setDeleteOpen(open);
if (!open) {
setItemToDelete(null);
}
}, []);
};
const handleDeleteRequest = useCallback((item: InboxItem) => {
const handleDeleteRequest = (item: InboxItem) => {
setItemToDelete(item);
setDeleteOpen(true);
}, []);
};
const handleDeleteConfirm = useCallback(async () => {
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
const result = await deleteInboxItemAction({
@@ -168,21 +173,21 @@ export function InboxPage({
toast.error(result.error);
throw new Error(result.error);
}, [itemToDelete]);
};
const handleRestoreOpenChange = useCallback((open: boolean) => {
const handleRestoreOpenChange = (open: boolean) => {
setRestoreOpen(open);
if (!open) {
setItemToRestore(null);
}
}, []);
};
const handleRestoreRequest = useCallback((item: InboxItem) => {
const handleRestoreRequest = (item: InboxItem) => {
setItemToRestore(item);
setRestoreOpen(true);
}, []);
};
const handleRestoreToPendingConfirm = useCallback(async () => {
const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return;
const result = await restoreDiscardedInboxItemAction({
@@ -196,21 +201,18 @@ export function InboxPage({
toast.error(result.error);
throw new Error(result.error);
}, [itemToRestore]);
};
const handleBulkDeleteOpenChange = useCallback((open: boolean) => {
const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open);
}, []);
};
const handleBulkDeleteRequest = useCallback(
(status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
},
[],
);
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
};
const handleBulkDeleteConfirm = useCallback(async () => {
const handleBulkDeleteConfirm = async () => {
const result = await bulkDeleteInboxItemsAction({
status: bulkDeleteStatus,
});
@@ -222,9 +224,9 @@ export function InboxPage({
toast.error(result.error);
throw new Error(result.error);
}, [bulkDeleteStatus]);
};
const handleLancamentoSuccess = useCallback(async () => {
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
@@ -236,7 +238,7 @@ export function InboxPage({
} else {
toast.error(result.error);
}
}, [itemToProcess]);
};
// Prepare default values from inbox item
const getDateString = (