feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
"use client";
import { toggleLancamentoSettlementAction } from "@/app/(dashboard)/lancamentos/actions";
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 {
RiBarcodeFill,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiLoader4Line,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
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);
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">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<RiBarcodeFill className="size-5" />
</div>
<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-green-600 dark:text-green-400"
)}
>
{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-green-600 dark:text-green-400">
<RiCheckboxCircleFill className="size-3" /> Pago
</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-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-emerald-500/10 text-emerald-500">
<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 className="gap-3 text-center sm:text-left">
<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="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 bg-muted/50 p-4 text-center sm:flex-row sm:text-left">
<div className="flex size-12 shrink-0 items-center justify-center">
<RiBarcodeFill className="size-8" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{selectedBoleto.name}
</p>
{selectedBoletoDueLabel ? (
<p className="text-xs text-muted-foreground">
{selectedBoletoDueLabel}
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 text-sm">
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Valor do boleto
</span>
<MoneyValues
amount={selectedBoleto.amount}
className="text-lg"
/>
</div>
<div className="flex items-center justify-between rounded border border-border/60 px-3 py-2">
<span className="text-xs uppercase text-muted-foreground/80">
Status atual
</span>
<span className="text-sm font-medium">
<Badge
variant={getStatusBadgeVariant(
selectedBoleto.isSettled ? "Pago" : "Pendente"
)}
className="text-xs"
>
{selectedBoleto.isSettled ? "Pago" : "Pendente"}
</Badge>
</span>
</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,25 @@
import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
type DashboardGridProps = {
data: DashboardData;
period: string;
};
export function DashboardGrid({ data, period }: DashboardGridProps) {
return (
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{widgetsConfig.map((widget) => (
<WidgetCard
key={widget.id}
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
>
{widget.component({ data, period })}
</WidgetCard>
))}
</section>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { main_font } from "@/public/fonts/font_index";
import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
};
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",
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 }: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
return (
<Card
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-lg 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"
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">
<h1 className="text-xl font-medium">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
}

View File

@@ -0,0 +1,179 @@
import MoneyValues from "@/components/money-values";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetProps = {
data: ExpensesByCategoryData;
period: string;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
};
export function ExpensesByCategoryWidget({
data,
period,
}: ExpensesByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
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 (
<div className="flex flex-col px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
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 formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
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">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<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-red-600"
: hasDecrease
? "text-green-600"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine 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-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{budgetExceeded ? (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite excedeu em {formatCurrency(exceededAmount)}
</>
) : (
<>
{formatPercentage(category.budgetUsedPercentage)} do
limite
</>
)}
</span>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,182 @@
import MoneyValues from "@/components/money-values";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { WidgetEmptyState } from "../widget-empty-state";
type IncomeByCategoryWidgetProps = {
data: IncomeByCategoryData;
period: string;
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
};
export function IncomeByCategoryWidget({
data,
period,
}: IncomeByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
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 (
<div className="flex flex-col gap-2 px-0">
{data.categories.map((category) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildInitials(category.categoryName);
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 formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
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">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{IconComponent ? (
<IconComponent className="size-4 text-foreground" />
) : (
<span className="text-xs font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</div>
<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-green-600"
: hasDecrease
? "text-red-600"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine 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-red-600"
: "text-blue-600 dark:text-blue-400"
}`}
/>
<span
className={
budgetExceeded
? "text-red-600"
: "text-blue-600 dark:text-blue-400"
}
>
{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>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { CardContent } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
} from "@/components/ui/chart";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { IncomeExpenseBalanceData } from "@/lib/dashboard/income-expense-balance";
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
};
const chartConfig = {
receita: {
label: "Receita",
color: "var(--chart-1)",
},
despesa: {
label: "Despesa",
color: "var(--chart-2)",
},
balanco: {
label: "Balanço",
color: "var(--chart-3)",
},
} satisfies ChartConfig;
export function IncomeExpenseBalanceWidget({
data,
}: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({
month: month.monthLabel,
receita: month.income,
despesa: month.expense,
balanco: month.balance,
}));
// Verifica se todos os valores são zero
const isEmpty = chartData.every(
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0
);
if (isEmpty) {
return (
<CardContent className="px-0">
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Nenhuma movimentação financeira no período"
description="Registre receitas e despesas para visualizar o balanço mensal."
/>
</CardContent>
);
}
return (
<CardContent className="space-y-4 px-0">
<ChartContainer
config={chartConfig}
className="h-[270px] w-full aspect-auto"
>
<BarChart
data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
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">
{payload.map((entry) => {
const config =
chartConfig[entry.dataKey as keyof typeof chartConfig];
const value = entry.value as number;
return (
<div
key={entry.dataKey}
className="flex items-center gap-2"
>
<div
className="h-2.5 w-2.5 rounded"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground">
{config?.label}:
</span>
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
/>
<Bar
dataKey="receita"
fill={chartConfig.receita.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="despesa"
fill={chartConfig.despesa.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
<Bar
dataKey="balanco"
fill={chartConfig.balanco.color}
radius={[4, 4, 0, 0]}
maxBarSize={60}
/>
</BarChart>
</ChartContainer>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: chartConfig.receita.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
}

View File

@@ -0,0 +1,190 @@
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 { RiNumbersLine } from "@remixicon/react";
import Image from "next/image";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
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-2 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>
<Progress value={progress} className="h-2" />
<p className="text-xs text-muted-foreground mt-1">
Restantes {remainingInstallments}
{endDate && ` • Termina em ${endDate}`}
{" • Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
/>
</p>
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

View File

@@ -0,0 +1,561 @@
"use client";
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 {
RiBillLine,
RiCheckboxCircleFill,
RiCheckboxCircleLine,
RiExternalLinkLine,
RiLoader4Line,
} 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 { Badge } from "../ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import { WidgetEmptyState } from "../widget-empty-state";
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}`,
};
}
const date = new Date(Date.UTC(year, month - 1, dayNumber));
return {
label: `Vence em ${DUE_DATE_FORMATTER.format(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";
};
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 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-10 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{logo ? (
<Image
src={logo}
alt={`Logo do cartão ${invoice.cardName}`}
width={44}
height={44}
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-green-600 dark:text-green-400">
{paymentInfo.label}
</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={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-green-600 dark:text-green-400 flex items-center gap-1">
<RiCheckboxCircleFill className="size-3" /> Pago
</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-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-emerald-500/10 text-emerald-500">
<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 className="gap-3">
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Revise os dados antes de confirmar. Vamos registrar a fatura
como paga.
</DialogDescription>
</DialogHeader>
{selectedInvoice ? (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/50 p-3">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
{selectedLogo ? (
<Image
src={selectedLogo}
alt={`Logo do cartão ${selectedInvoice.cardName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{buildInitials(selectedInvoice.cardName)}
</span>
)}
</div>
<div>
<p className="text-sm text-muted-foreground">Cartão</p>
<p className="text-base font-semibold text-foreground">
{selectedInvoice.cardName}
</p>
{selectedInvoice.paymentStatus !==
INVOICE_PAYMENT_STATUS.PAID ? (
<p className="text-xs text-muted-foreground">
{
parseDueDate(
selectedInvoice.period,
selectedInvoice.dueDay
).label
}
</p>
) : null}
{selectedInvoice.paymentStatus ===
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
<p className="text-xs text-emerald-600">
{selectedPaymentInfo.label}
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-1">
<div className="rounded border border-border/60 px-3 items-center py-2 flex justify-between">
<span className="text-xs uppercase text-muted-foreground/80">
Valor da fatura
</span>
<MoneyValues
amount={selectedInvoice.totalAmount}
className="text-lg"
/>
</div>
<div className="rounded border border-border/60 px-3 py-2 flex justify-between items-center">
<span className="text-xs uppercase text-muted-foreground/80">
Status atual
</span>
<span className="block text-sm">
<Badge
variant={getStatusBadgeVariant(
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]
)}
className="text-xs"
>
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
</Badge>
</span>
</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>
</>
);
}

View File

@@ -0,0 +1,130 @@
import {
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { RiBarChartBoxLine } from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import MoneyValues from "../money-values";
import { WidgetEmptyState } from "../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 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>
<CardContent className="py-2 px-0">
{displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
const logoSrc = resolveLogoSrc(account.logo);
const initials = buildInitials(account.name);
return (
<li
key={account.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-1"
>
<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-lg"
/>
</div>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-sm font-semibold uppercase text-secondary-foreground">
{initials}
</div>
)}
<div className="min-w-0">
<Link
prefetch
href={`/contas/${
account.id
}/extrato?periodo=${formatPeriodForUrl(period)}`}
className="truncate font-medium text-foreground hover:text-primary hover:underline"
>
<span className="truncate text-sm">{account.name}</span>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues amount={account.balance} />
</div>
</li>
);
})}
</ul>
)}
</CardContent>
{visibleAccounts.length > displayedAccounts.length ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>
) : null}
</>
);
}

