refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,363 +1,363 @@
"use client";
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 { 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,
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[];
boletos: DashboardBoleto[];
};
type ModalState = "idle" | "processing" | "success";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDateLabel = (value: string | null, prefix?: string) => {
if (!value) {
return null;
}
if (!value) {
return null;
}
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) {
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))
);
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day)),
);
return prefix ? `${prefix} ${formatted}` : formatted;
return prefix ? `${prefix} ${formatted}` : formatted;
};
const buildStatusLabel = (boleto: DashboardBoleto) => {
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
if (boleto.isSettled) {
return buildDateLabel(boleto.boletoPaymentDate, "Pago em");
}
return buildDateLabel(boleto.dueDate, "Vence 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}`;
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();
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]);
useEffect(() => {
setItems(boletos);
}, [boletos]);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId]
);
const selectedBoleto = useMemo(
() => items.find((boleto) => boleto.id === selectedId) ?? null,
[items, selectedId],
);
const isProcessing = modalState === "processing" || isPending;
const isProcessing = modalState === "processing" || isPending;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const selectedBoletoDueLabel = selectedBoleto
? buildDateLabel(selectedBoleto.dueDate, "Vencimento:")
: null;
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const handleOpenModal = (boletoId: string) => {
setSelectedId(boletoId);
setModalState("idle");
setIsModalOpen(true);
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const resetModalState = () => {
setIsModalOpen(false);
setSelectedId(null);
setModalState("idle");
};
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
const handleConfirmPayment = () => {
if (!selectedBoleto || selectedBoleto.isSettled || isProcessing) {
return;
}
setModalState("processing");
setModalState("processing");
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
startTransition(async () => {
const result = await toggleLancamentoSettlementAction({
id: selectedBoleto.id,
value: true,
});
if (!result.success) {
toast.error(result.error);
setModalState("idle");
return;
}
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");
});
};
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";
};
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 (
<>
<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">
<EstabelecimentoLogo name={boleto.name} size={38} />
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={38} />
<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="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>
<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>
<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>
{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}
<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>
</>
);
<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

