refactor(dashboard): reorganiza widgets e remove magnet-lines

This commit is contained in:
Felipe Coutinho
2026-03-09 17:12:44 +00:00
parent 3e06a1d056
commit 69da27276c
106 changed files with 6072 additions and 3601 deletions

View File

@@ -54,7 +54,6 @@ const deleteAccountSchema = z.object({
});
const updatePreferencesSchema = z.object({
disableMagnetlines: z.boolean(),
extratoNoteAsColumn: z.boolean(),
lancamentosColumnOrder: z.array(z.string()).nullable(),
systemFont: z.enum(FONT_KEYS).default(DEFAULT_FONT_KEY),
@@ -403,7 +402,6 @@ export async function updatePreferencesAction(
await db
.update(schema.preferenciasUsuario)
.set({
disableMagnetlines: validated.disableMagnetlines,
extratoNoteAsColumn: validated.extratoNoteAsColumn,
lancamentosColumnOrder: validated.lancamentosColumnOrder,
systemFont: validated.systemFont,
@@ -415,7 +413,6 @@ export async function updatePreferencesAction(
// Create new preferences
await db.insert(schema.preferenciasUsuario).values({
userId: session.user.id,
disableMagnetlines: validated.disableMagnetlines,
extratoNoteAsColumn: validated.extratoNoteAsColumn,
lancamentosColumnOrder: validated.lancamentosColumnOrder,
systemFont: validated.systemFont,

View File

@@ -4,7 +4,6 @@ import { db, schema } from "@/lib/db";
import { type FontKey, normalizeFontKey } from "@/public/fonts/font_index";
export interface UserPreferences {
disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: FontKey;
@@ -34,7 +33,6 @@ export async function fetchUserPreferences(
): Promise<UserPreferences | null> {
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
systemFont: schema.preferenciasUsuario.systemFont,

View File

@@ -67,9 +67,6 @@ export default async function Page() {
</p>
</div>
<PreferencesForm
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
extratoNoteAsColumn={
userPreferences?.extratoNoteAsColumn ?? false
}

View File

@@ -2,7 +2,6 @@ import { eq } from "drizzle-orm";
import { db, schema } from "@/lib/db";
export interface UserDashboardPreferences {
disableMagnetlines: boolean;
dashboardWidgets: string | null;
}
@@ -11,7 +10,6 @@ export async function fetchUserDashboardPreferences(
): Promise<UserDashboardPreferences> {
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
dashboardWidgets: schema.preferenciasUsuario.dashboardWidgets,
})
.from(schema.preferenciasUsuario)
@@ -19,7 +17,6 @@ export async function fetchUserDashboardPreferences(
.limit(1);
return {
disableMagnetlines: result[0]?.disableMagnetlines ?? false,
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
};
}

View File

@@ -4,8 +4,10 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardLoading() {
return (
<main className="flex flex-col gap-4">
{/* Welcome Banner skeleton */}
<Skeleton className="h-[104px] w-full rounded-xl bg-foreground/10" />
<div className="space-y-2 px-1 py-2">
<Skeleton className="h-8 w-72 rounded-xl bg-foreground/10" />
<Skeleton className="h-5 w-56 rounded-xl bg-foreground/10" />
</div>
{/* Month Picker skeleton */}
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />

View File

@@ -1,6 +1,6 @@
import { DashboardGridEditable } from "@/components/dashboard/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/components/dashboard/dashboard-metrics-cards";
import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome";
import { SectionCards } from "@/components/dashboard/section-cards";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
@@ -41,7 +41,7 @@ export default async function Page({ searchParams }: PageProps) {
fetchLancamentoFilterSources(user.id),
getRecentEstablishmentsAction(),
]);
const { disableMagnetlines, dashboardWidgets } = preferences;
const { dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
@@ -57,12 +57,9 @@ export default async function Page({ searchParams }: PageProps) {
return (
<main className="flex flex-col gap-4">
<DashboardWelcome
name={user.name}
disableMagnetlines={disableMagnetlines}
/>
<DashboardWelcome name={user.name} />
<MonthNavigation />
<SectionCards metrics={dashboardData.metrics} />
<DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}

View File

@@ -21,7 +21,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { useFont } from "@/components/font-provider";
import { useFont } from "@/components/providers/font-provider";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
@@ -39,7 +39,6 @@ import {
import { FONT_OPTIONS } from "@/public/fonts/font_index";
interface PreferencesFormProps {
disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: string;
@@ -84,7 +83,6 @@ function SortableColumnItem({ id }: { id: string }) {
}
export function PreferencesForm({
disableMagnetlines,
extratoNoteAsColumn: initialExtratoNoteAsColumn,
lancamentosColumnOrder: initialColumnOrder,
systemFont: initialSystemFont,
@@ -92,8 +90,6 @@ export function PreferencesForm({
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
const [extratoNoteAsColumn, setExtratoNoteAsColumn] = useState(
initialExtratoNoteAsColumn,
);
@@ -138,7 +134,6 @@ export function PreferencesForm({
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
extratoNoteAsColumn,
lancamentosColumnOrder: columnOrder,
systemFont: selectedSystemFont,
@@ -274,35 +269,6 @@ export function PreferencesForm({
</div>
</section>
<div className="border-b" />
{/* Seção: Dashboard */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Dashboard</h3>
<p className="text-sm text-muted-foreground">
Opções que afetam a experiência no painel principal.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
</section>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}

View File

@@ -0,0 +1,35 @@
"use client";
import type { DashboardBill } from "@/lib/dashboard/bills";
import { useBillWidgetController } from "@/lib/dashboard/use-bill-widget-controller";
import { BillsWidgetView } from "./bills/bills-widget-view";
type BillWidgetProps = {
bills?: DashboardBill[];
};
export function BillWidget({ bills }: BillWidgetProps) {
const {
items,
selectedBill,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
} = useBillWidgetController(bills);
return (
<BillsWidgetView
bills={items}
selectedBill={selectedBill}
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}
/>
);
}

View File

@@ -0,0 +1,73 @@
import { RiCheckboxCircleFill } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/shared/money-values";
import { Button } from "@/components/ui/button";
import type { DashboardBill } from "@/lib/dashboard/bills";
import {
buildBillStatusLabel,
isBillOverdue,
} from "@/lib/dashboard/bills-helpers";
import { cn } from "@/lib/utils/ui";
type BillListItemProps = {
bill: DashboardBill;
onPay: (billId: string) => void;
};
export function BillListItem({ bill, onPay }: BillListItemProps) {
const statusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill);
return (
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<EstabelecimentoLogo name={bill.name} size={37} />
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{bill.name}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={bill.amount} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={bill.isSettled}
onClick={() => onPay(bill.id)}
>
{bill.isSettled ? (
<span className="flex items-center gap-1 text-success">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : overdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">Pagar</span>
</span>
) : (
"Pagar"
)}
</Button>
</div>
</li>
);
}

View File

@@ -0,0 +1,189 @@
import {
RiBarcodeFill,
RiCheckboxCircleLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { DashboardBill } from "@/lib/dashboard/bills";
import {
type BillDialogState,
formatBillDateLabel,
getBillStatusBadgeVariant,
} from "@/lib/dashboard/bills-helpers";
type BillPaymentDialogProps = {
bill: DashboardBill | null;
open: boolean;
modalState: BillDialogState;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function BillPaymentDialog({
bill,
open,
modalState,
isPending,
onClose,
onConfirm,
}: BillPaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending;
const dueLabel = bill
? formatBillDateLabel(bill.dueDate, "Vencimento:")
: null;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || isProcessing) {
return;
}
onClose();
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento registrado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status do boleto para pago. Em instantes ele
aparecerá como baixado no histórico.
</DialogDescription>
</div>
<DialogFooter className="sm:justify-center">
<Button type="button" onClick={onClose} className="sm:w-auto">
Fechar
</Button>
</DialogFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
<DialogDescription>
Confirme os dados para registrar o pagamento. Você poderá editar
o lançamento depois, se necessário.
</DialogDescription>
</DialogHeader>
{bill ? (
<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">
<RiBarcodeFill className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Boleto
</p>
<p className="text-lg font-bold text-foreground">
{bill.name}
</p>
</div>
</div>
{dueLabel ? (
<div className="text-right">
<p className="text-sm text-muted-foreground">
{dueLabel}
</p>
</div>
) : null}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor do Boleto
</span>
</div>
<MoneyValues
amount={bill.amount}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getBillStatusBadgeVariant(
bill.isSettled ? "Pago" : "Pendente",
)}
>
{bill.isSettled ? "Pago" : "Pendente"}
</Badge>
</div>
</div>
</div>
) : null}
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={isProcessing || !bill || bill.isSettled}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,29 @@
import { RiBarcodeFill } from "@remixicon/react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { DashboardBill } from "@/lib/dashboard/bills";
import { BillListItem } from "./bill-list-item";
type BillsListProps = {
bills: DashboardBill[];
onPay: (billId: string) => void;
};
export function BillsList({ bills, onPay }: BillsListProps) {
if (bills.length === 0) {
return (
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
/>
);
}
return (
<ul className="flex flex-col">
{bills.map((bill) => (
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
))}
</ul>
);
}

View File

@@ -0,0 +1,43 @@
import type { DashboardBill } from "@/lib/dashboard/bills";
import type { BillDialogState } from "@/lib/dashboard/bills-helpers";
import { BillPaymentDialog } from "./bill-payment-dialog";
import { BillsList } from "./bills-list";
type BillsWidgetViewProps = {
bills: DashboardBill[];
selectedBill: DashboardBill | null;
isModalOpen: boolean;
modalState: BillDialogState;
isPending: boolean;
onOpenPaymentDialog: (billId: string) => void;
onClosePaymentDialog: () => void;
onConfirmPayment: () => void;
};
export function BillsWidgetView({
bills,
selectedBill,
isModalOpen,
modalState,
isPending,
onOpenPaymentDialog,
onClosePaymentDialog,
onConfirmPayment,
}: BillsWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4">
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
</div>
<BillPaymentDialog
bill={selectedBill}
open={isModalOpen}
modalState={modalState}
isPending={isPending}
onClose={onClosePaymentDialog}
onConfirm={onConfirmPayment}
/>
</>
);
}

View File

@@ -1,388 +0,0 @@
"use client";
import {
RiBarcodeFill,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
} from "@/components/ui/dialog";
import type { DashboardBoleto } from "@/lib/dashboard/boletos";
import { cn } from "@/lib/utils/ui";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
type BoletosWidgetProps = {
boletos: DashboardBoleto[];
};
type ModalState = "idle" | "processing" | "success";
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 = (boleto: DashboardBoleto) => {
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
return buildDateLabel(boleto.dueDate, "Vence em");
};
const getTodayDateString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export function BoletosWidget({ boletos }: BoletosWidgetProps) {
const router = useRouter();
const [items, setItems] = useState(boletos);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [isPending, startTransition] = useTransition();
useEffect(() => {
setItems(boletos);
}, [boletos]);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId],
);
const isProcessing = modalState === "processing" || isPending;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
setModalState("processing");
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
setItems((previous) =>
previous.map((boleto) =>
boleto.id === selectedBoleto.id
? {
...boleto,
isSettled: true,
boletoPaymentDate: getTodayDateString(),
}
: boleto,
),
);
toast.success(result.message);
router.refresh();
setModalState("success");
});
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "pendente") {
return "info";
}
return "success";
};
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((boleto) => {
const statusLabel = buildStatusLabel(boleto);
const isOverdue = (() => {
if (boleto.isSettled || !boleto.dueDate) return false;
const [y, m, d] = boleto.dueDate.split("-").map(Number);
if (!y || !m || !d) return false;
return new Date(Date.UTC(y, m - 1, d)) < new Date();
})();
return (
<li
key={boleto.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3 py-2">
<EstabelecimentoLogo name={boleto.name} size={37} />
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{boleto.name}
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"rounded-full py-0.5",
boleto.isSettled && "text-success",
)}
>
{statusLabel}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={boleto.amount} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={boleto.isSettled}
onClick={() => handleOpenModal(boleto.id)}
>
{boleto.isSettled ? (
<span className="flex items-center gap-1 text-success">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">Pagar</span>
</span>
) : (
"Pagar"
)}
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
if (isProcessing) {
return;
}
resetModalState();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
return;
}
resetModalState();
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento registrado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status do boleto para pago. Em instantes ele
aparecerá como baixado no histórico.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={resetModalState}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
<DialogDescription>
Confirme os dados para registrar o pagamento. Você poderá
editar o lançamento depois, se necessário.
</DialogDescription>
</DialogHeader>
{selectedBoleto ? (
<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">
<RiBarcodeFill className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Boleto
</p>
<p className="text-lg font-bold text-foreground">
{selectedBoleto.name}
</p>
</div>
</div>
{selectedBoletoDueLabel ? (
<div className="text-right">
<p className="text-sm text-muted-foreground">
{selectedBoletoDueLabel}
</p>
</div>
) : null}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor do Boleto
</span>
</div>
<MoneyValues
amount={selectedBoleto.amount}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getStatusBadgeVariant(
selectedBoleto.isSettled ? "Pago" : "Pendente",
)}
>
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
</Badge>
</div>
</div>
</div>
) : null}
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={resetModalState}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={
isProcessing || !selectedBoleto || selectedBoleto.isSettled
}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,400 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { DashboardCategoryBreakdownData } from "@/lib/dashboard/categories/category-breakdown";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import { formatPercentage as formatPercentageValue } from "@/lib/utils/percentage";
import { formatPeriodForUrl } from "@/lib/utils/period";
type CategoryBreakdownVariant = "income" | "expense";
type CategoryBreakdownWidgetViewProps = {
data: DashboardCategoryBreakdownData;
period: string;
variant: CategoryBreakdownVariant;
};
const CATEGORY_BREAKDOWN_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
const VARIANT_CONFIG = {
income: {
emptyTitle: "Nenhuma receita encontrada",
emptyDescription:
"Quando houver receitas registradas, elas aparecerão aqui.",
shareLabel: "receita total",
percentageDigits: 1,
changeClassName: {
increase: "text-success",
decrease: "text-destructive",
},
listItemClassName:
"flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0",
includeBudgetAmount: true,
},
expense: {
emptyTitle: "Nenhuma despesa encontrada",
emptyDescription:
"Quando houver despesas registradas, elas aparecerão aqui.",
shareLabel: "despesa total",
percentageDigits: 0,
changeClassName: {
increase: "text-destructive",
decrease: "text-success",
},
listItemClassName:
"flex flex-col py-2 border-b border-dashed last:border-0",
includeBudgetAmount: false,
},
} as const;
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
export function CategoryBreakdownWidgetView({
data,
period,
variant,
}: CategoryBreakdownWidgetViewProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
const config = VARIANT_CONFIG[variant];
const chartConfig = useMemo(() => {
const nextConfig: ChartConfig = {};
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
} else {
const topCategories = data.categories.slice(0, 7);
topCategories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
nextConfig.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return nextConfig;
}, [data.categories]);
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
const topCategories = data.categories.slice(0, 7);
const otherCategories = data.categories.slice(7);
const otherTotal = otherCategories.reduce(
(sum, category) => sum + category.currentAmount,
0,
);
const otherPercentage = otherCategories.reduce(
(sum, category) => sum + category.percentageOfTotal,
0,
);
const groupedData = topCategories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
if (otherCategories.length > 0) {
groupedData.push({
category: "outros",
name: "Outros",
value: otherTotal,
percentage: otherPercentage,
fill: chartConfig.outros?.color,
});
}
return groupedData;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title={config.emptyTitle}
description={config.emptyDescription}
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(value: string) => setActiveTab(value as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="mr-1 size-3.5" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="mr-1 size-3.5" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const changeClassName = hasIncrease
? config.changeClassName.increase
: hasDecrease
? config.changeClassName.decrease
: "text-muted-foreground";
return (
<div
key={category.categoryId}
className={config.listItemClassName}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
colorIndex={index}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(
category.percentageOfTotal,
config.percentageDigits,
)}{" "}
da {config.shareLabel}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null ? (
<span
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
>
{hasIncrease ? (
<RiArrowUpSFill className="size-3" />
) : null}
{hasDecrease ? (
<RiArrowDownSFill className="size-3" />
) : null}
{formatPercentage(
category.percentageChange,
config.percentageDigits,
)}
</span>
) : null}
</div>
</div>
{hasBudget && category.budgetUsedPercentage !== null ? (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded ? "text-destructive" : "text-info"
}`}
/>
<span
className={
budgetExceeded ? "text-destructive" : "text-info"
}
>
{budgetExceeded ? (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}{" "}
- excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</>
)}
</span>
</div>
) : null}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ payload }) =>
formatPercentage(
(payload as { percentage?: number } | undefined)
?.percentage ?? 0,
config.percentageDigits,
)
}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) {
return null;
}
const entry = payload[0]?.payload;
if (!entry) {
return null;
}
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{entry.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(entry.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(
entry.percentage,
config.percentageDigits,
)}{" "}
do total
</span>
</div>
</div>
</div>
);
}}
/>
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.fill }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -6,6 +6,7 @@ import {
} from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -26,9 +27,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
import { formatCurrency, formatCurrencyCompact } from "@/lib/utils/currency";
import { getIconComponent } from "@/lib/utils/icons";
type CategoryHistoryWidgetProps = {
@@ -124,33 +125,6 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
return config;
}, [filteredCategories]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatCurrencyCompact = (value: number) => {
if (value >= 1000) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: "compact",
}).format(value);
}
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
@@ -217,7 +191,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
<span style={{ color }}>
<IconComponent className="size-4" />
</span>
) : (
<div
className="size-3 rounded-sm"
@@ -383,7 +359,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
tickFormatter={(value) => formatCurrencyCompact(Number(value))}
/>
<ChartTooltip
content={({ active, payload }) => {

View File

@@ -23,15 +23,15 @@ import {
RiEyeOffLine,
RiTodoLine,
} from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import { SortableWidget } from "@/components/dashboard/sortable-widget";
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import type { SelectOption } from "@/components/lancamentos/types";
import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card";
import { Button } from "@/components/ui/button";
import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
resetWidgetPreferences,
@@ -58,6 +58,8 @@ type DashboardGridEditableProps = {
};
};
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
export function DashboardGridEditable({
data,
period,
@@ -68,9 +70,8 @@ export function DashboardGridEditable({
const [isPending, startTransition] = useTransition();
// Initialize widget order and hidden state
const defaultOrder = widgetsConfig.map((w) => w.id);
const [widgetOrder, setWidgetOrder] = useState<string[]>(
initialPreferences?.order ?? defaultOrder,
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
);
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
@@ -118,7 +119,7 @@ export function DashboardGridEditable({
return ordered;
}, [widgetOrder, hiddenWidgets]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
@@ -128,44 +129,41 @@ export function DashboardGridEditable({
return arrayMove(items, oldIndex, newIndex);
});
}
}, []);
};
const handleToggleWidget = useCallback(
(widgetId: string) => {
const newHidden = hiddenWidgets.includes(widgetId)
? hiddenWidgets.filter((id) => id !== widgetId)
: [...hiddenWidgets, widgetId];
const handleToggleWidget = (widgetId: string) => {
const newHidden = hiddenWidgets.includes(widgetId)
? hiddenWidgets.filter((id) => id !== widgetId)
: [...hiddenWidgets, widgetId];
setHiddenWidgets(newHidden);
setHiddenWidgets(newHidden);
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
},
[hiddenWidgets, widgetOrder],
);
});
};
const handleHideWidget = useCallback((widgetId: string) => {
const handleHideWidget = (widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);
}, []);
};
const handleStartEditing = useCallback(() => {
const handleStartEditing = () => {
setOriginalOrder(widgetOrder);
setOriginalHidden(hiddenWidgets);
setIsEditing(true);
}, [widgetOrder, hiddenWidgets]);
};
const handleCancelEditing = useCallback(() => {
const handleCancelEditing = () => {
setWidgetOrder(originalOrder);
setHiddenWidgets(originalHidden);
setIsEditing(false);
}, [originalOrder, originalHidden]);
};
const handleSave = useCallback(() => {
const handleSave = () => {
startTransition(async () => {
const result = await updateWidgetPreferences({
order: widgetOrder,
@@ -179,21 +177,21 @@ export function DashboardGridEditable({
toast.error(result.error ?? "Erro ao salvar");
}
});
}, [widgetOrder, hiddenWidgets]);
};
const handleReset = useCallback(() => {
const handleReset = () => {
startTransition(async () => {
const result = await resetWidgetPreferences();
if (result.success) {
setWidgetOrder(defaultOrder);
setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
}
});
}, [defaultOrder]);
};
return (
<div className="space-y-4">
@@ -360,14 +358,14 @@ export function DashboardGridEditable({
</div>
</div>
)}
<WidgetCard
<ExpandableWidgetCard
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
</ExpandableWidgetCard>
</div>
</SortableWidget>
))}

View File

@@ -7,6 +7,7 @@ import {
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import MoneyValues from "@/components/shared/money-values";
import {
Card,
CardAction,
@@ -14,10 +15,10 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import MoneyValues from "../money-values";
import type { DashboardCardMetrics } from "@/lib/dashboard/dashboard-metrics";
import { formatPercentage } from "@/lib/utils/percentage";
type SectionCardsProps = {
type DashboardMetricsCardsProps = {
metrics: DashboardCardMetrics;
};
@@ -70,7 +71,11 @@ const getPercentChange = (current: number, previous: number): string => {
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
? formatPercentage(change, {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
signDisplay: "always",
})
: "—";
};
@@ -82,7 +87,7 @@ const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
: "text-destructive border-destructive";
};
export function SectionCards({ metrics }: SectionCardsProps) {
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
@@ -94,8 +99,8 @@ export function SectionCards({ metrics }: SectionCardsProps) {
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4 text-primary" />
<CardTitle className="flex items-center gap-1 font-[aeonik] tracking-tighter lowercase">
<Icon className="size-4" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
@@ -108,9 +113,9 @@ export function SectionCards({ metrics }: SectionCardsProps) {
</CardHeader>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
mês anterior
</div>
<div className="text-muted-foreground">
<div className="text-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>

View File

@@ -1,79 +1,18 @@
"use client";
import { formatCurrentDate, getGreeting } from "./welcome-widget";
import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
disableMagnetlines?: boolean;
};
const capitalizeFirstLetter = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
const formatCurrentDate = (date = new Date()) => {
const formatted = new Intl.DateTimeFormat("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(date);
return capitalizeFirstLetter(formatted);
};
const getGreeting = () => {
const now = new Date();
// Get hour in Brasilia timezone
const brasiliaHour = new Intl.DateTimeFormat("pt-BR", {
hour: "numeric",
hour12: false,
timeZone: "America/Sao_Paulo",
}).format(now);
const hour = parseInt(brasiliaHour, 10);
if (hour >= 5 && hour < 12) {
return "Bom dia";
} else if (hour >= 12 && hour < 18) {
return "Boa tarde";
} else {
return "Boa noite";
}
};
export function DashboardWelcome({
name,
disableMagnetlines = false,
}: DashboardWelcomeProps) {
export function DashboardWelcome({ name }: { name?: string | null }) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
return (
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines
rows={8}
columns={16}
containerSize="100%"
lineColor="currentColor"
lineWidth="0.4vmin"
lineHeight="5vmin"
baseAngle={0}
className="text-welcome-banner-foreground"
disabled={disableMagnetlines}
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">
<h1 className="text-xl">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
<section className="p-2">
<div className="tracking-tight">
<h1 className="text-xl font-[aeonik]">
{greeting}, {displayName}
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
<p className="text-sm mt-1">{formattedDate}</p>
</div>
</Card>
</section>
);
}

View File

@@ -1,328 +1,22 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function ExpensesByCategoryWidgetWithChart({
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
colorIndex={index}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
despesa total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-destructive"
: hasDecrease
? "text-success"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-3" />}
{hasDecrease && <RiArrowDownSFill className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget && category.budgetUsedPercentage !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded ? "text-destructive" : "text-info"
}`}
/>
<span
className={
budgetExceeded ? "text-destructive" : "text-info"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite - excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
<CategoryBreakdownWidgetView
data={data}
period={period}
variant="expense"
/>
);
}

View File

@@ -1,146 +1,32 @@
"use client";
import { RiFundsLine, RiPencilLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { GoalsProgressData } from "@/lib/dashboard/goals-progress";
import { WidgetEmptyState } from "../widget-empty-state";
import { useGoalsProgressWidgetController } from "@/lib/dashboard/use-goals-progress-widget-controller";
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
type GoalsProgressWidgetProps = {
data: GoalsProgressData;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const formatPercentage = (value: number, withSign = false) =>
`${new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
...(withSign ? { signDisplay: "always" as const } : {}),
}).format(value)}%`;
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
const categories = useMemo<BudgetCategory[]>(
() =>
data.categories.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
})),
[data.categories],
);
const defaultPeriod = data.items[0]?.period ?? "";
const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => {
setSelectedBudget({
id: item.id,
amount: item.budgetAmount,
spent: item.spentAmount,
period: item.period,
createdAt: item.createdAt,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
icon: item.categoryIcon,
}
: null,
});
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedBudget(null);
}
}, []);
if (data.items.length === 0) {
return (
<WidgetEmptyState
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
title="Nenhum orçamento para o período"
description="Cadastre orçamentos para acompanhar o progresso das metas."
/>
);
}
const {
selectedBudget,
editOpen,
categories,
defaultPeriod,
handleEdit,
handleEditOpenChange,
} = useGoalsProgressWidgetController(data);
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col">
{data.items.map((item, index) => {
const statusColor =
item.status === "exceeded" ? "text-destructive" : "";
const progressValue = clamp(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
return (
<li
key={item.id}
className="border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
icon={item.categoryIcon}
name={item.categoryName}
colorIndex={index}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`text-xs font-medium ${statusColor}`}>
{formatPercentage(percentageDelta, true)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-1.5 ml-11">
<Progress value={progressValue} />
</div>
</li>
);
})}
</ul>
<BudgetDialog
mode="update"
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={defaultPeriod}
open={editOpen && !!selectedBudget}
onOpenChange={handleEditOpenChange}
/>
</div>
<GoalsProgressWidgetView
data={data}
selectedBudget={selectedBudget}
editOpen={editOpen}
categories={categories}
defaultPeriod={defaultPeriod}
onEdit={handleEdit}
onEditOpenChange={handleEditOpenChange}
/>
);
}

View File

@@ -0,0 +1,70 @@
import { RiPencilLine } from "@remixicon/react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/shared/money-values";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { GoalProgressItem as GoalProgressItemData } from "@/lib/dashboard/goals-progress";
import {
clampGoalProgress,
formatGoalProgressPercentage,
getGoalProgressStatusColorClass,
} from "@/lib/dashboard/goals-progress-helpers";
type GoalProgressItemProps = {
item: GoalProgressItemData;
index: number;
onEdit: (item: GoalProgressItemData) => void;
};
export function GoalProgressItem({
item,
index,
onEdit,
}: GoalProgressItemProps) {
const statusColor = getGoalProgressStatusColorClass(item.status);
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
return (
<li className="border-b border-dashed py-2 last:border-b-0 last:pb-0">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
icon={item.categoryIcon}
name={item.categoryName}
colorIndex={index}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={`text-xs font-medium ${statusColor}`}>
{formatGoalProgressPercentage(percentageDelta, true)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(item)}
aria-label={`Editar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</div>
<div className="ml-11 mt-1.5">
<Progress value={progressValue} />
</div>
</li>
);
}

View File

@@ -0,0 +1,34 @@
import { RiFundsLine } from "@remixicon/react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { GoalProgressItem } from "@/lib/dashboard/goals-progress";
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
type GoalsProgressListProps = {
items: GoalProgressItem[];
onEdit: (item: GoalProgressItem) => void;
};
export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
if (items.length === 0) {
return (
<WidgetEmptyState
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
title="Nenhum orçamento para o período"
description="Cadastre orçamentos para acompanhar o progresso das metas."
/>
);
}
return (
<ul className="flex flex-col">
{items.map((item, index) => (
<GoalProgressListItem
key={item.id}
item={item}
index={index}
onEdit={onEdit}
/>
))}
</ul>
);
}

View File

@@ -0,0 +1,29 @@
import { BudgetDialog } from "@/components/orcamentos/budget-dialog";
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
type GoalsProgressWidgetDialogsProps = {
selectedBudget: Budget | null;
editOpen: boolean;
categories: BudgetCategory[];
defaultPeriod: string;
onEditOpenChange: (open: boolean) => void;
};
export function GoalsProgressWidgetDialogs({
selectedBudget,
editOpen,
categories,
defaultPeriod,
onEditOpenChange,
}: GoalsProgressWidgetDialogsProps) {
return (
<BudgetDialog
mode="update"
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={defaultPeriod}
open={editOpen && !!selectedBudget}
onOpenChange={onEditOpenChange}
/>
);
}

View File

@@ -0,0 +1,41 @@
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import type {
GoalProgressItem,
GoalsProgressData,
} from "@/lib/dashboard/goals-progress";
import { GoalsProgressList } from "./goals-progress-list";
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
type GoalsProgressWidgetViewProps = {
data: GoalsProgressData;
selectedBudget: Budget | null;
editOpen: boolean;
categories: BudgetCategory[];
defaultPeriod: string;
onEdit: (item: GoalProgressItem) => void;
onEditOpenChange: (open: boolean) => void;
};
export function GoalsProgressWidgetView({
data,
selectedBudget,
editOpen,
categories,
defaultPeriod,
onEdit,
onEditOpenChange,
}: GoalsProgressWidgetViewProps) {
return (
<div className="flex flex-col gap-4 px-0">
<GoalsProgressList items={data.items} onEdit={onEdit} />
<GoalsProgressWidgetDialogs
selectedBudget={selectedBudget}
editOpen={editOpen}
categories={categories}
defaultPeriod={defaultPeriod}
onEditOpenChange={onEditOpenChange}
/>
</div>
);
}

View File

@@ -1,331 +1,18 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function IncomeByCategoryWidgetWithChart({
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
} else {
// Top 7 + Outros
const top7 = data.categories.slice(0, 7);
top7.forEach((category, index) => {
config[category.categoryId] = {
label: category.categoryName,
color: colors[index % colors.length],
};
});
config.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return config;
}, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal,
0,
);
const top7Data = top7.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
// Adicionar "Outros" se houver
if (others.length > 0) {
top7Data.push({
category: "outros",
name: "Outros",
value: othersTotal,
percentage: othersPercentage,
fill: chartConfig.outros?.color,
});
}
return top7Data;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma receita encontrada"
description="Quando houver receitas registradas, elas aparecerão aqui."
/>
);
}
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
className="w-full"
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<RiListUnordered className="size-3.5 mr-1" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<RiPieChart2Line className="size-3.5 mr-1" />
Gráfico
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div
key={category.categoryId}
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
colorIndex={index}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{formatPercentage(category.percentageOfTotal)} da
receita total
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
amount={category.currentAmount}
/>
{category.percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
hasIncrease
? "text-success"
: hasDecrease
? "text-destructive"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-3" />}
{hasDecrease && <RiArrowDownSFill className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</div>
</div>
{hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetAmount !== null && (
<div className="ml-11 flex items-center gap-1.5 text-xs">
<RiWallet3Line
className={`size-3 ${
budgetExceeded ? "text-destructive" : "text-info"
}`}
/>
<span
className={
budgetExceeded ? "text-destructive" : "text-info"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)} -
excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite {formatCurrency(category.budgetAmount)}
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
);
}

View File

@@ -2,14 +2,15 @@
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
import { formatCurrency } from "@/lib/utils/currency";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
@@ -80,15 +81,6 @@ export function IncomeExpenseBalanceWidget({
return null;
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
@@ -103,7 +95,7 @@ export function IncomeExpenseBalanceWidget({
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
className="size-2 rounded-full"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground">
@@ -144,7 +136,7 @@ export function IncomeExpenseBalanceWidget({
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }}
/>
<span className="text-sm text-muted-foreground">
@@ -153,7 +145,7 @@ export function IncomeExpenseBalanceWidget({
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
@@ -162,7 +154,7 @@ export function IncomeExpenseBalanceWidget({
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">

View File

@@ -6,7 +6,7 @@ import {
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { InstallmentGroupCard } from "./installment-group-card";

View File

@@ -8,7 +8,7 @@ import {
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";

View File

@@ -1,191 +1,12 @@
import { RiNumbersLine } from "@remixicon/react";
import Image from "next/image";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
} from "@/lib/installments/utils";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
type InstallmentExpensesWidgetProps = {
data: InstallmentExpensesData;
};
const buildCompactInstallmentLabel = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
const isLastInstallment = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
};
const calculateRemainingInstallments = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
};
const calculateRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) => {
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount,
);
return amount * remaining;
};
const formatEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return null;
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
};
const buildProgress = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export function InstallmentExpensesWidget({
data,
}: InstallmentExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
const compactLabel = buildCompactInstallmentLabel(
expense.currentInstallment,
expense.installmentCount,
);
const isLast = isLastInstallment(
expense.currentInstallment,
expense.installmentCount,
);
const remainingInstallments = calculateRemainingInstallments(
expense.currentInstallment,
expense.installmentCount,
);
const remainingAmount = calculateRemainingAmount(
expense.amount,
expense.currentInstallment,
expense.installmentCount,
);
const endDate = formatEndDate(
expense.period,
expense.currentInstallment,
expense.installmentCount,
);
const progress = buildProgress(
expense.currentInstallment,
expense.installmentCount,
);
return (
<li
key={expense.id}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
{compactLabel && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
{compactLabel}
{isLast && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={14}
height={14}
className="h-3.5 w-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Última parcela!
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
</div>
<p className="text-xs text-muted-foreground ">
{endDate && `Termina em ${endDate}`}
{" | Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>{" "}
({remainingInstallments})
</p>
<Progress value={progress} className="h-2 mt-1" />
</div>
</li>
);
})}
</ul>
</CardContent>
);
return <InstallmentExpensesWidgetView data={data} />;
}

View File

@@ -0,0 +1,76 @@
import Image from "next/image";
import MoneyValues from "@/components/shared/money-values";
import { Progress } from "@/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
import { buildInstallmentExpenseDisplay } from "@/lib/dashboard/installment-expenses-helpers";
type InstallmentExpenseListItemProps = {
expense: InstallmentExpense;
};
export function InstallmentExpenseListItem({
expense,
}: InstallmentExpenseListItemProps) {
const {
compactLabel,
isLast,
remainingInstallments,
remainingAmount,
endDate,
progress,
} = buildInstallmentExpenseDisplay(expense);
return (
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
{compactLabel ? (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
{compactLabel}
{isLast ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={14}
height={14}
className="h-3.5 w-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
</span>
) : null}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
</div>
<p className="text-xs text-muted-foreground">
{endDate ? `Termina em ${endDate}` : null}
{" | Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>{" "}
({remainingInstallments})
</p>
<Progress value={progress} className="mt-1 h-2" />
</div>
</li>
);
}

View File

@@ -0,0 +1,30 @@
import { RiNumbersLine } from "@remixicon/react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
import { InstallmentExpenseListItem } from "./installment-expense-list-item";
type InstallmentExpensesListProps = {
expenses: InstallmentExpense[];
};
export function InstallmentExpensesList({
expenses,
}: InstallmentExpensesListProps) {
if (expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
return (
<ul className="flex flex-col gap-2">
{expenses.map((expense) => (
<InstallmentExpenseListItem key={expense.id} expense={expense} />
))}
</ul>
);
}

View File

@@ -0,0 +1,16 @@
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
import { InstallmentExpensesList } from "./installment-expenses-list";
type InstallmentExpensesWidgetViewProps = {
data: InstallmentExpensesData;
};
export function InstallmentExpensesWidgetView({
data,
}: InstallmentExpensesWidgetViewProps) {
return (
<div className="flex flex-col gap-4 px-0">
<InstallmentExpensesList expenses={data.expenses} />
</div>
);
}

View File

@@ -1,584 +1,35 @@
"use client";
import {
RiBillLine,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiExternalLinkLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import MoneyValues from "@/components/money-values";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter as ModalFooter,
} from "@/components/ui/dialog";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Badge } from "../ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import { WidgetEmptyState } from "../widget-empty-state";
import { useInvoicesWidgetController } from "@/lib/dashboard/use-invoices-widget-controller";
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
type InvoicesWidgetProps = {
invoices: DashboardInvoice[];
};
type ModalState = "idle" | "processing" | "success";
const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const resolveLogoPath = (logo: string | null) => {
if (!logo) {
return null;
}
if (/^(https?:\/\/|data:)/.test(logo)) {
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";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
};
const parseDueDate = (period: string, dueDay: string) => {
const [yearStr, monthStr] = period.split("-");
const dayNumber = Number.parseInt(dueDay, 10);
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (
Number.isNaN(dayNumber) ||
Number.isNaN(year) ||
Number.isNaN(month) ||
period.length !== 7
) {
return {
label: `Vence dia ${dueDay}`,
date: null,
};
}
const date = new Date(Date.UTC(year, month - 1, dayNumber));
return {
label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`,
date,
};
};
const formatPaymentDate = (value: string | null) => {
if (!value) {
return null;
}
const [yearStr, monthStr, dayStr] = value.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const day = Number.parseInt(dayStr ?? "", 10);
if (
Number.isNaN(year) ||
Number.isNaN(month) ||
Number.isNaN(day) ||
yearStr?.length !== 4 ||
monthStr?.length !== 2 ||
dayStr?.length !== 2
) {
return null;
}
const date = new Date(Date.UTC(year, month - 1, day));
return {
label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`,
};
};
const getTodayDateString = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const formatSharePercentage = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0%";
}
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
return `${value.toLocaleString("pt-BR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})}%`;
};
const getShareLabel = (amount: number, total: number) => {
if (total <= 0) {
return "0% do total";
}
const percentage = (amount / total) * 100;
return `${formatSharePercentage(percentage)} do total`;
};
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [items, setItems] = useState(invoices);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalState>("idle");
useEffect(() => {
setItems(invoices);
}, [invoices]);
const selectedInvoice = useMemo(
() => items.find((invoice) => invoice.id === selectedId) ?? null,
[items, selectedId],
);
const selectedLogo = useMemo(
() => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null),
[selectedInvoice],
);
const selectedPaymentInfo = useMemo(
() => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null),
[selectedInvoice],
);
const handleOpenModal = (invoiceId: string) => {
setSelectedId(invoiceId);
setModalState("idle");
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setModalState("idle");
setSelectedId(null);
};
const handleConfirmPayment = () => {
if (!selectedInvoice) {
return;
}
setModalState("processing");
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cartaoId: selectedInvoice.cardId,
period: selectedInvoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
});
if (result.success) {
toast.success(result.message);
setItems((previous) =>
previous.map((invoice) =>
invoice.id === selectedInvoice.id
? {
...invoice,
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
paidAt: getTodayDateString(),
}
: invoice,
),
);
setModalState("success");
router.refresh();
return;
}
toast.error(result.error);
setModalState("idle");
});
};
const getStatusBadgeVariant = (status: string): "success" | "info" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "em aberto") {
return "info";
}
return "success";
};
const {
items,
selectedInvoice,
isModalOpen,
modalState,
isPending,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
} = useInvoicesWidgetController(invoices);
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{items.length === 0 ? (
<WidgetEmptyState
icon={<RiBillLine className="size-6 text-muted-foreground" />}
title="Nenhuma fatura para o período selecionado"
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{items.map((invoice) => {
const logo = resolveLogoPath(invoice.logo);
const initials = buildInitials(invoice.cardName);
const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
const isPaid =
invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && dueInfo.date < new Date();
const paymentInfo = formatPaymentDate(invoice.paidAt);
return (
<li
key={invoice.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<div className="flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
{logo ? (
<Image
src={logo}
alt={`Logo do cartão ${invoice.cardName}`}
width={36}
height={36}
className="h-full w-full object-contain"
/>
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<div className="min-w-0">
{(() => {
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const linkNode = (
<Link
prefetch
href={`/cartoes/${
invoice.cardId
}/fatura?periodo=${formatPeriodForUrl(
invoice.period,
)}`}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
);
if (!hasBreakdown) {
return linkNode;
}
const totalForShare = Math.abs(invoice.totalAmount);
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
{linkNode}
</HoverCardTrigger>
<HoverCardContent
align="start"
className="w-72 space-y-3"
>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Distribuição por pagador
</p>
<ul className="space-y-2">
{breakdown.map((share, index) => (
<li
key={`${invoice.id}-${
share.pagadorId ??
share.pagadorName ??
index
}`}
className="flex items-center gap-3"
>
<Avatar className="size-9">
<AvatarImage
src={getAvatarSrc(share.pagadorAvatar)}
alt={`Avatar de ${share.pagadorName}`}
/>
<AvatarFallback>
{buildInitials(share.pagadorName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{share.pagadorName}
</p>
<p className="text-xs text-muted-foreground">
{getShareLabel(
share.amount,
totalForShare,
)}
</p>
</div>
<div className="text-sm font-semibold text-foreground">
<MoneyValues amount={share.amount} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
})()}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{isPaid && paymentInfo ? (
<span className="text-success">
{paymentInfo.label}
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
disabled={isPaid}
onClick={() => handleOpenModal(invoice.id)}
variant={"link"}
className="p-0 h-auto disabled:opacity-100"
>
{isPaid ? (
<span className="text-success flex items-center gap-1">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">
Pagar
</span>
</span>
) : (
<span>Pagar</span>
)}
</Button>
</div>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
if (!open) {
handleCloseModal();
return;
}
setIsModalOpen(true);
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (modalState === "processing") {
event.preventDefault();
return;
}
handleCloseModal();
}}
onPointerDownOutside={(event) => {
if (modalState === "processing") {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento confirmado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status da fatura. O lançamento do pagamento
aparecerá no extrato em instantes.
</DialogDescription>
</div>
<ModalFooter className="sm:justify-center">
<Button
type="button"
onClick={handleCloseModal}
className="sm:w-auto"
>
Fechar
</Button>
</ModalFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Revise os dados antes de confirmar. Vamos registrar a fatura
como paga.
</DialogDescription>
</DialogHeader>
{selectedInvoice ? (
<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 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
{selectedLogo ? (
<Image
src={selectedLogo}
alt={`Logo do cartão ${selectedInvoice.cardName}`}
width={40}
height={40}
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs font-semibold uppercase text-primary">
{buildInitials(selectedInvoice.cardName)}
</span>
)}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Cartão
</p>
<p className="text-lg font-bold text-foreground">
{selectedInvoice.cardName}
</p>
</div>
</div>
<div className="text-right">
{selectedInvoice.paymentStatus !==
INVOICE_PAYMENT_STATUS.PAID ? (
<p className="text-sm text-muted-foreground">
{
parseDueDate(
selectedInvoice.period,
selectedInvoice.dueDay,
).label
}
</p>
) : null}
{selectedInvoice.paymentStatus ===
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
<p className="text-sm text-success">
{selectedPaymentInfo.label}
</p>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor da Fatura
</span>
</div>
<MoneyValues
amount={Math.abs(selectedInvoice.totalAmount)}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getStatusBadgeVariant(
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
</Badge>
</div>
</div>
</div>
) : null}
<ModalFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={modalState === "processing"}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmPayment}
disabled={modalState === "processing" || isPending}
className="relative"
>
{modalState === "processing" || isPending ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</ModalFooter>
</>
)}
</DialogContent>
</Dialog>
</>
<InvoicesWidgetView
invoices={items}
selectedInvoice={selectedInvoice}
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}
/>
);
}

View File

@@ -0,0 +1,148 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/shared/money-values";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import {
buildInvoiceDetailsHref,
buildInvoiceInitials,
formatInvoicePaymentDate,
getInvoiceShareLabel,
parseInvoiceDueDate,
} from "@/lib/dashboard/invoices-helpers";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { isDateOnlyPast } from "@/lib/utils/date";
import { InvoiceLogo } from "./invoice-logo";
type InvoiceListItemProps = {
invoice: DashboardInvoice;
onPay: (invoiceId: string) => void;
};
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const linkNode = (
<Link
prefetch
href={detailHref}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
);
return (
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
size={36}
containerClassName="size-9.5"
/>
<div className="min-w-0">
{hasBreakdown ? (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
<HoverCardContent align="start" className="w-72 space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Distribuição por pagador
</p>
<ul className="space-y-2">
{breakdown.map((share, index) => (
<li
key={`${invoice.id}-${
share.pagadorId ?? share.pagadorName ?? index
}`}
className="flex items-center gap-3"
>
<Avatar className="size-9">
<AvatarImage
src={getAvatarSrc(share.pagadorAvatar)}
alt={`Avatar de ${share.pagadorName}`}
/>
<AvatarFallback>
{buildInvoiceInitials(share.pagadorName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{share.pagadorName}
</p>
<p className="text-xs text-muted-foreground">
{getInvoiceShareLabel(
share.amount,
Math.abs(invoice.totalAmount),
)}
</p>
</div>
<div className="text-sm font-semibold text-foreground">
<MoneyValues amount={share.amount} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
) : (
linkNode
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{isPaid && paymentInfo ? (
<span className="text-success">{paymentInfo.label}</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={isPaid}
onClick={() => onPay(invoice.id)}
>
{isPaid ? (
<span className="flex items-center gap-1 text-success">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">Pagar</span>
</span>
) : (
<span>Pagar</span>
)}
</Button>
</div>
</li>
);
}

View File

@@ -0,0 +1,59 @@
import Image from "next/image";
import {
buildInvoiceInitials,
type InvoiceLogoTone,
} from "@/lib/dashboard/invoices-helpers";
import { resolveLogoSrc } from "@/lib/logo";
import { cn } from "@/lib/utils/ui";
type InvoiceLogoProps = {
cardName: string;
logo: string | null;
size: number;
containerClassName?: string;
imageClassName?: string;
fallbackClassName?: string;
tone?: InvoiceLogoTone;
};
export function InvoiceLogo({
cardName,
logo,
size,
containerClassName,
imageClassName,
fallbackClassName,
tone = "muted",
}: InvoiceLogoProps) {
const resolvedLogo = resolveLogoSrc(logo);
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
tone === "accent" && "bg-primary/10",
containerClassName,
)}
>
{resolvedLogo ? (
<Image
src={resolvedLogo}
alt={`Logo do cartão ${cardName}`}
width={size}
height={size}
className={cn("h-full w-full object-contain", imageClassName)}
/>
) : (
<span
className={cn(
"text-sm font-semibold uppercase text-muted-foreground",
tone === "accent" && "text-primary",
fallbackClassName,
)}
>
{buildInvoiceInitials(cardName)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
import {
RiCheckboxCircleLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import {
formatInvoicePaymentDate,
getInvoiceStatusBadgeVariant,
type InvoiceDialogState,
parseInvoiceDueDate,
} from "@/lib/dashboard/invoices-helpers";
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
import { InvoiceLogo } from "./invoice-logo";
type InvoicePaymentDialogProps = {
invoice: DashboardInvoice | null;
open: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function InvoicePaymentDialog({
invoice,
open,
modalState,
isPending,
onClose,
onConfirm,
}: InvoicePaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending;
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || isProcessing) {
return;
}
onClose();
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento confirmado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status da fatura. O lançamento do pagamento
aparecerá no extrato em instantes.
</DialogDescription>
</div>
<DialogFooter className="sm:justify-center">
<Button type="button" onClick={onClose} className="sm:w-auto">
Fechar
</Button>
</DialogFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Revise os dados antes de confirmar. Vamos registrar a fatura
como paga.
</DialogDescription>
</DialogHeader>
{invoice ? (
<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">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
size={40}
tone="accent"
containerClassName="size-10"
fallbackClassName="text-xs"
/>
<div>
<p className="text-sm font-medium text-muted-foreground">
Cartão
</p>
<p className="text-lg font-bold text-foreground">
{invoice.cardName}
</p>
</div>
</div>
<div className="text-right">
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
<p className="text-sm text-muted-foreground">
{
parseInvoiceDueDate(invoice.period, invoice.dueDay)
.label
}
</p>
) : null}
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
paymentInfo ? (
<p className="text-sm text-success">
{paymentInfo.label}
</p>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor da Fatura
</span>
</div>
<MoneyValues
amount={Math.abs(invoice.totalAmount)}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getInvoiceStatusBadgeVariant(
INVOICE_STATUS_LABEL[invoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
</Badge>
</div>
</div>
</div>
) : null}
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={isProcessing || !invoice}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,29 @@
import { RiBillLine } from "@remixicon/react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import { InvoiceListItem } from "./invoice-list-item";
type InvoicesListProps = {
invoices: DashboardInvoice[];
onPay: (invoiceId: string) => void;
};
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
if (invoices.length === 0) {
return (
<WidgetEmptyState
icon={<RiBillLine className="size-6 text-muted-foreground" />}
title="Nenhuma fatura para o período selecionado"
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
/>
);
}
return (
<ul className="flex flex-col">
{invoices.map((invoice) => (
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
))}
</ul>
);
}

View File

@@ -0,0 +1,43 @@
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import type { InvoiceDialogState } from "@/lib/dashboard/invoices-helpers";
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
import { InvoicesList } from "./invoices-list";
type InvoicesWidgetViewProps = {
invoices: DashboardInvoice[];
selectedInvoice: DashboardInvoice | null;
isModalOpen: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
onOpenPaymentDialog: (invoiceId: string) => void;
onClosePaymentDialog: () => void;
onConfirmPayment: () => void;
};
export function InvoicesWidgetView({
invoices,
selectedInvoice,
isModalOpen,
modalState,
isPending,
onOpenPaymentDialog,
onClosePaymentDialog,
onConfirmPayment,
}: InvoicesWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4">
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
</div>
<InvoicePaymentDialog
invoice={selectedInvoice}
open={isModalOpen}
modalState={modalState}
isPending={isPending}
onClose={onClosePaymentDialog}
onConfirm={onConfirmPayment}
/>
</>
);
}

View File

@@ -1,62 +1,38 @@
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import {
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { CardFooter } from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
import { resolveLogoSrc } from "@/lib/logo";
import { formatPeriodForUrl } from "@/lib/utils/period";
import MoneyValues from "../money-values";
import { WidgetEmptyState } from "../widget-empty-state";
import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
totalBalance: number;
maxVisible?: number;
period: string;
};
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
const buildInitials = (name: string) => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CC";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
};
export function MyAccountsWidget({
accounts,
totalBalance,
maxVisible = 5,
period,
}: MyAccountsWidgetProps) {
const visibleAccounts = accounts.filter(
(account) => !account.excludeFromBalance,
);
const displayedAccounts = visibleAccounts.slice(0, maxVisible);
const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
return (
<>
<CardHeader className="pb-4 px-0">
<CardDescription>Saldo Total</CardDescription>
<div className="text-2xl text-foreground">
<MoneyValues amount={totalBalance} />
</div>
</CardHeader>
<div className="flex justify-between py-2">
Saldo Total
<MoneyValues className="text-2xl" amount={totalBalance} />
</div>
<CardContent className="py-2 px-0">
<div className="py-2 px-0">
{displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
@@ -71,7 +47,6 @@ export function MyAccountsWidget({
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
const initials = buildInitials(account.name);
return (
<li
@@ -79,20 +54,14 @@ export function MyAccountsWidget({
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{logoSrc ? (
<div className="relative size-10 overflow-hidden">
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
className="object-contain rounded-full"
/>
</div>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
{initials}
</div>
)}
<div className="relative size-10 overflow-hidden">
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
className="object-contain rounded-full"
/>
</div>
<div className="min-w-0">
<Link
@@ -122,7 +91,7 @@ export function MyAccountsWidget({
})}
</ul>
)}
</CardContent>
</div>
{visibleAccounts.length > displayedAccounts.length ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">

View File

@@ -1,154 +1,37 @@
"use client";
import { RiFileList2Line, RiPencilLine, RiTodoLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import type { Note } from "@/components/anotacoes/types";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import type { DashboardNote } from "@/lib/dashboard/notes";
import { Badge } from "../ui/badge";
import { WidgetEmptyState } from "../widget-empty-state";
import { useNotesWidgetController } from "@/lib/dashboard/use-notes-widget-controller";
import { NotesWidgetView } from "./notes/notes-widget-view";
type NotesWidgetProps = {
notes: DashboardNote[];
};
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDisplayTitle = (value: string) => {
const trimmed = value.trim();
return trimmed.length ? trimmed : "Anotação sem título";
};
const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
id: note.id,
title: note.title,
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
createdAt: note.createdAt,
});
const getTasksSummary = (note: DashboardNote) => {
if (note.type !== "tarefa") {
return "Nota";
}
const tasks = note.tasks ?? [];
const completed = tasks.filter((task) => task.completed).length;
return `${completed}/${tasks.length} concluídas`;
};
export function NotesWidget({ notes }: NotesWidgetProps) {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]);
const handleOpenEdit = useCallback((note: Note) => {
setNoteToEdit(note);
setIsEditOpen(true);
}, []);
const handleOpenDetails = useCallback((note: Note) => {
setNoteDetails(note);
setIsDetailsOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setIsEditOpen(open);
if (!open) {
setNoteToEdit(null);
}
}, []);
const handleDetailsOpenChange = useCallback((open: boolean) => {
setIsDetailsOpen(open);
if (!open) {
setNoteDetails(null);
}
}, []);
const {
mappedNotes,
noteToEdit,
isEditOpen,
noteDetails,
isDetailsOpen,
openEdit,
openDetails,
handleEditOpenChange,
handleDetailsOpenChange,
} = useNotesWidgetController(notes);
return (
<>
<CardContent className="flex flex-col gap-4 px-0">
{mappedNotes.length === 0 ? (
<WidgetEmptyState
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
title="Nenhuma anotação ativa"
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
/>
) : (
<ul className="flex flex-col">
{mappedNotes.map((note) => (
<li
key={note.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{buildDisplayTitle(note.title)}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{getTasksSummary(note)}
</Badge>
<p className="truncate text-[11px] text-muted-foreground">
{DATE_FORMATTER.format(new Date(note.createdAt))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenEdit(note)}
aria-label={`Editar anotação ${buildDisplayTitle(note.title)}`}
>
<RiPencilLine className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${buildDisplayTitle(
note.title,
)}`}
>
<RiFileList2Line className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={isEditOpen}
onOpenChange={handleEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={isDetailsOpen}
onOpenChange={handleDetailsOpenChange}
/>
</>
<NotesWidgetView
notes={mappedNotes}
noteToEdit={noteToEdit}
isEditOpen={isEditOpen}
noteDetails={noteDetails}
isDetailsOpen={isDetailsOpen}
onOpenEdit={openEdit}
onOpenDetails={openDetails}
onEditOpenChange={handleEditOpenChange}
onDetailsOpenChange={handleDetailsOpenChange}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
import type { Note } from "@/components/anotacoes/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
buildNoteDisplayTitle,
formatNoteCreatedAt,
getNoteTasksSummary,
} from "@/lib/notes/formatters";
type NoteListItemProps = {
note: Note;
onOpenEdit: (note: Note) => void;
onOpenDetails: (note: Note) => void;
};
export function NoteListItem({
note,
onOpenEdit,
onOpenDetails,
}: NoteListItemProps) {
const displayTitle = buildNoteDisplayTitle(note.title);
const createdAtLabel = formatNoteCreatedAt(note.createdAt);
return (
<li className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{displayTitle}
</p>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{getNoteTasksSummary(note)}
</Badge>
{createdAtLabel ? (
<p className="truncate text-[11px] text-muted-foreground">
{createdAtLabel}
</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenEdit(note)}
aria-label={`Editar anotação ${displayTitle}`}
>
<RiPencilLine className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${displayTitle}`}
>
<RiFileList2Line className="size-4" />
</Button>
</div>
</li>
);
}

View File

@@ -0,0 +1,39 @@
import { RiTodoLine } from "@remixicon/react";
import type { Note } from "@/components/anotacoes/types";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { NoteListItem } from "./note-list-item";
type NotesListProps = {
notes: Note[];
onOpenEdit: (note: Note) => void;
onOpenDetails: (note: Note) => void;
};
export function NotesList({
notes,
onOpenEdit,
onOpenDetails,
}: NotesListProps) {
if (notes.length === 0) {
return (
<WidgetEmptyState
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
title="Nenhuma anotação ativa"
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
/>
);
}
return (
<ul className="flex flex-col">
{notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
onOpenEdit={onOpenEdit}
onOpenDetails={onOpenDetails}
/>
))}
</ul>
);
}

View File

@@ -0,0 +1,38 @@
import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog";
import { NoteDialog } from "@/components/anotacoes/note-dialog";
import type { Note } from "@/components/anotacoes/types";
type NotesWidgetDialogsProps = {
noteToEdit: Note | null;
isEditOpen: boolean;
noteDetails: Note | null;
isDetailsOpen: boolean;
onEditOpenChange: (open: boolean) => void;
onDetailsOpenChange: (open: boolean) => void;
};
export function NotesWidgetDialogs({
noteToEdit,
isEditOpen,
noteDetails,
isDetailsOpen,
onEditOpenChange,
onDetailsOpenChange,
}: NotesWidgetDialogsProps) {
return (
<>
<NoteDialog
mode="update"
note={noteToEdit ?? undefined}
open={isEditOpen}
onOpenChange={onEditOpenChange}
/>
<NoteDetailsDialog
note={noteDetails}
open={isDetailsOpen}
onOpenChange={onDetailsOpenChange}
/>
</>
);
}

View File

@@ -0,0 +1,48 @@
import type { Note } from "@/components/anotacoes/types";
import { NotesList } from "./notes-list";
import { NotesWidgetDialogs } from "./notes-widget-dialogs";
type NotesWidgetViewProps = {
notes: Note[];
noteToEdit: Note | null;
isEditOpen: boolean;
noteDetails: Note | null;
isDetailsOpen: boolean;
onOpenEdit: (note: Note) => void;
onOpenDetails: (note: Note) => void;
onEditOpenChange: (open: boolean) => void;
onDetailsOpenChange: (open: boolean) => void;
};
export function NotesWidgetView({
notes,
noteToEdit,
isEditOpen,
noteDetails,
isDetailsOpen,
onOpenEdit,
onOpenDetails,
onEditOpenChange,
onDetailsOpenChange,
}: NotesWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4 px-0">
<NotesList
notes={notes}
onOpenEdit={onOpenEdit}
onOpenDetails={onOpenDetails}
/>
</div>
<NotesWidgetDialogs
noteToEdit={noteToEdit}
isEditOpen={isEditOpen}
noteDetails={noteDetails}
isDetailsOpen={isDetailsOpen}
onEditOpenChange={onEditOpenChange}
onDetailsOpenChange={onDetailsOpenChange}
/>
</>
);
}

View File

@@ -8,21 +8,18 @@ import {
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CardContent } from "@/components/ui/card";
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { WidgetEmptyState } from "../widget-empty-state";
import { formatPercentage } from "@/lib/utils/percentage";
type PagadoresWidgetProps = {
type PayersWidgetProps = {
pagadores: DashboardPagador[];
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
@@ -37,7 +34,7 @@ const buildInitials = (value: string) => {
return `${firstChar}${secondChar}`.toUpperCase() || "??";
};
export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
export function PayersWidget({ pagadores }: PayersWidgetProps) {
return (
<CardContent className="flex flex-col gap-4 px-0">
{pagadores.length === 0 ? (

View File

@@ -1,88 +0,0 @@
import {
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
};
const CONDITION_ICON_CLASSES =
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
const CONDITION_ICONS: Record<string, ReactNode> = {
"À vista": <RiCheckLine className="size-5" aria-hidden />,
Parcelado: <RiLoader2Fill className="size-5" aria-hidden />,
Recorrente: <RiRefreshLine className="size-5" aria-hidden />,
};
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
export function PaymentConditionsWidget({
data,
}: PaymentConditionsWidgetProps) {
if (data.conditions.length === 0) {
return (
<WidgetEmptyState
icon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa encontrada"
description="As distribuições por condição aparecerão conforme novos lançamentos."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.conditions.map((condition) => {
const Icon =
CONDITION_ICONS[condition.condition] ?? CONDITION_ICONS["À vista"];
const percentageLabel = formatPercentage(condition.percentage);
return (
<li
key={condition.condition}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={CONDITION_ICON_CLASSES}>{Icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{condition.condition}
</p>
<MoneyValues amount={condition.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{condition.transactions}{" "}
{condition.transactions === 1
? "lançamento"
: "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={condition.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
};
const ICON_WRAPPER_CLASS =
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
const formatPercentage = (value: number) =>
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
const resolveIcon = (paymentMethod: string | null | undefined) => {
if (!paymentMethod) {
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
return <RiBankCard2Line className="size-5" aria-hidden />;
};
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
if (data.methods.length === 0) {
return (
<WidgetEmptyState
icon={
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Cadastre despesas para visualizar a distribuição por forma de pagamento."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.methods.map((method) => {
const icon = resolveIcon(method.paymentMethod);
const percentageLabel = formatPercentage(method.percentage);
return (
<li
key={method.paymentMethod}
className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0"
>
<div className={ICON_WRAPPER_CLASS}>{icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground text-sm">
{method.paymentMethod}
</p>
<MoneyValues amount={method.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{method.transactions}{" "}
{method.transactions === 1 ? "lançamento" : "lançamentos"}
</span>
<span>{percentageLabel}%</span>
</div>
<div className="mt-1">
<Progress value={method.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,12 +1,9 @@
"use client";
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
import { useState } from "react";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { PaymentConditionsWidget } from "./payment-conditions-widget";
import { PaymentMethodsWidget } from "./payment-methods-widget";
import { usePaymentOverviewWidgetController } from "@/lib/dashboard/use-payment-overview-widget-controller";
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
type PaymentOverviewWidgetProps = {
paymentConditionsData: PaymentConditionsData;
@@ -17,34 +14,14 @@ export function PaymentOverviewWidget({
paymentConditionsData,
paymentMethodsData,
}: PaymentOverviewWidgetProps) {
const [activeTab, setActiveTab] = useState<"conditions" | "methods">(
"conditions",
);
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
return (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "conditions" | "methods")}
className="w-full"
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="conditions" className="text-xs">
<RiSlideshowLine className="mr-1 size-3.5" />
Condições
</TabsTrigger>
<TabsTrigger value="methods" className="text-xs">
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas
</TabsTrigger>
</TabsList>
<TabsContent value="conditions" className="mt-2">
<PaymentConditionsWidget data={paymentConditionsData} />
</TabsContent>
<TabsContent value="methods" className="mt-2">
<PaymentMethodsWidget data={paymentMethodsData} />
</TabsContent>
</Tabs>
<PaymentOverviewWidgetView
activeTab={activeTab}
paymentConditionsData={paymentConditionsData}
paymentMethodsData={paymentMethodsData}
onTabChange={handleTabChange}
/>
);
}

View File

@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import MoneyValues from "@/components/shared/money-values";
import { Progress } from "@/components/ui/progress";
import {
formatPaymentBreakdownPercentage,
formatPaymentBreakdownTransactionsLabel,
} from "@/lib/dashboard/payment-breakdown-formatters";
const ICON_WRAPPER_CLASS =
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
export type PaymentBreakdownListItemData = {
id: string;
title: string;
icon: ReactNode;
amount: number;
transactions: number;
percentage: number;
};
type PaymentBreakdownListItemProps = {
item: PaymentBreakdownListItemData;
};
export function PaymentBreakdownListItem({
item,
}: PaymentBreakdownListItemProps) {
return (
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
<div className={ICON_WRAPPER_CLASS}>{item.icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">{item.title}</p>
<MoneyValues amount={item.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{formatPaymentBreakdownTransactionsLabel(item.transactions)}
</span>
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span>
</div>
<div className="mt-1">
<Progress value={item.percentage} />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import {
PaymentBreakdownListItem,
type PaymentBreakdownListItemData,
} from "./payment-breakdown-list-item";
export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
type PaymentBreakdownListProps = {
items: PaymentBreakdownListItemData[];
emptyIcon: ReactNode;
emptyTitle: string;
emptyDescription: string;
};
export function PaymentBreakdownList({
items,
emptyIcon,
emptyTitle,
emptyDescription,
}: PaymentBreakdownListProps) {
if (items.length === 0) {
return (
<WidgetEmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{items.map((item) => (
<PaymentBreakdownListItem key={item.id} item={item} />
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import { getConditionIcon } from "@/lib/utils/icons";
import {
PaymentBreakdownList,
type PaymentBreakdownListItemData,
} from "./payment-breakdown-list";
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
};
const resolveConditionIcon = (condition: string) =>
getConditionIcon(condition) ?? <RiCheckLine className="size-5" aria-hidden />;
export function PaymentConditionsWidget({
data,
}: PaymentConditionsWidgetProps) {
const items: PaymentBreakdownListItemData[] = data.conditions.map(
(condition) => ({
id: condition.condition,
title: condition.condition,
icon: resolveConditionIcon(condition.condition),
amount: condition.amount,
transactions: condition.transactions,
percentage: condition.percentage,
}),
);
return (
<PaymentBreakdownList
items={items}
emptyIcon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
emptyTitle="Nenhuma despesa encontrada"
emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
/>
);
}

View File

@@ -0,0 +1,38 @@
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import {
PaymentBreakdownList,
type PaymentBreakdownListItemData,
} from "./payment-breakdown-list";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
};
const resolvePaymentMethodIcon = (paymentMethod: string) =>
getPaymentMethodIcon(paymentMethod) ?? (
<RiBankCard2Line className="size-5" aria-hidden />
);
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
id: method.paymentMethod,
title: method.paymentMethod,
icon: resolvePaymentMethodIcon(method.paymentMethod),
amount: method.amount,
transactions: method.transactions,
percentage: method.percentage,
}));
return (
<PaymentBreakdownList
items={items}
emptyIcon={
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
}
emptyTitle="Nenhuma despesa encontrada"
emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
/>
);
}

View File

@@ -0,0 +1,44 @@
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { PaymentOverviewTab } from "@/lib/dashboard/payment-overview-tabs";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { PaymentConditionsWidget } from "./payment-conditions-widget";
import { PaymentMethodsWidget } from "./payment-methods-widget";
type PaymentOverviewWidgetViewProps = {
activeTab: PaymentOverviewTab;
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
onTabChange: (value: string) => void;
};
export function PaymentOverviewWidgetView({
activeTab,
paymentConditionsData,
paymentMethodsData,
onTabChange,
}: PaymentOverviewWidgetViewProps) {
return (
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="conditions" className="text-xs">
<RiSlideshowLine className="mr-1 size-3.5" />
Condições
</TabsTrigger>
<TabsTrigger value="methods" className="text-xs">
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas
</TabsTrigger>
</TabsList>
<TabsContent value="conditions" className="mt-2">
<PaymentConditionsWidget data={paymentConditionsData} />
</TabsContent>
<TabsContent value="methods" className="mt-2">
<PaymentMethodsWidget data={paymentMethodsData} />
</TabsContent>
</Tabs>
);
}

View File

@@ -1,103 +1,12 @@
"use client";
import {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
import { Progress } from "../ui/progress";
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
};
type CategorySectionProps = {
title: string;
total: number;
confirmed: number;
pending: number;
};
function CategorySection({
title,
total,
confirmed,
pending,
}: CategorySectionProps) {
// Usa valores absolutos para calcular percentual corretamente
const absTotal = Math.abs(total);
const absConfirmed = Math.abs(confirmed);
const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues
amount={total}
className="text-sm font-medium tabular-nums"
/>
</div>
{/* Barra de progresso */}
<Progress value={confirmedPercentage} className="h-2" />
{/* Status de confirmados e pendentes */}
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5">
<RiCheckboxCircleLine className="size-3 shrink-0 text-success" />
<MoneyValues amount={confirmed} className="tabular-nums" />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5">
<RiHourglass2Line className="size-3 shrink-0 text-warning" />
<MoneyValues amount={pending} className="tabular-nums" />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>
</div>
);
}
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
title="Nenhum valor a receber ou pagar no período"
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-6 px-0">
<CategorySection
title="A Receber"
total={data.income.total}
confirmed={data.income.confirmed}
pending={data.income.pending}
/>
{/* Linha divisória pontilhada */}
<div className="border-t border-dashed" />
<CategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
return <PaymentStatusWidgetView data={data} />;
}

View File

@@ -0,0 +1,50 @@
import MoneyValues from "@/components/shared/money-values";
import StatusDot from "@/components/shared/status-dot";
import { Progress } from "@/components/ui/progress";
type PaymentStatusCategorySectionProps = {
title: string;
total: number;
confirmed: number;
pending: number;
};
export function PaymentStatusCategorySection({
title,
total,
confirmed,
pending,
}: PaymentStatusCategorySectionProps) {
const absTotal = Math.abs(total);
const absConfirmed = Math.abs(confirmed);
const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
return (
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues
amount={total}
className="text-sm font-medium tabular-nums"
/>
</div>
<Progress value={confirmedPercentage} className="h-2" />
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5">
<StatusDot color="bg-primary" />
<MoneyValues amount={confirmed} className="tabular-nums" />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5">
<StatusDot color="bg-warning/40" />
<MoneyValues amount={pending} className="tabular-nums" />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { RiWallet3Line } from "@remixicon/react";
import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { PaymentStatusData } from "@/lib/dashboard/payments/payment-status";
import { PaymentStatusCategorySection } from "./payment-status-category-section";
type PaymentStatusWidgetViewProps = {
data: PaymentStatusData;
};
export function PaymentStatusWidgetView({
data,
}: PaymentStatusWidgetViewProps) {
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
title="Nenhum valor a receber ou pagar no período"
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-6 px-0">
<PaymentStatusCategorySection
title="A Receber"
total={data.income.total}
confirmed={data.income.confirmed}
pending={data.income.pending}
/>
<div className="border-t border-dashed" />
<PaymentStatusCategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
}

View File

@@ -1,9 +1,10 @@
"use client";
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "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 {
Select,
SelectContent,
@@ -13,7 +14,6 @@ import {
} from "@/components/ui/select";
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
import { WidgetEmptyState } from "../widget-empty-state";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
@@ -38,21 +38,11 @@ const STORAGE_KEY = "purchases-by-category-selected";
export function PurchasesByCategoryWidget({
data,
}: PurchasesByCategoryWidgetProps) {
// Inicializa com a categoria salva ou a primeira disponível
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(() => {
if (typeof window === "undefined") {
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
}
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved && data.categories.some((cat) => cat.id === saved)) {
return saved;
}
const firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
});
const firstCategoryId = data.categories[0]?.id ?? "";
const hasRestoredSelectionRef = useRef(false);
const hasPersistedSelectionRef = useRef(false);
const [selectedCategoryId, setSelectedCategoryId] =
useState<string>(firstCategoryId);
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
@@ -72,27 +62,52 @@ export function PurchasesByCategoryWidget({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
// Salva a categoria selecionada quando mudar
// Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
useEffect(() => {
if (hasRestoredSelectionRef.current) {
return;
}
hasRestoredSelectionRef.current = true;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved && data.categories.some((cat) => cat.id === saved)) {
setSelectedCategoryId(saved);
return;
}
setSelectedCategoryId(firstCategoryId);
}, [data.categories, firstCategoryId]);
// Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
useEffect(() => {
if (!hasPersistedSelectionRef.current) {
hasPersistedSelectionRef.current = true;
return;
}
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
return;
}
sessionStorage.removeItem(STORAGE_KEY);
}, [selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
if (!selectedCategoryId && firstCategoryId) {
setSelectedCategoryId(firstCategoryId);
return;
}
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
const firstCategory = data.categories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
} else {
setSelectedCategoryId("");
}
setSelectedCategoryId(firstCategoryId);
}
}, [data.categories, selectedCategoryId]);
}, [data.categories, firstCategoryId, selectedCategoryId]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {

View File

@@ -1,9 +1,8 @@
import { RiRefreshLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import MoneyValues from "@/components/shared/money-values";
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
import { WidgetEmptyState } from "../widget-empty-state";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
@@ -31,7 +30,7 @@ export function RecurringExpensesWidget({
}
return (
<CardContent className="flex flex-col gap-4 px-0">
<div className="flex flex-col gap-4 px-0">
<ul className="flex flex-col gap-2">
{data.expenses.map((expense) => {
return (
@@ -61,6 +60,6 @@ export function RecurringExpensesWidget({
);
})}
</ul>
</CardContent>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { RiStore2Line } 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 type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
import { WidgetEmptyState } from "../widget-empty-state";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;

View File

@@ -3,13 +3,13 @@
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Switch } from "@/components/ui/switch";
import type {
TopExpense,
TopExpensesData,
} from "@/lib/dashboard/expenses/top-expenses";
import { WidgetEmptyState } from "../widget-empty-state";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;

View File

@@ -0,0 +1,9 @@
import {
formatBusinessCurrentDate,
getBusinessGreeting,
} from "@/lib/utils/date";
export const formatCurrentDate = (date = new Date()) =>
formatBusinessCurrentDate(date);
export const getGreeting = (date = new Date()) => getBusinessGreeting(date);

View File

@@ -1,112 +0,0 @@
"use client";
import type React from "react";
import { type CSSProperties, useEffect, useRef } from "react";
interface MagnetLinesProps {
rows?: number;
columns?: number;
containerSize?: string;
lineColor?: string;
lineWidth?: string;
lineHeight?: string;
baseAngle?: number;
className?: string;
style?: CSSProperties;
disabled?: boolean;
}
const MagnetLines: React.FC<MagnetLinesProps> = ({
rows = 9,
columns = 9,
containerSize = "80vmin",
lineColor = "#efefef",
lineWidth = "1vmin",
lineHeight = "6vmin",
baseAngle = -10,
className = "",
style = {},
disabled = false,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (disabled) return;
const container = containerRef.current;
if (!container) return;
const items = container.querySelectorAll<HTMLSpanElement>("span");
const onPointerMove = (pointer: { x: number; y: number }) => {
items.forEach((item) => {
const rect = item.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const b = pointer.x - centerX;
const a = pointer.y - centerY;
const c = Math.sqrt(a * a + b * b) || 1;
const r =
((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
item.style.setProperty("--rotate", `${r}deg`);
});
};
const handlePointerMove = (e: PointerEvent) => {
onPointerMove({ x: e.x, y: e.y });
};
window.addEventListener("pointermove", handlePointerMove);
if (items.length) {
const middleIndex = Math.floor(items.length / 2);
const rect = items[middleIndex].getBoundingClientRect();
onPointerMove({ x: rect.x, y: rect.y });
}
return () => {
window.removeEventListener("pointermove", handlePointerMove);
};
}, [disabled]);
// Se magnetlines estiver desabilitado, não renderiza nada
if (disabled) {
return null;
}
const total = rows * columns;
const spans = Array.from({ length: total }, (_, i) => (
<span
key={i}
className="block origin-center"
style={{
backgroundColor: lineColor,
width: lineWidth,
height: lineHeight,
// @ts-expect-error -- CSS custom property para controlar a rotação inline
"--rotate": `${baseAngle}deg`,
transform: "rotate(var(--rotate))",
willChange: "transform",
}}
/>
));
return (
<div
ref={containerRef}
className={`grid place-items-center ${className}`}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
width: containerSize,
height: containerSize,
...style,
}}
>
{spans}
</div>
);
};
export default MagnetLines;

View File

@@ -1,37 +0,0 @@
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton fiel aos cards de métricas do dashboard (SectionCards)
* Mantém o mesmo layout de 4 colunas responsivo
*/
export function SectionCardsSkeleton() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className="@container/card gap-2">
<CardHeader>
<div className="space-y-3">
{/* Título com ícone */}
<div className="flex items-center gap-1">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Valor principal */}
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
{/* Badge de tendência */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
</div>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -127,7 +127,6 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
extratoNoteAsColumn: boolean("extrato_note_as_column")
.notNull()
.default(false),

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" DROP COLUMN "disable_magnetlines";

File diff suppressed because it is too large Load Diff

View File

@@ -1,132 +1,139 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardAccount = {
id: string;

View File

@@ -0,0 +1,53 @@
import type { DashboardBill } from "@/lib/dashboard/bills";
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
import { getBusinessDateString, isDateOnlyPast } from "@/lib/utils/date";
import {
buildFinancialStatusLabel,
formatFinancialDateLabel,
} from "@/lib/utils/financial-dates";
export type BillDialogState = PaymentDialogState;
export type BillStatusDateItem = Pick<
DashboardBill,
"dueDate" | "boletoPaymentDate" | "isSettled"
>;
export const formatBillDateLabel = (value: string | null, prefix?: string) => {
return formatFinancialDateLabel(value, prefix);
};
export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
return buildFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
});
};
export const getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => {
if (bill.isSettled || !bill.dueDate) {
return false;
}
return isDateOnlyPast(bill.dueDate);
};
export const getBillStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {
if (statusLabel.toLowerCase() === "pendente") {
return "info";
}
return "success";
};
export const markBillAsSettled = (
bill: DashboardBill,
boletoPaymentDate: string,
): DashboardBill => ({
...bill,
isSettled: true,
boletoPaymentDate,
});

View File

@@ -2,13 +2,14 @@
import { and, asc, eq } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
type RawDashboardBoleto = {
type RawDashboardBill = {
id: string;
name: string;
amount: string | number | null;
@@ -17,7 +18,7 @@ type RawDashboardBoleto = {
isSettled: boolean | null;
};
export type DashboardBoleto = {
export type DashboardBill = {
id: string;
name: string;
amount: number;
@@ -26,35 +27,19 @@ export type DashboardBoleto = {
isSettled: boolean;
};
export type DashboardBoletosSnapshot = {
boletos: DashboardBoleto[];
export type DashboardBillsSnapshot = {
bills: DashboardBill[];
totalPendingAmount: number;
pendingCount: number;
};
const toISODate = (value: Date | string | null) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value;
}
return null;
};
export async function fetchDashboardBoletos(
export async function fetchDashboardBills(
userId: string,
period: string,
): Promise<DashboardBoletosSnapshot> {
): Promise<DashboardBillsSnapshot> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
@@ -81,14 +66,14 @@ export async function fetchDashboardBoletos(
asc(lancamentos.name),
);
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
const amount = Math.abs(toNumber(row.amount));
return {
id: row.id,
name: row.name,
amount,
dueDate: toISODate(row.dueDate),
boletoPaymentDate: toISODate(row.boletoPaymentDate),
dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
};
});
@@ -96,15 +81,15 @@ export async function fetchDashboardBoletos(
let totalPendingAmount = 0;
let pendingCount = 0;
for (const boleto of boletos) {
if (!boleto.isSettled) {
totalPendingAmount += boleto.amount;
for (const bill of bills) {
if (!bill.isSettled) {
totalPendingAmount += bill.amount;
pendingCount += 1;
}
}
return {
boletos,
bills,
totalPendingAmount,
pendingCount,
};

View File

@@ -0,0 +1,121 @@
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type DashboardCategoryBreakdownItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type DashboardCategoryBreakdownData = {
categories: DashboardCategoryBreakdownItem[];
currentTotal: number;
previousTotal: number;
};
type CategoryBreakdownRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string | null;
total: unknown;
};
type CategoryBudgetRow = {
categoriaId: string | null;
amount: unknown;
};
export function buildCategoryBreakdownData({
rows,
budgetRows,
period,
}: {
rows: CategoryBreakdownRow[];
budgetRows: CategoryBudgetRow[];
period: string;
}): DashboardCategoryBreakdownData {
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(toNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
const categories: DashboardCategoryBreakdownItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
}

View File

@@ -5,10 +5,10 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;

View File

@@ -1,12 +1,15 @@
import { addMonths, format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodWindow,
formatPeriodMonthShort,
} from "@/lib/utils/period";
export type CategoryOption = {
id: string;
@@ -34,6 +37,19 @@ export type CategoryHistoryData = {
};
const CHART_COLORS = CATEGORY_COLORS;
type MonthlyCategoryRow = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
period: string;
totalAmount: unknown;
};
type UniqueCategory = {
id: string;
name: string;
icon: string | null;
};
export async function fetchAllCategories(
userId: string,
@@ -61,26 +77,16 @@ export async function fetchCategoryHistory(
currentPeriod: string,
): Promise<CategoryHistoryData> {
// Generate last 8 months, current month, and next month (10 total)
const periods: string[] = [];
const monthLabels: string[] = [];
const [year, month] = currentPeriod.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
// Generate months from -8 to +1 (relative to current)
for (let i = 8; i >= -1; i--) {
const date = addMonths(currentDate, -i);
const period = format(date, "yyyy-MM");
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
periods.push(period);
monthLabels.push(label);
}
const periods = buildPeriodWindow(addMonthsToPeriod(currentPeriod, 1), 10);
const monthLabels = periods.map((period) =>
formatPeriodMonthShort(period).toUpperCase(),
);
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = await db
const monthlyDataQuery = (await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
@@ -112,7 +118,7 @@ export async function fetchCategoryHistory(
categorias.name,
categorias.icon,
lancamentos.period,
);
)) as MonthlyCategoryRow[];
if (monthlyDataQuery.length === 0) {
return {
@@ -124,8 +130,8 @@ export async function fetchCategoryHistory(
}
// Get unique categories from query results
const uniqueCategories = Array.from(
new Map(
const uniqueCategories: UniqueCategory[] = Array.from(
new Map<string, UniqueCategory>(
monthlyDataQuery.map((row) => [
row.categoryId,
{
@@ -178,15 +184,20 @@ export async function fetchCategoryHistory(
});
// Convert to chart data format
const chartData = monthLabels.map((month) => {
const dataPoint: Record<string, number | string> = { month };
const chartData: CategoryHistoryData["chartData"] = monthLabels.map(
(month) => {
const dataPoint: {
month: string;
[categoryName: string]: number | string;
} = { month };
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
return dataPoint;
});
return dataPoint;
},
);
return {
months: monthLabels,

View File

@@ -1,29 +1,20 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/lib/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type CategoryExpenseItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type ExpensesByCategoryData = {
categories: CategoryExpenseItem[];
currentTotal: number;
previousTotal: number;
};
export type CategoryExpenseItem = DashboardCategoryBreakdownItem;
export type ExpensesByCategoryData = DashboardCategoryBreakdownData;
export async function fetchExpensesByCategory(
userId: string,
@@ -50,15 +41,11 @@ export async function fetchExpensesByCategory(
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
excludeAutoInvoiceEntries(),
),
)
.groupBy(
@@ -76,85 +63,9 @@ export async function fetchExpensesByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(toNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calculate totals
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Build result
const categories: CategoryExpenseItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,32 +1,21 @@
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
type DashboardCategoryBreakdownItem,
} from "@/lib/dashboard/categories/category-breakdown";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
export type CategoryIncomeItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type IncomeByCategoryData = {
categories: CategoryIncomeItem[];
currentTotal: number;
previousTotal: number;
};
export type CategoryIncomeItem = DashboardCategoryBreakdownItem;
export type IncomeByCategoryData = DashboardCategoryBreakdownData;
export async function fetchIncomeByCategory(
userId: string,
@@ -54,21 +43,12 @@ export async function fetchIncomeByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(
@@ -86,85 +66,9 @@ export async function fetchIncomeByCategory(
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, safeToNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
const amount = Math.abs(safeToNumber(row.total));
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calculate totals
let currentTotal = 0;
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Build result
const categories: CategoryIncomeItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (entry.current / budgetAmount) * 100
: null;
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return buildCategoryBreakdownData({
rows,
budgetRows,
period,
});
}

View File

@@ -1,9 +0,0 @@
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
export { safeToNumber, calculatePercentageChange };
/**
* Alias for backward compatibility - dashboard uses "toNumber" naming
*/
export const toNumber = safeToNumber;

View File

@@ -1,21 +1,10 @@
import {
and,
asc,
eq,
gte,
ilike,
isNull,
lte,
ne,
not,
or,
sum,
} from "drizzle-orm";
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber } from "@/lib/utils/number";
@@ -107,21 +96,12 @@ export async function fetchDashboardCardMetrics(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType)

View File

@@ -4,23 +4,28 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
buildDateOnlyStringFromPeriodDay,
parseLocalDateString,
} from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
function calculateDueDate(period: string, dueDay: string | null): Date | null {
if (!dueDay) return null;
try {
const [year, month] = period.split("-");
if (!year || !month) return null;
const dueDateString = buildDateOnlyStringFromPeriodDay(period, dueDay);
if (!dueDateString) return null;
const day = parseInt(dueDay, 10);
if (Number.isNaN(day)) return null;
const dueDate = parseLocalDateString(dueDateString);
if (Number.isNaN(dueDate.getTime())) return null;
// Criar data ao meio-dia para evitar problemas de timezone
return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0);
// Meio-dia evita drift visual em serialização/locales diferentes.
dueDate.setHours(12, 0, 0, 0);
return dueDate;
} catch {
return null;
}

View File

@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type InstallmentExpense = {
id: string;

View File

@@ -4,9 +4,9 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type RecurringExpense = {
id: string;

View File

@@ -1,12 +1,12 @@
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopExpense = {
id: string;
@@ -32,19 +32,13 @@ export async function fetchTopExpenses(
}
const conditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
];
// Se cardOnly for true, filtra apenas pagamentos com cartão
@@ -72,7 +66,7 @@ export async function fetchTopExpenses(
.limit(10);
const expenses = results.map(
(row): TopExpense => ({
(row: (typeof results)[number]): TopExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),

View File

@@ -1,15 +1,15 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos";
import { fetchDashboardBills } from "./bills";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
import { fetchIncomeByCategory } from "./categories/income-by-category";
import { fetchDashboardCardMetrics } from "./dashboard-metrics";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
import { fetchTopExpenses } from "./expenses/top-expenses";
import { fetchGoalsProgressData } from "./goals-progress";
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
import { fetchDashboardInvoices } from "./invoices";
import { fetchDashboardCardMetrics } from "./metrics";
import { fetchDashboardNotes } from "./notes";
import { fetchDashboardPagadores } from "./pagadores";
import { fetchPaymentConditions } from "./payments/payment-conditions";
@@ -23,7 +23,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -43,7 +43,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardCardMetrics(userId, period),
fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period),
fetchDashboardBoletos(userId, period),
fetchDashboardBills(userId, period),
fetchGoalsProgressData(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
@@ -65,7 +65,7 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
billsSnapshot,
goalsProgressData,
paymentStatusData,
incomeExpenseBalanceData,
@@ -95,7 +95,7 @@ export function fetchDashboardData(userId: string, period: string) {
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
revalidate: 120,
revalidate: 60,
},
)();
}

View File

@@ -0,0 +1,45 @@
import type { Budget, BudgetCategory } from "@/components/orcamentos/types";
import type {
GoalProgressCategory,
GoalProgressItem,
GoalProgressStatus,
} from "@/lib/dashboard/goals-progress";
import { formatPercentage } from "@/lib/utils/percentage";
export const clampGoalProgress = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
export const formatGoalProgressPercentage = (value: number, withSign = false) =>
formatPercentage(value, {
maximumFractionDigits: 1,
signDisplay: withSign ? "always" : "auto",
});
export const getGoalProgressStatusColorClass = (status: GoalProgressStatus) =>
status === "exceeded" ? "text-destructive" : "";
export const mapGoalProgressCategoriesToBudgetCategories = (
categories: GoalProgressCategory[],
): BudgetCategory[] =>
categories.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
export const mapGoalProgressItemToBudget = (
item: GoalProgressItem,
): Budget => ({
id: item.id,
amount: item.budgetAmount,
spent: item.spentAmount,
period: item.period,
createdAt: item.createdAt,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
icon: item.categoryIcon,
}
: null,
});

View File

@@ -1,8 +1,8 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
const BUDGET_CRITICAL_THRESHOLD = 80;

View File

@@ -1,12 +1,18 @@
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
buildPeriodWindow,
formatPeriodMonthShort,
getCurrentPeriod,
} from "@/lib/utils/period";
export type MonthData = {
month: string;
@@ -20,47 +26,12 @@ export type IncomeExpenseBalanceData = {
months: MonthData[];
};
const MONTH_LABELS: Record<string, string> = {
"01": "jan",
"02": "fev",
"03": "mar",
"04": "abr",
"05": "mai",
"06": "jun",
"07": "jul",
"08": "ago",
"09": "set",
"10": "out",
"11": "nov",
"12": "dez",
};
const generateLast6Months = (currentPeriod: string): string[] => {
const [yearStr, monthStr] = currentPeriod.split("-");
let year = Number.parseInt(yearStr ?? "", 10);
let month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
const now = new Date();
year = now.getFullYear();
month = now.getMonth() + 1;
try {
return buildPeriodWindow(currentPeriod, 6);
} catch {
return buildPeriodWindow(getCurrentPeriod(), 6);
}
const periods: string[] = [];
for (let i = 5; i >= 0; i--) {
let targetMonth = month - i;
let targetYear = year;
while (targetMonth <= 0) {
targetMonth += 12;
targetYear -= 1;
}
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
}
return periods;
};
export async function fetchIncomeExpenseBalance(
@@ -85,17 +56,11 @@ export async function fetchIncomeExpenseBalance(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminFilters({ userId, adminPagadorId }),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
@@ -117,12 +82,10 @@ export async function fetchIncomeExpenseBalance(
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,

View File

@@ -0,0 +1,116 @@
import type { InstallmentExpense } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
} from "@/lib/installments/utils";
export type InstallmentExpenseDisplay = {
compactLabel: string | null;
isLast: boolean;
remainingInstallments: number;
remainingAmount: number;
endDate: string | null;
progress: number;
};
export const buildInstallmentCompactLabel = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
export const isInstallmentLast = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return false;
}
return currentInstallment === installmentCount && installmentCount > 1;
};
export const calculateInstallmentRemainingCount = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return 0;
}
return Math.max(0, installmentCount - currentInstallment);
};
export const calculateInstallmentRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) =>
amount *
calculateInstallmentRemainingCount(currentInstallment, installmentCount);
export const formatInstallmentEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) {
return null;
}
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
};
export const buildInstallmentProgress = (
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export const buildInstallmentExpenseDisplay = (
expense: InstallmentExpense,
): InstallmentExpenseDisplay => {
const { amount, currentInstallment, installmentCount, period } = expense;
return {
compactLabel: buildInstallmentCompactLabel(
currentInstallment,
installmentCount,
),
isLast: isInstallmentLast(currentInstallment, installmentCount),
remainingInstallments: calculateInstallmentRemainingCount(
currentInstallment,
installmentCount,
),
remainingAmount: calculateInstallmentRemainingAmount(
amount,
currentInstallment,
installmentCount,
),
endDate: formatInstallmentEndDate(
period,
currentInstallment,
installmentCount,
),
progress: buildInstallmentProgress(currentInstallment, installmentCount),
};
};

View File

@@ -0,0 +1,104 @@
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import type { PaymentDialogState } from "@/lib/dashboard/use-payment-dialog-controller";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { getBusinessDateString } from "@/lib/utils/date";
import {
buildDueDateInfoFromPeriodDay,
formatFinancialDateLabel,
} from "@/lib/utils/financial-dates";
import { formatPercentage } from "@/lib/utils/percentage";
import { formatPeriodForUrl } from "@/lib/utils/period";
export type InvoiceDialogState = PaymentDialogState;
export type InvoiceLogoTone = "muted" | "accent";
type InvoicePaymentDateInfo = {
label: string;
};
type InvoiceDueDateInfo = {
label: string;
date: string | null;
};
export const buildInvoiceInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
};
export const parseInvoiceDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildDueDateInfoFromPeriodDay(period, dueDay);
};
export const formatInvoicePaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
const label = formatFinancialDateLabel(value, "Pago em");
if (!label) {
return null;
}
return {
label,
};
};
export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return "0%";
}
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
return formatPercentage(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
export const getInvoiceShareLabel = (amount: number, total: number) => {
if (total <= 0) {
return "0% do total";
}
const percentage = (amount / total) * 100;
return `${formatInvoiceSharePercentage(percentage)} do total`;
};
export const getInvoiceStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {
if (statusLabel.toLowerCase() === "em aberto") {
return "info";
}
return "success";
};
export const buildInvoiceDetailsHref = (cardId: string, period: string) =>
`/cartoes/${cardId}/fatura?periodo=${formatPeriodForUrl(period)}`;
export const markInvoiceAsPaid = (
invoice: DashboardInvoice,
paidAt: string,
): DashboardInvoice => ({
...invoice,
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
paidAt,
});
export const isInvoicePaid = (status: InvoicePaymentStatus) =>
status === INVOICE_PAYMENT_STATUS.PAID;

View File

@@ -1,13 +1,14 @@
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
type RawDashboardInvoice = {
invoiceId: string | null;
@@ -24,6 +25,15 @@ type RawDashboardInvoice = {
invoiceCreatedAt: Date | null;
};
type RawInvoiceBreakdownRow = {
cardId: string | null;
period: string | null;
pagadorId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
amount: number | string | null;
};
export type InvoicePagadorBreakdown = {
pagadorId: string | null;
pagadorName: string;
@@ -51,22 +61,6 @@ export type DashboardInvoicesSnapshot = {
totalPending: number;
};
const toISODate = (value: Date | string | null | undefined) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value.slice(0, 10);
}
return null;
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
@@ -113,7 +107,7 @@ export async function fetchDashboardInvoices(
!Number.isNaN(row.purchaseDate.valueOf())
? row.purchaseDate
: row.createdAt;
const isoDate = toISODate(resolvedDate);
const isoDate = toDateOnlyString(resolvedDate);
if (!isoDate) {
continue;
}
@@ -123,7 +117,10 @@ export async function fetchDashboardInvoices(
}
}
const [rows, breakdownRows] = await Promise.all([
const [rows, breakdownRows]: [
RawDashboardInvoice[],
RawInvoiceBreakdownRow[],
] = await Promise.all([
db
.select({
invoiceId: faturas.id,
@@ -216,54 +213,57 @@ export async function fetchDashboardInvoices(
breakdownMap.set(key, current);
}
const invoices = rows
.map((row: RawDashboardInvoice | null) => {
if (!row) return null;
const invoices: DashboardInvoice[] = [];
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
for (const row of rows) {
if (!row) {
continue;
}
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
if (!shouldInclude) {
return null;
}
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
const resolvedPeriod = row.period ?? period;
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
const paidAt =
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentMap.get(paymentKey) ?? toISODate(row.invoiceCreatedAt))
: null;
if (!shouldInclude) {
continue;
}
return {
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
cardId: row.cardId,
cardName: row.cardName,
cardBrand: row.cardBrand,
cardStatus: row.cardStatus,
logo: row.logo,
dueDay: row.dueDay,
period: resolvedPeriod,
paymentStatus,
totalAmount,
paidAt,
pagadorBreakdown: (
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
).sort((a, b) => b.amount - a.amount),
} satisfies DashboardInvoice;
})
.filter((invoice): invoice is DashboardInvoice => invoice !== null)
.sort((a, b) => {
// Ordena do maior valor para o menor
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
const resolvedPeriod = row.period ?? period;
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
const paidAt =
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentMap.get(paymentKey) ?? toDateOnlyString(row.invoiceCreatedAt))
: null;
invoices.push({
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
cardId: row.cardId,
cardName: row.cardName,
cardBrand: row.cardBrand,
cardStatus: row.cardStatus,
logo: row.logo,
dueDay: row.dueDay,
period: resolvedPeriod,
paymentStatus,
totalAmount,
paidAt,
pagadorBreakdown: (
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
).sort((a, b) => b.amount - a.amount),
});
}
invoices.sort((a, b) => {
// Ordena do maior valor para o menor
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
});
const totalPending = invoices.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {

View File

@@ -0,0 +1,56 @@
import { and, eq, ilike, isNull, ne, not, or } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
type DashboardAdminFiltersParams = {
userId: string;
adminPagadorId: string;
};
type DashboardAdminPeriodFiltersParams = DashboardAdminFiltersParams & {
period: string;
};
export const buildDashboardAdminFilters = ({
userId,
adminPagadorId,
}: DashboardAdminFiltersParams) =>
[
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
] as const;
export const buildDashboardAdminPeriodFilters = ({
userId,
period,
adminPagadorId,
}: DashboardAdminPeriodFiltersParams) =>
[
...buildDashboardAdminFilters({ userId, adminPagadorId }),
eq(lancamentos.period, period),
] as const;
export const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
export const excludeAutoGeneratedEntryNotes = () =>
or(
isNull(lancamentos.note),
and(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
);
export const excludeInitialBalanceWhenConfigured = () =>
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
);

View File

@@ -0,0 +1,15 @@
import type { Note } from "@/components/anotacoes/types";
import type { DashboardNote } from "@/lib/dashboard/notes";
export const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
id: note.id,
title: note.title,
description: note.description,
type: note.type,
tasks: note.tasks,
arquivada: note.arquivada,
createdAt: note.createdAt,
});
export const mapDashboardNotesToNotes = (notes: DashboardNote[]) =>
notes.map(mapDashboardNoteToNote);

View File

@@ -11,6 +11,14 @@ import {
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import {
buildDateOnlyStringFromPeriodDay,
getBusinessDateString,
isDateOnlyPast,
isDateOnlyWithinDays,
toDateOnlyString,
} from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type NotificationType = "overdue" | "due_soon";
@@ -46,100 +54,6 @@ export type DashboardNotificationsSnapshot = {
const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
/**
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
* @param period Período no formato YYYY-MM
* @param dueDay Dia do vencimento (1-31)
* @returns Data de vencimento no formato YYYY-MM-DD
*/
function calculateDueDate(period: string, dueDay: string): string {
const [year, month] = period.split("-");
const yearNumber = Number(year);
const monthNumber = Number(month);
const hasValidMonth =
Number.isInteger(yearNumber) &&
Number.isInteger(monthNumber) &&
monthNumber >= 1 &&
monthNumber <= 12;
const daysInMonth = hasValidMonth
? new Date(yearNumber, monthNumber, 0).getDate()
: null;
const dueDayNumber = Number(dueDay);
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
const clampedDay =
hasValidMonth && hasValidDueDay && daysInMonth
? Math.min(dueDayNumber, daysInMonth)
: hasValidDueDay
? dueDayNumber
: null;
const day = clampedDay
? String(clampedDay).padStart(2, "0")
: dueDay.padStart(2, "0");
const normalizedMonth =
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
return `${year}-${normalizedMonth}-${day}`;
}
/**
* Normaliza uma data para o início do dia em UTC (00:00:00)
*/
function normalizeDate(date: Date): Date {
return new Date(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
0,
0,
0,
0,
),
);
}
/**
* Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone)
*/
function parseUTCDate(dateString: string): Date {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje)
*/
function isOverdue(dueDate: string, today: Date): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
return dueNormalized < today;
}
/**
* Verifica se uma data vence nos próximos X dias (incluindo hoje)
*/
function isDueWithinDays(
dueDate: string,
today: Date,
daysThreshold: number,
): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
const limitDate = new Date(today);
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
return dueNormalized >= today && dueNormalized <= limitDate;
}
function toNum(value: unknown): number {
if (typeof value === "number") return value;
return Number(value) || 0;
}
/**
* Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo
@@ -150,7 +64,7 @@ export async function fetchDashboardNotifications(
userId: string,
currentPeriod: string,
): Promise<DashboardNotificationsSnapshot> {
const today = normalizeDate(new Date());
const today = getBusinessDateString();
const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
@@ -285,8 +199,12 @@ export async function fetchDashboardNotifications(
// Faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const amount = toNum(invoice.totalAmount);
const dueDate = buildDateOnlyStringFromPeriodDay(
invoice.period,
invoice.dueDay,
);
if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount);
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
@@ -307,8 +225,13 @@ export async function fetchDashboardNotifications(
// Faturas do período atual
for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const amount = toNum(invoice.totalAmount);
const transactionCount = toNum(invoice.transactionCount);
const dueDate = buildDateOnlyStringFromPeriodDay(
invoice.period,
invoice.dueDay,
);
if (!dueDate) continue;
const amount = toNumber(invoice.totalAmount);
const transactionCount = toNumber(invoice.transactionCount);
const paymentStatus =
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
@@ -319,9 +242,12 @@ export async function fetchDashboardNotifications(
if (!shouldInclude) continue;
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const invoiceIsOverdue = isDateOnlyPast(dueDate, today);
const invoiceIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
@@ -343,17 +269,18 @@ export async function fetchDashboardNotifications(
// Boletos
for (const boleto of boletosRows) {
if (!boleto.dueDate) continue;
const dueDate =
boleto.dueDate instanceof Date
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
: boleto.dueDate;
const dueDate = toDateOnlyString(boleto.dueDate);
if (!dueDate) continue;
const boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const boletoIsOverdue = isDateOnlyPast(dueDate, today);
const boletoIsDueSoon = isDateOnlyWithinDays(
dueDate,
DAYS_THRESHOLD,
today,
);
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNum(boleto.amount);
const amount = toNumber(boleto.amount);
if (isOldPeriod) {
notifications.push({
@@ -391,8 +318,8 @@ export async function fetchDashboardNotifications(
const budgetNotifications: BudgetNotification[] = [];
for (const row of budgetRows) {
const budgetAmount = toNum(row.budgetAmount);
const spentAmount = toNum(row.spentAmount);
const budgetAmount = toNumber(row.budgetAmount);
const spentAmount = toNumber(row.spentAmount);
if (budgetAmount <= 0) continue;
const usedPercentage = (spentAmount / budgetAmount) * 100;

View File

@@ -1,10 +1,10 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = {

View File

@@ -0,0 +1,10 @@
import { formatPercentage } from "@/lib/utils/percentage";
export const formatPaymentBreakdownPercentage = (value: number) =>
formatPercentage(value, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
export const formatPaymentBreakdownTransactionsLabel = (transactions: number) =>
`${transactions} ${transactions === 1 ? "lançamento" : "lançamentos"}`;

View File

@@ -0,0 +1,11 @@
export type PaymentOverviewTab = "conditions" | "methods";
export const DEFAULT_PAYMENT_OVERVIEW_TAB: PaymentOverviewTab = "conditions";
export const parsePaymentOverviewTab = (value: string): PaymentOverviewTab => {
if (value === "methods") {
return "methods";
}
return DEFAULT_PAYMENT_OVERVIEW_TAB;
};

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentConditionSummary = {
condition: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentConditions(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.condition);
const summaries = rows.map((row) => {
const summaries = rows.map((row: (typeof rows)[number]) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentConditions(
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const conditions = summaries
.map((item) => ({
.map((item: (typeof summaries)[number]) => ({
condition: item.condition,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentConditions(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
conditions,

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentMethodSummary = {
paymentMethod: string;
@@ -37,22 +37,18 @@ export async function fetchPaymentMethods(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.paymentMethod);
const summaries = rows.map((row) => {
const summaries = rows.map((row: (typeof rows)[number]) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
@@ -63,10 +59,13 @@ export async function fetchPaymentMethods(
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const overallTotal = summaries.reduce(
(acc: number, item: (typeof summaries)[number]) => acc + item.amount,
0,
);
const methods = summaries
.map((item) => ({
.map((item: (typeof summaries)[number]) => ({
paymentMethod: item.paymentMethod,
amount: item.amount,
transactions: item.transactions,
@@ -75,7 +74,10 @@ export async function fetchPaymentMethods(
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
.sort(
(a: (typeof summaries)[number], b: (typeof summaries)[number]) =>
b.amount - a.amount,
);
return {
methods,

View File

@@ -1,9 +1,12 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, inArray, sql } from "drizzle-orm";
import { lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import {
buildDashboardAdminPeriodFilters,
excludeAutoInvoiceEntries,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type PaymentStatusCategory = {
total: number;
@@ -51,11 +54,13 @@ export async function fetchPaymentStatus(
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
excludeAutoInvoiceEntries(),
),
)
.groupBy(lancamentos.transactionType);

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type CategoryOption = {
id: string;
@@ -68,19 +68,13 @@ export async function fetchPurchasesByCategory(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.pagadorId, adminPagadorId),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
inArray(categorias.type, ["despesa", "receita"]),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.orderBy(desc(lancamentos.purchaseDate));

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
buildDashboardAdminPeriodFilters,
excludeAutoGeneratedEntryNotes,
} from "@/lib/dashboard/lancamento-filters";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
export type TopEstablishment = {
id: string;
@@ -55,17 +55,13 @@ export async function fetchTopEstablishments(
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
...buildDashboardAdminPeriodFilters({
userId,
period,
adminPagadorId,
}),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
excludeAutoGeneratedEntryNotes(),
),
)
.groupBy(lancamentos.name)
@@ -76,9 +72,11 @@ export async function fetchTopEstablishments(
.limit(10);
const establishments = rows
.filter((row) => shouldIncludeEstablishment(row.name))
.filter((row: (typeof rows)[number]) =>
shouldIncludeEstablishment(row.name),
)
.map(
(row): TopEstablishment => ({
(row: (typeof rows)[number]): TopEstablishment => ({
id: row.name,
name: row.name,
amount: Math.abs(toNumber(row.totalAmount)),

View File

@@ -0,0 +1,46 @@
"use client";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
import type { DashboardBill } from "@/lib/dashboard/bills";
import {
type BillDialogState,
getCurrentBillDateString,
markBillAsSettled,
} from "@/lib/dashboard/bills-helpers";
import {
type PaymentDialogController,
usePaymentDialogController,
} from "@/lib/dashboard/use-payment-dialog-controller";
const EMPTY_BILLS: DashboardBill[] = [];
export type BillWidgetController = Omit<
PaymentDialogController<DashboardBill>,
"selectedItem"
> & {
selectedBill: DashboardBill | null;
modalState: BillDialogState;
};
export function useBillWidgetController(
bills?: DashboardBill[],
): BillWidgetController {
const safeBills = bills ?? EMPTY_BILLS;
const controller = usePaymentDialogController({
items: safeBills,
getItemId: (bill) => bill.id,
isItemConfirmed: (bill) => bill.isSettled,
executeConfirm: (bill) =>
toggleLancamentoSettlementAction({
id: bill.id,
value: true,
}),
applyConfirmedState: (bill) =>
markBillAsSettled(bill, getCurrentBillDateString()),
});
return {
...controller,
selectedBill: controller.selectedItem,
};
}

Some files were not shown because too many files have changed in this diff Show More