View File

@@ -0,0 +1,88 @@
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import {
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
};
const CONDITION_ICON_CLASSES =
"flex size-10 shrink-0 items-center justify-center rounded-lg 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-4 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 gap-3">
<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

@@ -0,0 +1,87 @@
import MoneyValues from "@/components/money-values";
import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import { RiBankCardLine, RiMoneyDollarCircleLine } from "@remixicon/react";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
};
const ICON_WRAPPER_CLASS =
"flex size-10 shrink-0 items-center justify-center rounded-lg 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 <RiBankCardLine 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-4 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 gap-3">
<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

@@ -0,0 +1,100 @@
"use client";
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 {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import { Progress } from "../ui/progress";
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} />
</div>
{/* Barra de progresso */}
<Progress value={confirmedPercentage} className="h-2" />
{/* Status de confirmados e pendentes */}
<div className="flex items-center justify-between gap-4 text-sm">
<div className="flex items-center gap-1.5 ">
<RiCheckboxCircleLine className="size-3 text-emerald-600" />
<MoneyValues amount={confirmed} />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5 ">
<RiHourglass2Line className="size-3 text-orange-500" />
<MoneyValues amount={pending} />
<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>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import MoneyValues from "@/components/money-values";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CATEGORY_TYPE_LABEL } from "@/lib/categorias/constants";
import type { PurchasesByCategoryData } from "@/lib/dashboard/purchases-by-category";
import { RiArrowDownLine, RiStore3Line } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
};
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 "LC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "LC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "LC";
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
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 : "";
});
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
const grouped: Record<string, typeof data.categories> = {};
for (const category of data.categories) {
if (!grouped[category.type]) {
grouped[category.type] = [];
}
const typeGroup = grouped[category.type];
if (typeGroup) {
typeGroup.push(category);
}
}
return grouped;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.categories]);
// Salva a categoria selecionada quando mudar
useEffect(() => {
if (selectedCategoryId) {
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
}
}, [selectedCategoryId]);
// Atualiza a categoria selecionada se ela não existir mais na lista
useEffect(() => {
if (
selectedCategoryId &&
!data.categories.some((cat) => cat.id === selectedCategoryId)
) {
const firstCategory = data.categories[0];
if (firstCategory) {
setSelectedCategoryId(firstCategory.id);
} else {
setSelectedCategoryId("");
}
}
}, [data.categories, selectedCategoryId]);
const currentTransactions = useMemo(() => {
if (!selectedCategoryId) {
return [];
}
return data.transactionsByCategory[selectedCategoryId] ?? [];
}, [selectedCategoryId, data.transactionsByCategory]);
const selectedCategory = useMemo(() => {
return data.categories.find((cat) => cat.id === selectedCategoryId);
}, [data.categories, selectedCategoryId]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
title="Nenhuma categoria encontrada"
description="Crie categorias de despesas ou receitas para visualizar as compras."
/>
);
}
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center gap-3">
<Select
value={selectedCategoryId}
onValueChange={setSelectedCategoryId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione uma categoria" />
</SelectTrigger>
<SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type}
</div>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{currentTransactions.length === 0 ? (
<WidgetEmptyState
icon={<RiArrowDownLine className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada"
description={
selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras."
}
/>
) : (
<ul className="flex flex-col">
{currentTransactions.map((transaction) => {
const logo = resolveLogoPath(transaction.logo);
const initials = buildInitials(transaction.name);
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{logo ? (
<Image
src={logo}
alt={`Logo de ${transaction.name}`}
width={48}
height={48}
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">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import MoneyValues from "@/components/money-values";
import type { RecentTransactionsData } from "@/lib/dashboard/recent-transactions";
import { RiExchangeLine } from "@remixicon/react";
import Image from "next/image";
import { WidgetEmptyState } from "../widget-empty-state";
type RecentTransactionsWidgetProps = {
data: RecentTransactionsData;
};
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 "LC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "LC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "LC";
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
export function RecentTransactionsWidget({
data,
}: RecentTransactionsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.transactions.length === 0 ? (
<WidgetEmptyState
icon={<RiExchangeLine className="size-6 text-muted-foreground" />}
title="Nenhum lançamento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.transactions.map((transaction) => {
const logo = resolveLogoPath(
transaction.cardLogo ?? transaction.accountLogo
);
const initials = buildInitials(transaction.name);
return (
<li
key={transaction.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{logo ? (
<Image
src={logo}
alt={`Logo de ${transaction.name}`}
width={48}
height={48}
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">
<p className="truncate text-sm font-medium text-foreground">
{transaction.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import MoneyValues from "@/components/money-values";
import { CardContent } from "@/components/ui/card";
import type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
import { RiRefreshLine } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
};
const formatOccurrences = (value: number | null) => {
if (!value) {
return "Recorrência contínua";
}
return `${value} recorrências`;
};
export function RecurringExpensesWidget({
data,
}: RecurringExpensesWidgetProps) {
if (data.expenses.length === 0) {
return (
<WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente"
description="Lançamentos recorrentes 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) => {
return (
<li
key={expense.id}
className="flex items-start gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<RiRefreshLine className="size-5 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<MoneyValues amount={expense.amount} />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
{expense.paymentMethod}
</span>
<span>{formatOccurrences(expense.recurrenceCount)}</span>
</div>
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

View File

@@ -0,0 +1,97 @@
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import {
RiArrowDownLine,
RiArrowUpLine,
RiCurrencyLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import MoneyValues from "../money-values";
type SectionCardsProps = {
metrics: DashboardCardMetrics;
};
type Trend = "up" | "down" | "flat";
const TREND_THRESHOLD = 0.005;
const CARDS = [
{ label: "Receitas", key: "receitas", icon: RiArrowUpLine },
{ label: "Despesas", key: "despesas", icon: RiArrowDownLine },
{ label: "Balanço", key: "balanco", icon: RiIncreaseDecreaseLine },
{ label: "Previsto", key: "previsto", icon: RiCurrencyLine },
] as const;
const TREND_ICONS = {
up: RiArrowUpLine,
down: RiArrowDownLine,
flat: RiSubtractLine,
} as const;
const getTrend = (current: number, previous: number): Trend => {
const diff = current - previous;
if (diff > TREND_THRESHOLD) return "up";
if (diff < -TREND_THRESHOLD) return "down";
return "flat";
};
const getPercentChange = (current: number, previous: number): string => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return "0%";
return "—";
}
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? `${change > 0 ? "+" : ""}${change.toFixed(1)}%`
: "—";
};
export function SectionCards({ metrics }: SectionCardsProps) {
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 }) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4" />
{label}
</CardTitle>
<MoneyValues className="text-2xl" amount={metric.current} />
<CardAction>
<Badge variant="outline">
<TrendIcon />
{getPercentChange(metric.current, metric.previous)}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 text-sm">
Mês anterior:
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import MoneyValues from "@/components/money-values";
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
import { RiStore2Line } from "@remixicon/react";
import Image from "next/image";
import { WidgetEmptyState } from "../widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;
};
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 "LC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "LC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "LC";
};
const formatOccurrencesLabel = (occurrences: number) => {
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
};
export function TopEstablishmentsWidget({
data,
}: TopEstablishmentsWidgetProps) {
return (
<div className="flex flex-col px-0">
{data.establishments.length === 0 ? (
<WidgetEmptyState
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
title="Nenhum estabelecimento encontrado"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{data.establishments.map((establishment) => {
const logo = resolveLogoPath(establishment.logo);
const initials = buildInitials(establishment.name);
return (
<li
key={establishment.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{logo ? (
<Image
src={logo}
alt={`Logo de ${establishment.name}`}
width={48}
height={48}
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">
<p className="truncate text-sm font-medium text-foreground">
{establishment.name}
</p>
<p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={establishment.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import MoneyValues from "@/components/money-values";
import { Switch } from "@/components/ui/switch";
import type { TopExpense, TopExpensesData } from "@/lib/dashboard/expenses/top-expenses";
import { RiArrowUpDoubleLine } from "@remixicon/react";
import Image from "next/image";
import { useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
};
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 "LC";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "LC";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "LC";
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
const shouldIncludeExpense = (expense: TopExpense) => {
const normalizedName = expense.name.trim().toLowerCase();
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
return true;
};
const isCardExpense = (expense: TopExpense) =>
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
export function TopExpensesWidget({
allExpenses,
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
return (
<div className="flex flex-col gap-4 px-0">
<div className="flex items-center justify-between gap-3">
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
{cardOnly
? "Somente cartões de crédito ou débito."
: "Todas as despesas"}
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
}
title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui."
/>
</div>
) : (
<ul className="flex flex-col">
{data.expenses.map((expense) => {
const logo = resolveLogoPath(expense.logo);
const initials = buildInitials(expense.name);
return (
<li
key={expense.id}
className="flex items-center justify-between gap-3 border-b border-dashed py-2 last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
{logo ? (
<Image
src={logo}
alt={`Logo de ${expense.name}`}
width={48}
height={48}
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">
<p className="truncate text-sm font-medium text-foreground">
{expense.name}
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(expense.purchaseDate)}
</p>
</div>
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={expense.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}