@@ -1,472 +1,472 @@
"use client";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
type ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history";
import { getIconComponent } from "@/lib/utils/icons";
import {
RiArrowDownSLine,
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
type CategoryHistoryWidgetProps = {
data: CategoryHistoryData;
data: CategoryHistoryData;
};
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
// Vibrant colors for categories
const CHART_COLORS = [
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
];
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
// Load selected categories from sessionStorage on mount
useEffect(() => {
setIsClient(true);
// Load selected categories from sessionStorage on mount
useEffect(() => {
setIsClient(true);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id)
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories)
);
}
}, [selectedCategories, isClient]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, isClient]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {
return selectedCategories
.map((id, index) => {
const cat = data.categories.find((c) => c.id === id);
if (!cat) return null;
return {
...cat,
color: CHART_COLORS[index % CHART_COLORS.length],
};
})
.filter(Boolean) as Array<{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}>;
}, [data.categories, selectedCategories]);
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
// Filter chart data to include only selected categories
const filteredChartData = useMemo(() => {
if (filteredCategories.length === 0) {
return data.chartData.map((item) => ({ month: item.month }));
}
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
return data.chartData.map((item) => {
const filtered: Record<string, number | string> = { month: item.month };
filteredCategories.forEach((category) => {
filtered[category.name] = item[category.name] || 0;
});
return filtered;
});
}, [data.chartData, filteredCategories]);
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
// Build chart config dynamically from filtered categories
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
filteredCategories.forEach((category) => {
config[category.name] = {
label: category.name,
color: category.color,
};
});
return config;
}, [filteredCategories]);
return config;
}, [filteredCategories]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
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 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 &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleAddCategory = (categoryId: string) => {
if (
categoryId &&
!selectedCategories.includes(categoryId) &&
selectedCategories.length < 5
) {
setSelectedCategories([...selectedCategories, categoryId]);
setOpen(false);
}
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleRemoveCategory = (categoryId: string) => {
setSelectedCategories(selectedCategories.filter((id) => id !== categoryId));
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const handleClearAll = () => {
setSelectedCategories([]);
};
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id)
);
}, [data.allCategories, selectedCategories]);
const availableCategories = useMemo(() => {
return data.allCategories.filter(
(cat) => !selectedCategories.includes(cat.id),
);
}, [data.allCategories, selectedCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const selectedCategoryDetails = useMemo(() => {
return selectedCategories
.map((id) => data.allCategories.find((cat) => cat.id === id))
.filter(Boolean);
}, [selectedCategories, data.allCategories]);
const isEmpty = filteredCategories.length === 0;
const isEmpty = filteredCategories.length === 0;
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
// Group available categories by type
const { despesaCategories, receitaCategories } = useMemo(() => {
const despesa = availableCategories.filter((cat) => cat.type === "despesa");
const receita = availableCategories.filter((cat) => cat.type === "receita");
return { despesaCategories: despesa, receitaCategories: receita };
}, [availableCategories]);
if (!isClient) {
return null;
}
if (!isClient) {
return null;
}
return (
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => {
if (!category) return null;
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
return (
<div
key={category.id}
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
style={{ borderColor: color }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<div
className="size-3 rounded-sm"
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveCategory(category.id)}
>
<RiCloseLine className="size-3" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{selectedCategories.length}/5 selecionadas
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</div>
)}
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{selectedCategories.length < 5 && availableCategories.length > 0 && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between hover:scale-none"
>
Selecionar categorias
<RiArrowDownSLine className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-0"
align="start"
>
<Command>
<CommandInput placeholder="Pesquisar categoria..." />
<CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-red-600" />
) : (
<div className="size-3 rounded-sm bg-red-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{despesaCategories.length > 0 && (
<CommandGroup heading="Despesas">
{despesaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-red-600" />
) : (
<div className="size-3 rounded-sm bg-red-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-green-600" />
) : (
<div className="size-3 rounded-sm bg-green-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{receitaCategories.length > 0 && (
<CommandGroup heading="Receitas">
{receitaCategories.map((category) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
return (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => handleAddCategory(category.id)}
className="gap-2"
>
{IconComponent ? (
<IconComponent className="size-4 text-green-600" />
) : (
<div className="size-3 rounded-sm bg-green-600" />
)}
<span>{category.name}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
{isEmpty ? (
<div className="h-[450px] flex items-center justify-center">
<WidgetEmptyState
icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[450px] w-full">
<AreaChart
data={filteredChartData}
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
>
<defs>
{filteredCategories.map((category) => (
<linearGradient
key={`gradient-${category.id}`}
id={`gradient-${category.id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={category.color}
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor={category.color}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
className="text-xs"
tickFormatter={formatCurrencyCompact}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) {
return null;
}
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number)
);
// Sort payload by value (descending)
const sortedPayload = [...payload].sort(
(a, b) => (b.value as number) - (a.value as number),
);
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.map((entry) => {
const config =
chartConfig[
entry.dataKey as keyof typeof chartConfig
];
const value = entry.value as number;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{payload[0].payload.month}
</div>
<div className="grid gap-1.5">
{sortedPayload
.filter((entry) => (entry.value as number) > 0)
.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 justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
return (
<div
key={entry.dataKey}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: config?.color }}
/>
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
}}
/>
{filteredCategories.map((category) => (
<Area
key={category.id}
type="monotone"
dataKey={category.name}
stroke={category.color}
strokeWidth={1}
fill={`url(#gradient-${category.id})`}
fillOpacity={1}
dot={false}
activeDot={{
r: 5,
fill: category.color,
stroke: "hsl(var(--background))",
strokeWidth: 2,
}}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,287 +1,287 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import {
RiCheckLine,
RiCloseLine,
RiDragMove2Line,
RiEyeOffLine,
} from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { SortableWidget } from "@/components/dashboard/sortable-widget";
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
import { Button } from "@/components/ui/button";
import WidgetCard from "@/components/widget-card";
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import {
resetWidgetPreferences,
updateWidgetPreferences,
type WidgetPreferences,
resetWidgetPreferences,
updateWidgetPreferences,
type WidgetPreferences,
} from "@/lib/dashboard/widgets/actions";
import {
widgetsConfig,
type WidgetConfig,
type WidgetConfig,
widgetsConfig,
} from "@/lib/dashboard/widgets/widgets-config";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import {
RiCheckLine,
RiCloseLine,
RiDragMove2Line,
RiEyeOffLine,
} from "@remixicon/react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
type DashboardGridEditableProps = {
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
};
export function DashboardGridEditable({
data,
period,
initialPreferences,
data,
period,
initialPreferences,
}: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition();
const [isEditing, setIsEditing] = useState(false);
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,
);
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
// Initialize widget order and hidden state
const defaultOrder = widgetsConfig.map((w) => w.id);
const [widgetOrder, setWidgetOrder] = useState<string[]>(
initialPreferences?.order ?? defaultOrder,
);
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Get ordered and visible widgets
const orderedWidgets = useMemo(() => {
// Create a map for quick lookup
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
// Get ordered and visible widgets
const orderedWidgets = useMemo(() => {
// Create a map for quick lookup
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
// Get widgets in order, filtering out hidden ones
const ordered: WidgetConfig[] = [];
for (const id of widgetOrder) {
const widget = widgetMap.get(id);
if (widget && !hiddenWidgets.includes(id)) {
ordered.push(widget);
}
}
// Get widgets in order, filtering out hidden ones
const ordered: WidgetConfig[] = [];
for (const id of widgetOrder) {
const widget = widgetMap.get(id);
if (widget && !hiddenWidgets.includes(id)) {
ordered.push(widget);
}
}
// Add any new widgets that might not be in the order yet
for (const widget of widgetsConfig) {
if (
!widgetOrder.includes(widget.id) &&
!hiddenWidgets.includes(widget.id)
) {
ordered.push(widget);
}
}
// Add any new widgets that might not be in the order yet
for (const widget of widgetsConfig) {
if (
!widgetOrder.includes(widget.id) &&
!hiddenWidgets.includes(widget.id)
) {
ordered.push(widget);
}
}
return ordered;
}, [widgetOrder, hiddenWidgets]);
return ordered;
}, [widgetOrder, hiddenWidgets]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setWidgetOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
}, []);
if (over && active.id !== over.id) {
setWidgetOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
}, []);
const handleToggleWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) =>
prev.includes(widgetId)
? prev.filter((id) => id !== widgetId)
: [...prev, widgetId],
);
}, []);
const handleToggleWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) =>
prev.includes(widgetId)
? prev.filter((id) => id !== widgetId)
: [...prev, widgetId],
);
}, []);
const handleHideWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);
}, []);
const handleHideWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);
}, []);
const handleStartEditing = useCallback(() => {
setOriginalOrder(widgetOrder);
setOriginalHidden(hiddenWidgets);
setIsEditing(true);
}, [widgetOrder, hiddenWidgets]);
const handleStartEditing = useCallback(() => {
setOriginalOrder(widgetOrder);
setOriginalHidden(hiddenWidgets);
setIsEditing(true);
}, [widgetOrder, hiddenWidgets]);
const handleCancelEditing = useCallback(() => {
setWidgetOrder(originalOrder);
setHiddenWidgets(originalHidden);
setIsEditing(false);
}, [originalOrder, originalHidden]);
const handleCancelEditing = useCallback(() => {
setWidgetOrder(originalOrder);
setHiddenWidgets(originalHidden);
setIsEditing(false);
}, [originalOrder, originalHidden]);
const handleSave = useCallback(() => {
startTransition(async () => {
const result = await updateWidgetPreferences({
order: widgetOrder,
hidden: hiddenWidgets,
});
const handleSave = useCallback(() => {
startTransition(async () => {
const result = await updateWidgetPreferences({
order: widgetOrder,
hidden: hiddenWidgets,
});
if (result.success) {
toast.success("Preferências salvas!");
setIsEditing(false);
} else {
toast.error(result.error ?? "Erro ao salvar");
}
});
}, [widgetOrder, hiddenWidgets]);
if (result.success) {
toast.success("Preferências salvas!");
setIsEditing(false);
} else {
toast.error(result.error ?? "Erro ao salvar");
}
});
}, [widgetOrder, hiddenWidgets]);
const handleReset = useCallback(() => {
startTransition(async () => {
const result = await resetWidgetPreferences();
const handleReset = useCallback(() => {
startTransition(async () => {
const result = await resetWidgetPreferences();
if (result.success) {
setWidgetOrder(defaultOrder);
setHiddenWidgets([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
}
});
}, [defaultOrder]);
if (result.success) {
setWidgetOrder(defaultOrder);
setHiddenWidgets([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
}
});
}, [defaultOrder]);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelEditing}
disabled={isPending}
className="gap-2"
>
<RiCloseLine className="size-4" />
Cancelar
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending}
className="gap-2"
>
<RiCheckLine className="size-4" />
Salvar
</Button>
</>
) : (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)}
</div>
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-end gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleCancelEditing}
disabled={isPending}
className="gap-2"
>
<RiCloseLine className="size-4" />
Cancelar
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isPending}
className="gap-2"
>
<RiCheckLine className="size-4" />
Salvar
</Button>
</>
) : (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
/>
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="gap-2"
>
<RiDragMove2Line className="size-4" />
Reordenar
</Button>
</>
)}
</div>
{/* Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedWidgets.map((w) => w.id)}
strategy={rectSortingStrategy}
>
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{orderedWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
isEditing={isEditing}
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold">
Arraste para mover
</span>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleHideWidget(widget.id);
}}
className="gap-1 mt-2"
>
<RiEyeOffLine className="size-4" />
Ocultar
</Button>
</div>
</div>
)}
<WidgetCard
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
</div>
</SortableWidget>
))}
</section>
</SortableContext>
</DndContext>
{/* Grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedWidgets.map((w) => w.id)}
strategy={rectSortingStrategy}
>
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
{orderedWidgets.map((widget) => (
<SortableWidget
key={widget.id}
id={widget.id}
isEditing={isEditing}
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold">
Arraste para mover
</span>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleHideWidget(widget.id);
}}
className="gap-1 mt-2"
>
<RiEyeOffLine className="size-4" />
Ocultar
</Button>
</div>
</div>
)}
<WidgetCard
title={widget.title}
subtitle={widget.subtitle}
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
</div>
</SortableWidget>
))}
</section>
</SortableContext>
</DndContext>
{/* Hidden widgets indicator */}
{hiddenWidgets.length > 0 && !isEditing && (
<p className="text-center text-sm text-muted-foreground">
{hiddenWidgets.length} widget(s) oculto(s) {" "}
<button
onClick={handleReset}
className="text-primary hover:underline"
>
Restaurar todos
</button>
</p>
)}
</div>
);
{/* Hidden widgets indicator */}
{hiddenWidgets.length > 0 && !isEditing && (
<p className="text-center text-sm text-muted-foreground">
{hiddenWidgets.length} widget(s) oculto(s) {" "}
<button
onClick={handleReset}
className="text-primary hover:underline"
>
Restaurar todos
</button>
</p>
)}
</div>
);
}

