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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
PendingInvoice,
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
PendingInvoice,
|
||||
} from "@/lib/dashboard/expenses/installment-analysis";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };
|
||||
|
||||
@@ -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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user