View File

@@ -3,24 +3,24 @@ import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
type DashboardGridProps = {
data: DashboardData;
period: string;
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}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
))}
</section>
);
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}
action={widget.action}
>
{widget.component({ data, period })}
</WidgetCard>
))}
</section>
);
}

View File

@@ -5,74 +5,77 @@ import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
disableMagnetlines?: boolean;
name?: string | null;
disableMagnetlines?: boolean;
};
const capitalizeFirstLetter = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase() + value.slice(1) : value;
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);
const formatted = new Intl.DateTimeFormat("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "America/Sao_Paulo",
}).format(date);
return capitalizeFirstLetter(formatted);
return capitalizeFirstLetter(formatted);
};
const getGreeting = () => {
const now = new Date();
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);
// 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);
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";
}
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) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
export function DashboardWelcome({
name,
disableMagnetlines = false,
}: 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-none 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 font-medium">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
return (
<Card
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none 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 font-medium">
{greeting}, {displayName}! <span aria-hidden="true">👋</span>
</h1>
<p className="mt-2 text-sm opacity-90">{formattedDate}</p>
</div>
</Card>
);
}

View File

@@ -1,353 +1,353 @@
"use client";
import MoneyValues from "@/components/money-values";
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
RiArrowDownLine,
RiArrowUpLine,
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 MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetWithChartProps = {
data: ExpensesByCategoryData;
period: string;
data: ExpensesByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(0)}%`;
return `${Math.abs(value).toFixed(0)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function ExpensesByCategoryWidgetWithChart({
data,
period,
data,
period,
}: ExpensesByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
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)",
];
// 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)",
};
}
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]);
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,
}));
}
// 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);
// 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
);
// 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,
}));
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,
});
}
// 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]);
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."
/>
);
}
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>
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 IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(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;
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(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 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"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
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"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{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="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 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</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 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "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>
</TabsContent>
{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>
</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>
<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>
);
<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>
);
}

View File

@@ -1,179 +1,179 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
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;
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 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)}%`;
return `${Math.abs(value).toFixed(0)}%`;
};
export function ExpensesByCategoryWidget({
data,
period,
data,
period,
}: ExpensesByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
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."
/>
);
}
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;
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 formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
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>
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="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 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</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 dark:text-red-500"
: hasDecrease
? "text-green-600 dark:text-green-500"
: "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>
);
{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

@@ -1,356 +1,356 @@
"use client";
import MoneyValues from "@/components/money-values";
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
RiArrowDownLine,
RiArrowUpLine,
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 MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state";
type IncomeByCategoryWidgetWithChartProps = {
data: IncomeByCategoryData;
period: string;
data: IncomeByCategoryData;
period: string;
};
const formatPercentage = (value: number) => {
return `${Math.abs(value).toFixed(1)}%`;
return `${Math.abs(value).toFixed(1)}%`;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function IncomeByCategoryWidgetWithChart({
data,
period,
data,
period,
}: IncomeByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const periodParam = formatPeriodForUrl(period);
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)",
];
// 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)",
};
}
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]);
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,
}));
}
// 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);
// 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
);
// 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,
}));
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,
});
}
// 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]);
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."
/>
);
}
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>
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 IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(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;
<TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0">
{data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(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 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"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
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"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{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="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 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</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 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "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>
</TabsContent>
{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>
</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>
<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>
);
<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>
);
}

View File

@@ -1,182 +1,182 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiExternalLinkLine,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
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;
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 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)}%`;
return `${Math.abs(value).toFixed(1)}%`;
};
export function IncomeByCategoryWidget({
data,
period,
data,
period,
}: IncomeByCategoryWidgetProps) {
const periodParam = formatPeriodForUrl(period);
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."
/>
);
}
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;
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 formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
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>
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="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 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpLine className="size-3" />}
{hasDecrease && <RiArrowDownLine className="size-3" />}
{formatPercentage(category.percentageChange)}
</span>
)}
</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 dark:text-green-500"
: hasDecrease
? "text-red-600 dark:text-red-500"
: "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>
);
{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

@@ -1,175 +1,175 @@
"use client";
import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { CardContent } from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
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 { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData;
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)",
},
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,
data,
}: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({
month: month.monthLabel,
receita: month.income,
despesa: month.expense,
balanco: month.balance,
}));
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
);
// 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>
);
}
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;
}
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);
};
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 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-3 w-3 rounded-full"
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-full"
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-full"
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-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
return (
<div
key={entry.dataKey}
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
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-full"
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-full"
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-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div>
</div>
</CardContent>
);
}

View File

@@ -1,52 +1,52 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RiPieChartLine } from "@remixicon/react";
type AnalysisSummaryPanelProps = {
totalInstallments: number;
grandTotal: number;
selectedCount: number;
totalInstallments: number;
grandTotal: number;
selectedCount: number;
};
export function AnalysisSummaryPanel({
totalInstallments,
grandTotal,
selectedCount,
totalInstallments,
grandTotal,
selectedCount,
}: AnalysisSummaryPanelProps) {
return (
<Card className="border-primary/20">
<CardHeader className="border-b">
<div className="flex items-center gap-2">
<RiPieChartLine className="size-4 text-primary" />
<CardTitle className="text-base">Resumo</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-4">
{/* Total geral */}
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total Selecionado
</p>
<MoneyValues
amount={grandTotal}
className="text-2xl font-bold text-primary"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}
</p>
</div>
return (
<Card className="border-primary/20">
<CardHeader className="border-b">
<div className="flex items-center gap-2">
<RiPieChartLine className="size-4 text-primary" />
<CardTitle className="text-base">Resumo</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 pt-4">
{/* Total geral */}
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total Selecionado
</p>
<MoneyValues
amount={grandTotal}
className="text-2xl font-bold text-primary"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}
</p>
</div>
{/* Mensagem quando nada está selecionado */}
{selectedCount === 0 && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground">
Selecione parcelas para ver o resumo
</p>
</div>
)}
</CardContent>
</Card>
);
{/* Mensagem quando nada está selecionado */}
{selectedCount === 0 && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground">
Selecione parcelas para ver o resumo
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,210 +1,210 @@
"use client";
import {
RiCalculatorLine,
RiCheckboxBlankLine,
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
import MoneyValues from "@/components/money-values";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
RiCalculatorLine,
RiCheckboxBlankLine,
RiCheckboxLine,
} from "@remixicon/react";
import { useMemo, useState } from "react";
import { InstallmentGroupCard } from "./installment-group-card";
import type { InstallmentAnalysisData } from "./types";
type InstallmentAnalysisPageProps = {
data: InstallmentAnalysisData;
data: InstallmentAnalysisData;
};
export function InstallmentAnalysisPage({
data,
data,
}: InstallmentAnalysisPageProps) {
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
const [selectedInstallments, setSelectedInstallments] = useState<
Map<string, Set<string>>
>(new Map());
// Estado para parcelas selecionadas: Map<seriesId, Set<installmentId>>
const [selectedInstallments, setSelectedInstallments] = useState<
Map<string, Set<string>>
>(new Map());
// Calcular se está tudo selecionado (apenas parcelas não pagas)
const isAllSelected = useMemo(() => {
const allInstallmentsSelected = data.installmentGroups.every((group) => {
const groupSelection = selectedInstallments.get(group.seriesId);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled
);
if (!groupSelection || unpaidInstallments.length === 0) return false;
return groupSelection.size === unpaidInstallments.length;
});
// Calcular se está tudo selecionado (apenas parcelas não pagas)
const isAllSelected = useMemo(() => {
const allInstallmentsSelected = data.installmentGroups.every((group) => {
const groupSelection = selectedInstallments.get(group.seriesId);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled,
);
if (!groupSelection || unpaidInstallments.length === 0) return false;
return groupSelection.size === unpaidInstallments.length;
});
return allInstallmentsSelected && data.installmentGroups.length > 0;
}, [selectedInstallments, data]);
return allInstallmentsSelected && data.installmentGroups.length > 0;
}, [selectedInstallments, data]);
// Função para selecionar/desselecionar tudo
const toggleSelectAll = () => {
if (isAllSelected) {
// Desmarcar tudo
setSelectedInstallments(new Map());
} else {
// Marcar tudo (exceto parcelas já pagas)
const newInstallments = new Map<string, Set<string>>();
data.installmentGroups.forEach((group) => {
const unpaidIds = group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id);
if (unpaidIds.length > 0) {
newInstallments.set(group.seriesId, new Set(unpaidIds));
}
});
// Função para selecionar/desselecionar tudo
const toggleSelectAll = () => {
if (isAllSelected) {
// Desmarcar tudo
setSelectedInstallments(new Map());
} else {
// Marcar tudo (exceto parcelas já pagas)
const newInstallments = new Map<string, Set<string>>();
data.installmentGroups.forEach((group) => {
const unpaidIds = group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id);
if (unpaidIds.length > 0) {
newInstallments.set(group.seriesId, new Set(unpaidIds));
}
});
setSelectedInstallments(newInstallments);
}
};
setSelectedInstallments(newInstallments);
}
};
// Função para selecionar/desselecionar um grupo de parcelas
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
const newMap = new Map(selectedInstallments);
const current = newMap.get(seriesId) || new Set<string>();
// Função para selecionar/desselecionar um grupo de parcelas
const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => {
const newMap = new Map(selectedInstallments);
const current = newMap.get(seriesId) || new Set<string>();
if (current.size === installmentIds.length) {
// Já está tudo selecionado, desmarcar
newMap.delete(seriesId);
} else {
// Marcar tudo
newMap.set(seriesId, new Set(installmentIds));
}
if (current.size === installmentIds.length) {
// Já está tudo selecionado, desmarcar
newMap.delete(seriesId);
} else {
// Marcar tudo
newMap.set(seriesId, new Set(installmentIds));
}
setSelectedInstallments(newMap);
};
setSelectedInstallments(newMap);
};
// Função para selecionar/desselecionar parcela individual
const toggleInstallmentSelection = (
seriesId: string,
installmentId: string
) => {
const newMap = new Map(selectedInstallments);
// Criar uma NOVA instância do Set para React detectar a mudança
const current = new Set(newMap.get(seriesId) || []);
// Função para selecionar/desselecionar parcela individual
const toggleInstallmentSelection = (
seriesId: string,
installmentId: string,
) => {
const newMap = new Map(selectedInstallments);
// Criar uma NOVA instância do Set para React detectar a mudança
const current = new Set(newMap.get(seriesId) || []);
if (current.has(installmentId)) {
current.delete(installmentId);
if (current.size === 0) {
newMap.delete(seriesId);
} else {
newMap.set(seriesId, current);
}
} else {
current.add(installmentId);
newMap.set(seriesId, current);
}
if (current.has(installmentId)) {
current.delete(installmentId);
if (current.size === 0) {
newMap.delete(seriesId);
} else {
newMap.set(seriesId, current);
}
} else {
current.add(installmentId);
newMap.set(seriesId, current);
}
setSelectedInstallments(newMap);
};
setSelectedInstallments(newMap);
};
// Calcular totais
const { grandTotal, selectedCount } = useMemo(() => {
let installmentsSum = 0;
let installmentsCount = 0;
// Calcular totais
const { grandTotal, selectedCount } = useMemo(() => {
let installmentsSum = 0;
let installmentsCount = 0;
selectedInstallments.forEach((installmentIds, seriesId) => {
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
if (group) {
installmentIds.forEach((id) => {
const installment = group.pendingInstallments.find(
(i) => i.id === id
);
if (installment && !installment.isSettled) {
installmentsSum += installment.amount;
installmentsCount++;
}
});
}
});
selectedInstallments.forEach((installmentIds, seriesId) => {
const group = data.installmentGroups.find((g) => g.seriesId === seriesId);
if (group) {
installmentIds.forEach((id) => {
const installment = group.pendingInstallments.find(
(i) => i.id === id,
);
if (installment && !installment.isSettled) {
installmentsSum += installment.amount;
installmentsCount++;
}
});
}
});
return {
grandTotal: installmentsSum,
selectedCount: installmentsCount,
};
}, [selectedInstallments, data]);
return {
grandTotal: installmentsSum,
selectedCount: installmentsCount,
};
}, [selectedInstallments, data]);
const hasNoData = data.installmentGroups.length === 0;
const hasNoData = data.installmentGroups.length === 0;
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-3xl font-bold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
selecionadas
</p>
</CardContent>
</Card>
return (
<div className="flex flex-col gap-4">
{/* Card de resumo principal */}
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-3xl font-bold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
selecionadas
</p>
</CardContent>
</Card>
{/* Botões de ação */}
{!hasNoData && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="gap-2"
>
{isAllSelected ? (
<RiCheckboxLine className="size-4" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
</Button>
</div>
)}
{/* Botões de ação */}
{!hasNoData && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="gap-2"
>
{isAllSelected ? (
<RiCheckboxLine className="size-4" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
</Button>
</div>
)}
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}
group={group}
selectedInstallments={
selectedInstallments.get(group.seriesId) || new Set()
}
onToggleGroup={() =>
toggleGroupSelection(
group.seriesId,
group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id)
)
}
onToggleInstallment={(installmentId) =>
toggleInstallmentSelection(group.seriesId, installmentId)
}
/>
))}
</div>
)}
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}
group={group}
selectedInstallments={
selectedInstallments.get(group.seriesId) || new Set()
}
onToggleGroup={() =>
toggleGroupSelection(
group.seriesId,
group.pendingInstallments
.filter((i) => !i.isSettled)
.map((i) => i.id),
)
}
onToggleInstallment={(installmentId) =>
toggleInstallmentSelection(group.seriesId, installmentId)
}
/>
))}
</div>
)}
{/* Estado vazio */}
{hasNoData && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
<div className="text-center">
<p className="font-medium">Nenhuma parcela pendente</p>
<p className="text-sm text-muted-foreground">
Você está em dia com seus pagamentos!
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
{/* Estado vazio */}
{hasNoData && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
<div className="text-center">
<p className="font-medium">Nenhuma parcela pendente</p>
<p className="text-sm text-muted-foreground">
Você está em dia com seus pagamentos!
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,235 +1,239 @@
"use client";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils/ui";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import type { InstallmentGroup } from "./types";
type InstallmentGroupCardProps = {
group: InstallmentGroup;
selectedInstallments: Set<string>;
onToggleGroup: () => void;
onToggleInstallment: (installmentId: string) => void;
group: InstallmentGroup;
selectedInstallments: Set<string>;
onToggleGroup: () => void;
onToggleInstallment: (installmentId: string) => void;
};
export function InstallmentGroupCard({
group,
selectedInstallments,
onToggleGroup,
onToggleInstallment,
group,
selectedInstallments,
onToggleGroup,
onToggleInstallment,
}: InstallmentGroupCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled
);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled,
);
const unpaidCount = unpaidInstallments.length;
const unpaidCount = unpaidInstallments.length;
const isFullySelected =
selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0;
const isFullySelected =
selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
: 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
: 0;
const selectedAmount = group.pendingInstallments
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0);
const selectedAmount = group.pendingInstallments
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount,
0
);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount,
0
);
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
return (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-2">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
return (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-2">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex gap-1 items-center">
{group.cartaoLogo && (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName}
className="h-6 w-auto object-contain rounded"
/>
)}
<span className="font-medium">{group.name}</span>|
<span className="text-xs text-muted-foreground">
{group.cartaoName}
</span>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex gap-1 items-center">
{group.cartaoLogo && (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName}
className="h-6 w-auto object-contain rounded"
/>
)}
<span className="font-medium">{group.name}</span>|
<span className="text-xs text-muted-foreground">
{group.cartaoName}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-bold"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
</div>
</div>
</div>
<div className="shrink-0 flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-bold"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
</div>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-2 flex items-center px-1 justify-between text-xs text-muted-foreground">
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
</span>
<div className="flex items-center gap-2">
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado: <MoneyValues amount={selectedAmount} className="text-xs font-medium text-primary inline" />
</span>
)}
</div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-2 flex items-center px-1 justify-between text-xs text-muted-foreground">
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
</span>
<div className="flex items-center gap-2">
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado:{" "}
<MoneyValues
amount={selectedAmount}
className="text-xs font-medium text-primary inline"
/>
</span>
)}
</div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
</div>
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="px-8 mt-2 flex flex-col gap-2">
{group.pendingInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const isPaid = installment.isSettled;
const dueDate = installment.dueDate
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
: format(installment.purchaseDate, "dd/MM/yyyy", {
locale: ptBR,
});
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="px-8 mt-2 flex flex-col gap-2">
{group.pendingInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const isPaid = installment.isSettled;
const dueDate = installment.dueDate
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
: format(installment.purchaseDate, "dd/MM/yyyy", {
locale: ptBR,
});
return (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && !isPaid && "border-primary/50 bg-primary/5",
isPaid &&
"border-green-400 bg-green-50 dark:border-green-900 dark:bg-green-950/30"
)}
>
<Checkbox
checked={isPaid ? false : isSelected}
disabled={isPaid}
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
return (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && !isPaid && "border-primary/50 bg-primary/5",
isPaid &&
"border-green-400 bg-green-50 dark:border-green-900 dark:bg-green-950/30",
)}
>
<Checkbox
checked={isPaid ? false : isSelected}
disabled={isPaid}
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
"text-xs font-medium",
isPaid &&
"text-green-700 dark:text-green-400 line-through decoration-green-600/50"
)}
>
Parcela {installment.currentInstallment}/
{group.totalInstallments}
{isPaid && (
<Badge
variant="outline"
className="ml-1 text-xs border-none border-green-700 text-green-700 dark:text-green-400"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid
? "text-green-700 dark:text-green-500"
: "text-muted-foreground"
)}
>
Vencimento: {dueDate}
</p>
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
"text-xs font-medium",
isPaid &&
"text-green-700 dark:text-green-400 line-through decoration-green-600/50",
)}
>
Parcela {installment.currentInstallment}/
{group.totalInstallments}
{isPaid && (
<Badge
variant="outline"
className="ml-1 text-xs border-none border-green-700 text-green-700 dark:text-green-400"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid
? "text-green-700 dark:text-green-500"
: "text-muted-foreground",
)}
>
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-green-700 dark:text-green-400"
)}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-green-700 dark:text-green-400",
)}
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,177 +1,177 @@
"use client";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBillLine,
} from "@remixicon/react";
import { format, parse } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useState } from "react";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils/ui";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBillLine,
} from "@remixicon/react";
import { format, parse } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import type { PendingInvoice } from "./types";
import Image from "next/image";
type PendingInvoiceCardProps = {
invoice: PendingInvoice;
isSelected: boolean;
onToggle: () => void;
invoice: PendingInvoice;
isSelected: boolean;
onToggle: () => void;
};
export function PendingInvoiceCard({
invoice,
isSelected,
onToggle,
invoice,
isSelected,
onToggle,
}: PendingInvoiceCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Formatar período (YYYY-MM) para texto legível
const periodDate = parse(invoice.period, "yyyy-MM", new Date());
const periodText = format(periodDate, "MMMM 'de' yyyy", { locale: ptBR });
// Formatar período (YYYY-MM) para texto legível
const periodDate = parse(invoice.period, "yyyy-MM", new Date());
const periodText = format(periodDate, "MMMM 'de' yyyy", { locale: ptBR });
// Calcular data de vencimento aproximada
const dueDay = parseInt(invoice.dueDay, 10);
const dueDate = new Date(periodDate);
dueDate.setDate(dueDay);
const dueDateText = format(dueDate, "dd/MM/yyyy", { locale: ptBR });
// Calcular data de vencimento aproximada
const dueDay = parseInt(invoice.dueDay, 10);
const dueDate = new Date(periodDate);
dueDate.setDate(dueDay);
const dueDateText = format(dueDate, "dd/MM/yyyy", { locale: ptBR });
return (
<Card className={cn(isSelected && "border-primary/50 bg-primary/5")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
className="mt-1"
aria-label={`Selecionar fatura ${invoice.cartaoName} - ${periodText}`}
/>
return (
<Card className={cn(isSelected && "border-primary/50 bg-primary/5")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
className="mt-1"
aria-label={`Selecionar fatura ${invoice.cartaoName} - ${periodText}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{invoice.cartaoLogo ? (
<Image
src={invoice.cartaoLogo}
alt={invoice.cartaoName}
width={24}
height={24}
className="size-6 rounded"
/>
) : (
<div className="flex size-6 items-center justify-center rounded bg-muted">
<RiBillLine className="size-4 text-muted-foreground" />
</div>
)}
<p className="font-medium">{invoice.cartaoName}</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize">{periodText}</span>
<span>-</span>
<span>Vencimento: {dueDateText}</span>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{invoice.cartaoLogo ? (
<Image
src={invoice.cartaoLogo}
alt={invoice.cartaoName}
width={24}
height={24}
className="size-6 rounded"
/>
) : (
<div className="flex size-6 items-center justify-center rounded bg-muted">
<RiBillLine className="size-4 text-muted-foreground" />
</div>
)}
<p className="font-medium">{invoice.cartaoName}</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize">{periodText}</span>
<span>-</span>
<span>Vencimento: {dueDateText}</span>
</div>
</div>
<MoneyValues
amount={invoice.totalAmount}
className="shrink-0 text-sm font-semibold"
/>
</div>
<MoneyValues
amount={invoice.totalAmount}
className="shrink-0 text-sm font-semibold"
/>
</div>
{/* Badge de status */}
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="destructive" className="text-xs">
Pendente
</Badge>
<Badge variant="secondary" className="text-xs">
{invoice.lancamentos.length}{" "}
{invoice.lancamentos.length === 1
? "lançamento"
: "lançamentos"}
</Badge>
</div>
{/* Badge de status */}
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="destructive" className="text-xs">
Pendente
</Badge>
<Badge variant="secondary" className="text-xs">
{invoice.lancamentos.length}{" "}
{invoice.lancamentos.length === 1
? "lançamento"
: "lançamentos"}
</Badge>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar lançamentos ({invoice.lancamentos.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver lançamentos ({invoice.lancamentos.length})
</>
)}
</button>
</div>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar lançamentos ({invoice.lancamentos.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver lançamentos ({invoice.lancamentos.length})
</>
)}
</button>
</div>
</div>
{/* Lista de lançamentos expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{invoice.lancamentos.map((lancamento) => {
const purchaseDate = format(
lancamento.purchaseDate,
"dd/MM/yyyy",
{ locale: ptBR }
);
{/* Lista de lançamentos expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{invoice.lancamentos.map((lancamento) => {
const purchaseDate = format(
lancamento.purchaseDate,
"dd/MM/yyyy",
{ locale: ptBR },
);
const installmentLabel =
lancamento.condition === "Parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount
? `${lancamento.currentInstallment}/${lancamento.installmentCount}`
: null;
const installmentLabel =
lancamento.condition === "Parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount
? `${lancamento.currentInstallment}/${lancamento.installmentCount}`
: null;
return (
<div
key={lancamento.id}
className="flex items-center gap-3 rounded-md border p-2"
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{lancamento.name}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{purchaseDate}</span>
{installmentLabel && (
<>
<span>-</span>
<span>Parcela {installmentLabel}</span>
</>
)}
{lancamento.condition !== "Parcelado" && (
<>
<span>-</span>
<span>{lancamento.condition}</span>
</>
)}
</div>
</div>
return (
<div
key={lancamento.id}
className="flex items-center gap-3 rounded-md border p-2"
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{lancamento.name}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{purchaseDate}</span>
{installmentLabel && (
<>
<span>-</span>
<span>Parcela {installmentLabel}</span>
</>
)}
{lancamento.condition !== "Parcelado" && (
<>
<span>-</span>
<span>{lancamento.condition}</span>
</>
)}
</div>
</div>
<MoneyValues
amount={lancamento.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
<MoneyValues
amount={lancamento.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
} from "@/lib/dashboard/expenses/installment-analysis";
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };

View File

@@ -1,191 +1,191 @@
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,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { InstallmentExpensesData } from "@/lib/dashboard/expenses/installment-expenses";
import {
calculateLastInstallmentDate,
formatLastInstallmentDate,
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;
data: InstallmentExpensesData;
};
const buildCompactInstallmentLabel = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`;
}
return null;
};
const isLastInstallment = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
if (!currentInstallment || !installmentCount) return false;
return currentInstallment === installmentCount && installmentCount > 1;
};
const calculateRemainingInstallments = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
if (!currentInstallment || !installmentCount) return 0;
return Math.max(0, installmentCount - currentInstallment);
};
const calculateRemainingAmount = (
amount: number,
currentInstallment: number | null,
installmentCount: number | null
amount: number,
currentInstallment: number | null,
installmentCount: number | null,
) => {
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount
);
return amount * remaining;
const remaining = calculateRemainingInstallments(
currentInstallment,
installmentCount,
);
return amount * remaining;
};
const formatEndDate = (
period: string,
currentInstallment: number | null,
installmentCount: number | null
period: string,
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount) return null;
if (!currentInstallment || !installmentCount) return null;
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount
);
const lastDate = calculateLastInstallmentDate(
period,
currentInstallment,
installmentCount,
);
return formatLastInstallmentDate(lastDate);
return formatLastInstallmentDate(lastDate);
};
const buildProgress = (
currentInstallment: number | null,
installmentCount: number | null
currentInstallment: number | null,
installmentCount: number | null,
) => {
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
if (!currentInstallment || !installmentCount || installmentCount <= 0) {
return 0;
}
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100)
);
return Math.min(
100,
Math.max(0, (currentInstallment / installmentCount) * 100),
);
};
export function InstallmentExpensesWidget({
data,
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."
/>
);
}
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 (
<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>
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>
<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>
);
<Progress value={progress} className="h-2 mt-1" />
</div>
</li>
);
})}
</ul>
</CardContent>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +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 {
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import type { DashboardAccount } from "@/lib/dashboard/accounts";
import { formatPeriodForUrl } from "@/lib/utils/period";
import MoneyValues from "../money-values";
import { WidgetEmptyState } from "../widget-empty-state";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
totalBalance: number;
maxVisible?: number;
period: string;
accounts: DashboardAccount[];
totalBalance: number;
maxVisible?: number;
period: string;
};
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
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();
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,
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;
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>
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);
<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-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-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>
)}
return (
<li
key={account.id}
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-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="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>
<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}
</>
);
{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

@@ -1,88 +1,88 @@
import MoneyValues from "@/components/money-values";
import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions";
import {
RiCheckLine,
RiLoader2Fill,
RiRefreshLine,
RiSlideshowLine,
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;
data: PaymentConditionsData;
};
const CONDITION_ICON_CLASSES =
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
"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 />,
"À 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);
new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
export function PaymentConditionsWidget({
data,
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."
/>
);
}
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 (
<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>
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="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="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>
);
<div className="mt-1">
<Progress value={condition.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,87 +1,87 @@
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 { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import { Progress } from "../ui/progress";
import { WidgetEmptyState } from "../widget-empty-state";
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
data: PaymentMethodsData;
};
const ICON_WRAPPER_CLASS =
"flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground";
"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);
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 />;
}
if (!paymentMethod) {
return <RiMoneyDollarCircleLine className="size-5" aria-hidden />;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
const icon = getPaymentMethodIcon(paymentMethod);
if (icon) {
return icon;
}
return <RiBankCard2Line className="size-5" aria-hidden />;
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."
/>
);
}
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 (
<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>
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="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="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>
);
<div className="mt-1">
<Progress value={method.percentage} />
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,100 +1,100 @@
"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 {
RiCheckboxCircleLine,
RiHourglass2Line,
RiWallet3Line,
} from "@remixicon/react";
import { Progress } from "../ui/progress";
type PaymentStatusWidgetProps = {
data: PaymentStatusData;
data: PaymentStatusData;
};
type CategorySectionProps = {
title: string;
total: number;
confirmed: number;
pending: number;
title: string;
total: number;
confirmed: number;
pending: number;
};
function CategorySection({
title,
total,
confirmed,
pending,
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;
// 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>
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" />
{/* 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>
{/* 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>
);
<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;
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>
);
}
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}
/>
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" />
{/* 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>
);
<CategorySection
title="A Pagar"
total={data.expenses.total}
confirmed={data.expenses.confirmed}
pending={data.expenses.pending}
/>
</CardContent>
);
}

View File

@@ -1,187 +1,187 @@
"use client";
import { RiArrowDownLine, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
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 { useEffect, useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
data: PurchasesByCategoryData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
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 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,
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 : "";
}
// 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 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 firstCategory = data.categories[0];
return firstCategory ? firstCategory.id : "";
});
// Agrupa categorias por tipo
const categoriesByType = useMemo(() => {
const grouped: Record<string, typeof data.categories> = {};
// 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);
}
}
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]);
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]);
// 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]);
// 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 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]);
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."
/>
);
}
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>
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) => {
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">
<EstabelecimentoLogo name={transaction.name} size={38} />
{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) => {
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">
<EstabelecimentoLogo name={transaction.name} size={38} />
<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="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>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,66 +1,66 @@
import { RiExchangeLine } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import type { RecentTransactionsData } from "@/lib/dashboard/recent-transactions";
import { RiExchangeLine } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type RecentTransactionsWidgetProps = {
data: RecentTransactionsData;
data: RecentTransactionsData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
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 formatted = formatter.format(date);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};
export function RecentTransactionsWidget({
data,
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) => {
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">
<EstabelecimentoLogo name={transaction.name} size={38} />
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) => {
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">
<EstabelecimentoLogo name={transaction.name} size={38} />
<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="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>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,66 +1,66 @@
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 type { RecurringExpensesData } from "@/lib/dashboard/expenses/recurring-expenses";
import { RiRefreshLine } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type RecurringExpensesWidgetProps = {
data: RecurringExpensesData;
data: RecurringExpensesData;
};
const formatOccurrences = (value: number | null) => {
if (!value) {
return "Recorrência contínua";
}
if (!value) {
return "Recorrência contínua";
}
return `${value} recorrências`;
return `${value} recorrências`;
};
export function RecurringExpensesWidget({
data,
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."
/>
);
}
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-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<EstabelecimentoLogo name={expense.name} size={38} />
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-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
>
<EstabelecimentoLogo name={expense.name} size={38} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate text-foreground text-sm font-medium">
{expense.name}
</p>
<MoneyValues amount={expense.amount} />
</div>
<MoneyValues amount={expense.amount} />
</div>
<div className="flex items-center justify-between 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>
);
<div className="flex items-center justify-between 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

@@ -1,24 +1,24 @@
import {
RiArrowDownLine,
RiArrowUpLine,
RiCurrencyLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import { title_font } from "@/public/fonts/font_index";
import {
RiArrowDownLine,
RiArrowUpLine,
RiCurrencyLine,
RiIncreaseDecreaseLine,
RiSubtractLine,
} from "@remixicon/react";
import MoneyValues from "../money-values";
type SectionCardsProps = {
metrics: DashboardCardMetrics;
metrics: DashboardCardMetrics;
};
type Trend = "up" | "down" | "flat";
@@ -26,75 +26,75 @@ 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 },
{ 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,
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 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
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 "—";
}
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)}%`
: "—";
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={`${title_font.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 (
<div
className={`${title_font.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 text-primary" />
{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-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
return (
<Card key={label} className="@container/card gap-2">
<CardHeader>
<CardTitle className="flex items-center gap-1">
<Icon className="size-4 text-primary" />
{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-2 text-sm">
<div className="line-clamp-1 flex gap-2 text-xs">
Mês anterior
</div>
<div className="text-muted-foreground">
<MoneyValues amount={metric.previous} />
</div>
</CardFooter>
</Card>
);
})}
</div>
);
}

View File

@@ -1,47 +1,47 @@
"use client";
import { cn } from "@/lib/utils";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type SortableWidgetProps = {
id: string;
children: ReactNode;
isEditing: boolean;
id: string;
children: ReactNode;
isEditing: boolean;
};
export function SortableWidget({
id,
children,
isEditing,
id,
children,
isEditing,
}: SortableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled: !isEditing });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative",
isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing",
)}
{...(isEditing ? { ...attributes, ...listeners } : {})}
>
{children}
</div>
);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative",
isDragging && "z-50 opacity-90",
isEditing && "cursor-grab active:cursor-grabbing",
)}
{...(isEditing ? { ...attributes, ...listeners } : {})}
>
{children}
</div>
);
}

View File

@@ -1,60 +1,60 @@
import { RiStore2Line } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values";
import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments";
import { RiStore2Line } from "@remixicon/react";
import { WidgetEmptyState } from "../widget-empty-state";
type TopEstablishmentsWidgetProps = {
data: TopEstablishmentsData;
data: TopEstablishmentsData;
};
const formatOccurrencesLabel = (occurrences: number) => {
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
if (occurrences === 1) {
return "1 lançamento";
}
return `${occurrences} lançamentos`;
};
export function TopEstablishmentsWidget({
data,
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) => {
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">
<EstabelecimentoLogo name={establishment.name} size={38} />
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) => {
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">
<EstabelecimentoLogo name={establishment.name} size={38} />
<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="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>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={establishment.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,139 +1,139 @@
"use client";
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 { Switch } from "@/components/ui/switch";
import type {
TopExpense,
TopExpensesData,
TopExpense,
TopExpensesData,
} from "@/lib/dashboard/expenses/top-expenses";
import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { WidgetEmptyState } from "../widget-empty-state";
type TopExpensesWidgetProps = {
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
allExpenses: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
};
const formatTransactionDate = (date: Date) => {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
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 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();
const normalizedName = expense.name.trim().toLowerCase();
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName === "saldo inicial") {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
if (normalizedName.includes("fatura")) {
return false;
}
return true;
return true;
};
const isCardExpense = (expense: TopExpense) =>
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
export function TopExpensesWidget({
allExpenses,
cardOnlyExpenses,
allExpenses,
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
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>();
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: 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>
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) => {
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">
<EstabelecimentoLogo name={expense.name} size={38} />
{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) => {
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">
<EstabelecimentoLogo name={expense.name} size={38} />
<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="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>
);
<div className="shrink-0 text-foreground">
<MoneyValues amount={expense.amount} />
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,92 +1,92 @@
"use client";
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
import { useState } from "react";
type WidgetSettingsDialogProps = {
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
hiddenWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onReset: () => void;
};
export function WidgetSettingsDialog({
hiddenWidgets,
onToggleWidget,
onReset,
hiddenWidgets,
onToggleWidget,
onReset,
}: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<RiSettings4Line className="size-4" />
Widgets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle>
<DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<RiSettings4Line className="size-4" />
Widgets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle>
<DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{widgetsConfig.map((widget) => {
const isVisible = !hiddenWidgets.includes(widget.id);
<div className="max-h-[400px] overflow-y-auto py-4">
<div className="space-y-3">
{widgetsConfig.map((widget) => {
const isVisible = !hiddenWidgets.includes(widget.id);
return (
<div
key={widget.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-primary shrink-0">{widget.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{widget.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{widget.subtitle}
</p>
</div>
</div>
<Switch
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>
</div>
);
})}
</div>
</div>
return (
<div
key={widget.id}
className="flex items-center justify-between gap-4 rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-primary shrink-0">{widget.icon}</span>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{widget.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{widget.subtitle}
</p>
</div>
</div>
<Switch
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>
</div>
);
})}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RiRefreshLine className="size-4" />
Restaurar Padrão
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={onReset}
className="gap-2"
>
<RiRefreshLine className="size-4" />
Restaurar Padrão
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}