mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
35
src/features/dashboard/components/bill-widget.tsx
Normal file
35
src/features/dashboard/components/bill-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { useBillWidgetController } from "@/features/dashboard/use-bill-widget-controller";
|
||||
import { BillsWidgetView } from "./bills/bills-widget-view";
|
||||
|
||||
type BillWidgetProps = {
|
||||
bills?: DashboardBill[];
|
||||
};
|
||||
|
||||
export function BillWidget({ bills }: BillWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useBillWidgetController(bills);
|
||||
|
||||
return (
|
||||
<BillsWidgetView
|
||||
bills={items}
|
||||
selectedBill={selectedBill}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
73
src/features/dashboard/components/bills/bill-list-item.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||
import {
|
||||
buildBillStatusLabel,
|
||||
isBillOverdue,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type BillListItemProps = {
|
||||
bill: DashboardBill;
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
const statusLabel = buildBillStatusLabel(bill);
|
||||
const overdue = isBillOverdue(bill);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<EstabelecimentoLogo name={bill.name} size={37} />
|
||||
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{bill.name}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={bill.amount} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={bill.isSettled}
|
||||
onClick={() => onPay(bill.id)}
|
||||
>
|
||||
{bill.isSettled ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : overdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
"Pagar"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
189
src/features/dashboard/components/bills/bill-payment-dialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
RiBarcodeFill,
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
type BillDialogState,
|
||||
formatBillDateLabel,
|
||||
getBillStatusBadgeVariant,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
|
||||
type BillPaymentDialogProps = {
|
||||
bill: DashboardBill | null;
|
||||
open: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function BillPaymentDialog({
|
||||
bill,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: BillPaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const dueLabel = bill
|
||||
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento registrado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status do boleto para pago. Em instantes ele
|
||||
aparecerá como baixado no histórico.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento do boleto</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme os dados para registrar o pagamento. Você poderá editar
|
||||
o lançamento depois, se necessário.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{bill ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<RiBarcodeFill className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{dueLabel ? (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dueLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor do Boleto
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getBillStatusBadgeVariant(
|
||||
bill.isSettled ? "Pago" : "Pendente",
|
||||
)}
|
||||
>
|
||||
{bill.isSettled ? "Pago" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !bill || bill.isSettled}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
29
src/features/dashboard/components/bills/bills-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBarcodeFill } from "@remixicon/react";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { BillListItem } from "./bill-list-item";
|
||||
|
||||
type BillsListProps = {
|
||||
bills: DashboardBill[];
|
||||
onPay: (billId: string) => void;
|
||||
};
|
||||
|
||||
export function BillsList({ bills, onPay }: BillsListProps) {
|
||||
if (bills.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum boleto cadastrado para o período selecionado"
|
||||
description="Cadastre boletos para monitorar os pagamentos aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{bills.map((bill) => (
|
||||
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BillDialogState } from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||
import { BillsList } from "./bills-list";
|
||||
|
||||
type BillsWidgetViewProps = {
|
||||
bills: DashboardBill[];
|
||||
selectedBill: DashboardBill | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: BillDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (billId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function BillsWidgetView({
|
||||
bills,
|
||||
selectedBill,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: BillsWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<BillPaymentDialog
|
||||
bill={selectedBill}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiListUnordered,
|
||||
RiPieChart2Line,
|
||||
RiPieChartLine,
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pie, PieChart, Tooltip } from "recharts";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type CategoryBreakdownVariant = "income" | "expense";
|
||||
|
||||
type CategoryBreakdownWidgetViewProps = {
|
||||
data: DashboardCategoryBreakdownData;
|
||||
period: string;
|
||||
variant: CategoryBreakdownVariant;
|
||||
};
|
||||
|
||||
const CATEGORY_BREAKDOWN_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
];
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
income: {
|
||||
emptyTitle: "Nenhuma receita encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver receitas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "receita total",
|
||||
percentageDigits: 1,
|
||||
changeClassName: {
|
||||
increase: "text-success",
|
||||
decrease: "text-destructive",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: true,
|
||||
},
|
||||
expense: {
|
||||
emptyTitle: "Nenhuma despesa encontrada",
|
||||
emptyDescription:
|
||||
"Quando houver despesas registradas, elas aparecerão aqui.",
|
||||
shareLabel: "despesa total",
|
||||
percentageDigits: 0,
|
||||
changeClassName: {
|
||||
increase: "text-destructive",
|
||||
decrease: "text-success",
|
||||
},
|
||||
listItemClassName:
|
||||
"flex flex-col py-2 border-b border-dashed last:border-0",
|
||||
includeBudgetAmount: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const formatPercentage = (value: number, digits: number) =>
|
||||
formatPercentageValue(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
export function CategoryBreakdownWidgetView({
|
||||
data,
|
||||
period,
|
||||
variant,
|
||||
}: CategoryBreakdownWidgetViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
||||
const periodParam = formatPeriodForUrl(period);
|
||||
const config = VARIANT_CONFIG[variant];
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const nextConfig: ChartConfig = {};
|
||||
|
||||
if (data.categories.length <= 7) {
|
||||
data.categories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
topCategories.forEach((category, index) => {
|
||||
nextConfig[category.categoryId] = {
|
||||
label: category.categoryName,
|
||||
color:
|
||||
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
|
||||
};
|
||||
});
|
||||
nextConfig.outros = {
|
||||
label: "Outros",
|
||||
color: "var(--chart-6)",
|
||||
};
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}, [data.categories]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data.categories.length <= 7) {
|
||||
return data.categories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
}
|
||||
|
||||
const topCategories = data.categories.slice(0, 7);
|
||||
const otherCategories = data.categories.slice(7);
|
||||
const otherTotal = otherCategories.reduce(
|
||||
(sum, category) => sum + category.currentAmount,
|
||||
0,
|
||||
);
|
||||
const otherPercentage = otherCategories.reduce(
|
||||
(sum, category) => sum + category.percentageOfTotal,
|
||||
0,
|
||||
);
|
||||
|
||||
const groupedData = topCategories.map((category) => ({
|
||||
category: category.categoryId,
|
||||
name: category.categoryName,
|
||||
value: category.currentAmount,
|
||||
percentage: category.percentageOfTotal,
|
||||
fill: chartConfig[category.categoryId]?.color,
|
||||
}));
|
||||
|
||||
if (otherCategories.length > 0) {
|
||||
groupedData.push({
|
||||
category: "outros",
|
||||
name: "Outros",
|
||||
value: otherTotal,
|
||||
percentage: otherPercentage,
|
||||
fill: chartConfig.outros?.color,
|
||||
});
|
||||
}
|
||||
|
||||
return groupedData;
|
||||
}, [data.categories, chartConfig]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||
title={config.emptyTitle}
|
||||
description={config.emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => setActiveTab(value as "list" | "chart")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="mr-1 size-3.5" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="mr-1 size-3.5" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category, index) => {
|
||||
const hasIncrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange > 0;
|
||||
const hasDecrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange < 0;
|
||||
const hasBudget = category.budgetAmount !== null;
|
||||
const budgetExceeded =
|
||||
hasBudget &&
|
||||
category.budgetUsedPercentage !== null &&
|
||||
category.budgetUsedPercentage > 100;
|
||||
const exceededAmount =
|
||||
budgetExceeded && category.budgetAmount
|
||||
? category.currentAmount - category.budgetAmount
|
||||
: 0;
|
||||
const changeClassName = hasIncrease
|
||||
? config.changeClassName.increase
|
||||
: hasDecrease
|
||||
? config.changeClassName.decrease
|
||||
: "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.categoryId}
|
||||
className={config.listItemClassName}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
colorIndex={index}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
|
||||
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{category.categoryName}
|
||||
</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
da {config.shareLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null ? (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
||||
>
|
||||
{hasIncrease ? (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
) : null}
|
||||
{hasDecrease ? (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
) : null}
|
||||
{formatPercentage(
|
||||
category.percentageChange,
|
||||
config.percentageDigits,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBudget && category.budgetUsedPercentage !== null ? (
|
||||
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||
<RiWallet3Line
|
||||
className={`size-3 ${
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
budgetExceeded ? "text-destructive" : "text-info"
|
||||
}
|
||||
>
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}{" "}
|
||||
- excedeu em {formatCurrency(exceededAmount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatPercentage(
|
||||
category.budgetUsedPercentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do limite
|
||||
{config.includeBudgetAmount &&
|
||||
category.budgetAmount !== null
|
||||
? ` ${formatCurrency(category.budgetAmount)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ payload }) =>
|
||||
formatPercentage(
|
||||
(payload as { percentage?: number } | undefined)
|
||||
?.percentage ?? 0,
|
||||
config.percentageDigits,
|
||||
)
|
||||
}
|
||||
outerRadius={75}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = payload[0]?.payload;
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercentage(
|
||||
entry.percentage,
|
||||
config.percentageDigits,
|
||||
)}{" "}
|
||||
do total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="min-w-[140px] flex flex-col gap-2">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
443
src/features/dashboard/components/category-history-widget.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiBarChartBoxLine,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { CategoryHistoryData } from "@/features/dashboard/categories/category-history-queries";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { CATEGORY_COLORS } from "@/shared/utils/category-colors";
|
||||
import { formatCurrency, formatCurrencyCompact } from "@/shared/utils/currency";
|
||||
import { getIconComponent } from "@/shared/utils/icons";
|
||||
|
||||
type CategoryHistoryWidgetProps = {
|
||||
data: CategoryHistoryData;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_SELECTED = "dashboard-category-history-selected";
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
|
||||
export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Load from sessionStorage on mount and save on changes
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Only load from storage on first render
|
||||
if (isFirstRender.current) {
|
||||
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
|
||||
}
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
} else {
|
||||
// Save to storage on subsequent changes
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY_SELECTED,
|
||||
JSON.stringify(selectedCategories),
|
||||
);
|
||||
}
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
|
||||
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 = {};
|
||||
|
||||
filteredCategories.forEach((category) => {
|
||||
config[category.name] = {
|
||||
label: category.name,
|
||||
color: category.color,
|
||||
};
|
||||
});
|
||||
|
||||
return config;
|
||||
}, [filteredCategories]);
|
||||
|
||||
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 handleClearAll = () => {
|
||||
setSelectedCategories([]);
|
||||
};
|
||||
|
||||
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 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]);
|
||||
|
||||
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 (
|
||||
<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 ? (
|
||||
<span style={{ color }}>
|
||||
<IconComponent className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
<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} modal>
|
||||
<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-destructive" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-destructive" />
|
||||
)}
|
||||
<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-success" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-success" />
|
||||
)}
|
||||
<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={(value) => formatCurrencyCompact(Number(value))}
|
||||
/>
|
||||
<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),
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
390
src/features/dashboard/components/dashboard-grid-editable.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDragMove2Line,
|
||||
RiEyeOffLine,
|
||||
RiTodoLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SortableWidget } from "@/features/dashboard/components/sortable-widget";
|
||||
import { WidgetSettingsDialog } from "@/features/dashboard/components/widget-settings-dialog";
|
||||
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||
import {
|
||||
resetWidgetPreferences,
|
||||
updateWidgetPreferences,
|
||||
type WidgetPreferences,
|
||||
} from "@/features/dashboard/widgets/actions";
|
||||
import {
|
||||
type WidgetConfig,
|
||||
widgetsConfig,
|
||||
} from "@/features/dashboard/widgets/widgets-config";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import { LancamentoDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type DashboardGridEditableProps = {
|
||||
data: DashboardData;
|
||||
period: string;
|
||||
initialPreferences: WidgetPreferences | null;
|
||||
quickActionOptions: {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||
|
||||
export function DashboardGridEditable({
|
||||
data,
|
||||
period,
|
||||
initialPreferences,
|
||||
quickActionOptions,
|
||||
}: DashboardGridEditableProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Initialize widget order and hidden state
|
||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
||||
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
|
||||
);
|
||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||
initialPreferences?.hidden ?? [],
|
||||
);
|
||||
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleDragEnd = (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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWidget = (widgetId: string) => {
|
||||
const newHidden = hiddenWidgets.includes(widgetId)
|
||||
? hiddenWidgets.filter((id) => id !== widgetId)
|
||||
: [...hiddenWidgets, widgetId];
|
||||
|
||||
setHiddenWidgets(newHidden);
|
||||
|
||||
// Salvar automaticamente ao toggle
|
||||
startTransition(async () => {
|
||||
await updateWidgetPreferences({
|
||||
order: widgetOrder,
|
||||
hidden: newHidden,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleHideWidget = (widgetId: string) => {
|
||||
setHiddenWidgets((prev) => [...prev, widgetId]);
|
||||
};
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setOriginalOrder(widgetOrder);
|
||||
setOriginalHidden(hiddenWidgets);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelEditing = () => {
|
||||
setWidgetOrder(originalOrder);
|
||||
setHiddenWidgets(originalHidden);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
startTransition(async () => {
|
||||
const result = await resetWidgetPreferences();
|
||||
|
||||
if (result.success) {
|
||||
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||
setHiddenWidgets([]);
|
||||
toast.success("Preferências restauradas!");
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao restaurar");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{!isEditing ? (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1 px-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Ações rápidas
|
||||
</span>
|
||||
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Receita"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-success/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Receita</span>
|
||||
<span className="hidden sm:inline">Nova receita</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
pagadorOptions={quickActionOptions.pagadorOptions}
|
||||
splitPagadorOptions={quickActionOptions.splitPagadorOptions}
|
||||
defaultPagadorId={quickActionOptions.defaultPagadorId}
|
||||
contaOptions={quickActionOptions.contaOptions}
|
||||
cartaoOptions={quickActionOptions.cartaoOptions}
|
||||
categoriaOptions={quickActionOptions.categoriaOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPeriod={period}
|
||||
defaultTransactionType="Despesa"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<RiAddCircleLine className="size-3.5 shrink-0 text-destructive/80" />
|
||||
</span>
|
||||
<span className="sm:hidden">Despesa</span>
|
||||
<span className="hidden sm:inline">Nova despesa</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
||||
>
|
||||
<RiTodoLine className="size-3.5 shrink-0 text-info/80" />
|
||||
<span className="sm:hidden">Anotação</span>
|
||||
<span className="hidden sm:inline">Nova anotação</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
|
||||
<WidgetSettingsDialog
|
||||
hiddenWidgets={hiddenWidgets}
|
||||
onToggleWidget={handleToggleWidget}
|
||||
onReset={handleReset}
|
||||
triggerClassName="w-full sm:w-auto"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
>
|
||||
<RiDragMove2Line className="size-4" />
|
||||
Reordenar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
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>
|
||||
)}
|
||||
<ExpandableWidgetCard
|
||||
title={widget.title}
|
||||
subtitle={widget.subtitle}
|
||||
icon={widget.icon}
|
||||
action={widget.action}
|
||||
>
|
||||
{widget.component({ data, period })}
|
||||
</ExpandableWidgetCard>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
127
src/features/dashboard/components/dashboard-metrics-cards.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpLine,
|
||||
RiArrowUpSFill,
|
||||
RiCashLine,
|
||||
RiIncreaseDecreaseLine,
|
||||
RiSubtractLine,
|
||||
} from "@remixicon/react";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type DashboardMetricsCardsProps = {
|
||||
metrics: DashboardCardMetrics;
|
||||
};
|
||||
|
||||
type Trend = "up" | "down" | "flat";
|
||||
|
||||
const TREND_THRESHOLD = 0.005;
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
label: "Receitas",
|
||||
key: "receitas",
|
||||
icon: RiArrowUpLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{
|
||||
label: "Despesas",
|
||||
key: "despesas",
|
||||
icon: RiArrowDownLine,
|
||||
invertTrend: true,
|
||||
},
|
||||
{
|
||||
label: "Balanço",
|
||||
key: "balanco",
|
||||
icon: RiIncreaseDecreaseLine,
|
||||
invertTrend: false,
|
||||
},
|
||||
{ label: "Previsto", key: "previsto", icon: RiCashLine, invertTrend: false },
|
||||
] as const;
|
||||
|
||||
const TREND_ICONS = {
|
||||
up: RiArrowUpSFill,
|
||||
down: RiArrowDownSFill,
|
||||
flat: RiSubtractLine,
|
||||
} as const;
|
||||
|
||||
const getTrend = (current: number, previous: number): Trend => {
|
||||
const diff = current - previous;
|
||||
if (diff > TREND_THRESHOLD) return "up";
|
||||
if (diff < -TREND_THRESHOLD) return "down";
|
||||
return "flat";
|
||||
};
|
||||
|
||||
const getPercentChange = (current: number, previous: number): string => {
|
||||
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
|
||||
|
||||
if (Math.abs(previous) < EPSILON) {
|
||||
if (Math.abs(current) < EPSILON) return "0%";
|
||||
return "—";
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
||||
? formatPercentage(change, {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
})
|
||||
: "—";
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: Trend, invertTrend: boolean): string => {
|
||||
if (trend === "flat") return "";
|
||||
const isPositive = invertTrend ? trend === "down" : trend === "up";
|
||||
return isPositive
|
||||
? "text-success border-success"
|
||||
: "text-destructive border-destructive";
|
||||
};
|
||||
|
||||
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
{CARDS.map(({ label, key, icon: Icon, invertTrend }) => {
|
||||
const metric = metrics[key];
|
||||
const trend = getTrend(metric.current, metric.previous);
|
||||
const TrendIcon = TREND_ICONS[trend];
|
||||
const trendColor = getTrendColor(trend, invertTrend);
|
||||
|
||||
return (
|
||||
<Card key={label} className="@container/card gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1 tracking-tighter lowercase">
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</CardTitle>
|
||||
<MoneyValues className="text-2xl" amount={metric.current} />
|
||||
<CardAction>
|
||||
<div className={`flex items-center text-xs ${trendColor}`}>
|
||||
<TrendIcon size={16} />
|
||||
{getPercentChange(metric.current, metric.previous)}
|
||||
</div>
|
||||
</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-foreground">
|
||||
<MoneyValues amount={metric.previous} />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
18
src/features/dashboard/components/dashboard-welcome.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||
|
||||
export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||
const formattedDate = formatCurrentDate();
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<section className="p-2">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<p className="text-sm mt-1">{formattedDate}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ExpensesByCategoryData } from "@/features/dashboard/categories/expenses-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type ExpensesByCategoryWidgetWithChartProps = {
|
||||
data: ExpensesByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function ExpensesByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView
|
||||
data={data}
|
||||
period={period}
|
||||
variant="expense"
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
32
src/features/dashboard/components/goals-progress-widget.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress-queries";
|
||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/use-goals-progress-widget-controller";
|
||||
import { GoalsProgressWidgetView } from "./goals-progress/goals-progress-widget-view";
|
||||
|
||||
type GoalsProgressWidgetProps = {
|
||||
data: GoalsProgressData;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) {
|
||||
const {
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
handleEdit,
|
||||
handleEditOpenChange,
|
||||
} = useGoalsProgressWidgetController(data);
|
||||
|
||||
return (
|
||||
<GoalsProgressWidgetView
|
||||
data={data}
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEdit={handleEdit}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { CategoryIconBadge } from "@/features/categories/components/category-icon-badge";
|
||||
import {
|
||||
clampGoalProgress,
|
||||
formatGoalProgressPercentage,
|
||||
getGoalProgressStatusColorClass,
|
||||
} from "@/features/dashboard/goals-progress-helpers";
|
||||
import type { GoalProgressItem as GoalProgressItemData } from "@/features/dashboard/goals-progress-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
type GoalProgressItemProps = {
|
||||
item: GoalProgressItemData;
|
||||
index: number;
|
||||
onEdit: (item: GoalProgressItemData) => void;
|
||||
};
|
||||
|
||||
export function GoalProgressItem({
|
||||
item,
|
||||
index,
|
||||
onEdit,
|
||||
}: GoalProgressItemProps) {
|
||||
const statusColor = getGoalProgressStatusColorClass(item.status);
|
||||
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
|
||||
const percentageDelta = item.usedPercentage - 100;
|
||||
|
||||
return (
|
||||
<li className="border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<CategoryIconBadge
|
||||
icon={item.categoryIcon}
|
||||
name={item.categoryName}
|
||||
colorIndex={index}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{item.categoryName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||
<MoneyValues amount={item.budgetAmount} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={`text-xs font-medium ${statusColor}`}>
|
||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(item)}
|
||||
aria-label={`Editar orçamento de ${item.categoryName}`}
|
||||
>
|
||||
<RiPencilLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-11 mt-1.5">
|
||||
<Progress value={progressValue} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
import type { GoalProgressItem } from "@/features/dashboard/goals-progress-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { GoalProgressItem as GoalProgressListItem } from "./goal-progress-item";
|
||||
|
||||
type GoalsProgressListProps = {
|
||||
items: GoalProgressItem[];
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiFundsLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum orçamento para o período"
|
||||
description="Cadastre orçamentos para acompanhar o progresso das metas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item, index) => (
|
||||
<GoalProgressListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BudgetDialog } from "@/features/budgets/components/budget-dialog";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
|
||||
type GoalsProgressWidgetDialogsProps = {
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetDialogs({
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetDialogsProps) {
|
||||
return (
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
Budget,
|
||||
BudgetCategory,
|
||||
} from "@/features/budgets/components/types";
|
||||
import type {
|
||||
GoalProgressItem,
|
||||
GoalsProgressData,
|
||||
} from "@/features/dashboard/goals-progress-queries";
|
||||
import { GoalsProgressList } from "./goals-progress-list";
|
||||
import { GoalsProgressWidgetDialogs } from "./goals-progress-widget-dialogs";
|
||||
|
||||
type GoalsProgressWidgetViewProps = {
|
||||
data: GoalsProgressData;
|
||||
selectedBudget: Budget | null;
|
||||
editOpen: boolean;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
onEdit: (item: GoalProgressItem) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function GoalsProgressWidgetView({
|
||||
data,
|
||||
selectedBudget,
|
||||
editOpen,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
onEdit,
|
||||
onEditOpenChange,
|
||||
}: GoalsProgressWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<GoalsProgressList items={data.items} onEdit={onEdit} />
|
||||
|
||||
<GoalsProgressWidgetDialogs
|
||||
selectedBudget={selectedBudget}
|
||||
editOpen={editOpen}
|
||||
categories={categories}
|
||||
defaultPeriod={defaultPeriod}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "./category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type IncomeByCategoryWidgetWithChartProps = {
|
||||
data: IncomeByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function IncomeByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: IncomeByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/income-expense-balance-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@/shared/components/ui/chart";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
type IncomeExpenseBalanceWidgetProps = {
|
||||
data: IncomeExpenseBalanceData;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
receita: {
|
||||
label: "Receita",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
balanco: {
|
||||
label: "Balanço",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function IncomeExpenseBalanceWidget({
|
||||
data,
|
||||
}: IncomeExpenseBalanceWidgetProps) {
|
||||
const chartData = data.months.map((month) => ({
|
||||
month: month.monthLabel,
|
||||
receita: month.income,
|
||||
despesa: month.expense,
|
||||
balanco: month.balance,
|
||||
}));
|
||||
|
||||
// Verifica se todos os valores são zero
|
||||
const isEmpty = chartData.every(
|
||||
(item) => item.receita === 0 && item.despesa === 0 && item.balanco === 0,
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma movimentação financeira no período"
|
||||
description="Registre receitas e despesas para visualizar o balanço mensal."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-4 px-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[270px] w-full aspect-auto"
|
||||
>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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="size-2 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="size-2 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="size-2 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="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.balanco.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCalculatorLine,
|
||||
RiCheckboxBlankLine,
|
||||
RiCheckboxLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { InstallmentGroupCard } from "./installment-group-card";
|
||||
import type { InstallmentAnalysisData } from "./types";
|
||||
|
||||
type InstallmentAnalysisPageProps = {
|
||||
data: InstallmentAnalysisData;
|
||||
};
|
||||
|
||||
export function InstallmentAnalysisPage({
|
||||
data,
|
||||
}: InstallmentAnalysisPageProps) {
|
||||
// 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;
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
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>();
|
||||
|
||||
if (current.size === installmentIds.length) {
|
||||
// Já está tudo selecionado, desmarcar
|
||||
newMap.delete(seriesId);
|
||||
} else {
|
||||
// Marcar tudo
|
||||
newMap.set(seriesId, new Set(installmentIds));
|
||||
}
|
||||
|
||||
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) || []);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 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++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
grandTotal: installmentsSum,
|
||||
selectedCount: installmentsCount,
|
||||
};
|
||||
}, [selectedInstallments, data]);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"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 "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
type InstallmentGroupCardProps = {
|
||||
group: InstallmentGroup;
|
||||
selectedInstallments: Set<string>;
|
||||
onToggleGroup: () => void;
|
||||
onToggleInstallment: (installmentId: string) => void;
|
||||
};
|
||||
|
||||
export function InstallmentGroupCard({
|
||||
group,
|
||||
selectedInstallments,
|
||||
onToggleGroup,
|
||||
onToggleInstallment,
|
||||
}: InstallmentGroupCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
|
||||
const unpaidCount = unpaidInstallments.length;
|
||||
|
||||
const isFullySelected =
|
||||
selectedInstallments.size === unpaidInstallments.length &&
|
||||
unpaidInstallments.length > 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);
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
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 flex-col gap-1">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{group.cartaoLogo && (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="h-6 w-auto object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium truncate">{group.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
| {group.cartaoName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<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 flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
|
||||
{/* Lista de parcelas expandida */}
|
||||
{isExpanded && (
|
||||
<div className="px-2 sm: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-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
||||
)}
|
||||
>
|
||||
<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-success line-through decoration-success/50",
|
||||
)}
|
||||
>
|
||||
Parcela {installment.currentInstallment}/
|
||||
{group.totalInstallments}
|
||||
{isPaid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 text-xs border-none text-success"
|
||||
>
|
||||
<RiCheckboxCircleFill /> Pago
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-1",
|
||||
isPaid ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Vencimento: {dueDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MoneyValues
|
||||
amount={installment.amount}
|
||||
className={cn(
|
||||
"shrink-0 text-sm",
|
||||
isPaid && "text-success",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type {
|
||||
InstallmentAnalysisData,
|
||||
InstallmentGroup,
|
||||
} from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
|
||||
export type { InstallmentAnalysisData, InstallmentGroup };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesWidgetView } from "./installment-expenses/installment-expenses-widget-view";
|
||||
|
||||
type InstallmentExpensesWidgetProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidget({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetProps) {
|
||||
return <InstallmentExpensesWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Image from "next/image";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/installment-expenses-helpers";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
|
||||
type InstallmentExpenseListItemProps = {
|
||||
expense: InstallmentExpense;
|
||||
};
|
||||
|
||||
export function InstallmentExpenseListItem({
|
||||
expense,
|
||||
}: InstallmentExpenseListItemProps) {
|
||||
const {
|
||||
compactLabel,
|
||||
isLast,
|
||||
remainingInstallments,
|
||||
remainingAmount,
|
||||
endDate,
|
||||
progress,
|
||||
} = buildInstallmentExpenseDisplay(expense);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{expense.name}
|
||||
</p>
|
||||
{compactLabel ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
{compactLabel}
|
||||
{isLast ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Image
|
||||
src="/icons/party.svg"
|
||||
alt="Última parcela"
|
||||
width={14}
|
||||
height={14}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="sr-only">Última parcela</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" | Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-medium"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="mt-1 h-2" />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RiNumbersLine } from "@remixicon/react";
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InstallmentExpenseListItem } from "./installment-expense-list-item";
|
||||
|
||||
type InstallmentExpensesListProps = {
|
||||
expenses: InstallmentExpense[];
|
||||
};
|
||||
|
||||
export function InstallmentExpensesList({
|
||||
expenses,
|
||||
}: InstallmentExpensesListProps) {
|
||||
if (expenses.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa parcelada"
|
||||
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{expenses.map((expense) => (
|
||||
<InstallmentExpenseListItem key={expense.id} expense={expense} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesList } from "./installment-expenses-list";
|
||||
|
||||
type InstallmentExpensesWidgetViewProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidgetView({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<InstallmentExpensesList expenses={data.expenses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
35
src/features/dashboard/components/invoices-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { useInvoicesWidgetController } from "@/features/dashboard/use-invoices-widget-controller";
|
||||
import { InvoicesWidgetView } from "./invoices/invoices-widget-view";
|
||||
|
||||
type InvoicesWidgetProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
};
|
||||
|
||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
||||
const {
|
||||
items,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
openPaymentDialog,
|
||||
closePaymentDialog,
|
||||
confirmPayment,
|
||||
} = useInvoicesWidgetController(invoices);
|
||||
|
||||
return (
|
||||
<InvoicesWidgetView
|
||||
invoices={items}
|
||||
selectedInvoice={selectedInvoice}
|
||||
isModalOpen={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onOpenPaymentDialog={openPaymentDialog}
|
||||
onClosePaymentDialog={closePaymentDialog}
|
||||
onConfirmPayment={confirmPayment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
152
src/features/dashboard/components/invoices/invoice-list-item.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
buildInvoiceDetailsHref,
|
||||
buildInvoiceInitials,
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceShareLabel,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { isDateOnlyPast } from "@/shared/utils/date";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoiceListItemProps = {
|
||||
invoice: DashboardInvoice;
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
const isOverdue =
|
||||
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
||||
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
||||
|
||||
const linkNode = (
|
||||
<Link
|
||||
prefetch
|
||||
href={detailHref}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{invoice.cardName}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={36}
|
||||
containerClassName="size-9.5"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
{hasBreakdown ? (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-72 space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Distribuição por pagador
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{breakdown.map((share, index) => (
|
||||
<li
|
||||
key={`${invoice.id}-${
|
||||
share.pagadorId ?? share.pagadorName ?? index
|
||||
}`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(share.pagadorAvatar)}
|
||||
alt={`Avatar de ${share.pagadorName}`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{buildInvoiceInitials(share.pagadorName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{share.pagadorName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getInvoiceShareLabel(
|
||||
share.amount,
|
||||
Math.abs(invoice.totalAmount),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
linkNode
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||
{isPaid && paymentInfo ? (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-auto p-0 disabled:opacity-100"
|
||||
disabled={isPaid}
|
||||
onClick={() => onPay(invoice.id)}
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : isOverdue ? (
|
||||
<span className="overdue-blink">
|
||||
<span className="overdue-blink-primary text-destructive">
|
||||
Atrasado
|
||||
</span>
|
||||
<span className="overdue-blink-secondary">Pagar</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Pagar</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
59
src/features/dashboard/components/invoices/invoice-logo.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Image from "next/image";
|
||||
import {
|
||||
buildInvoiceInitials,
|
||||
type InvoiceLogoTone,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type InvoiceLogoProps = {
|
||||
cardName: string;
|
||||
logo: string | null;
|
||||
size: number;
|
||||
containerClassName?: string;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
tone?: InvoiceLogoTone;
|
||||
};
|
||||
|
||||
export function InvoiceLogo({
|
||||
cardName,
|
||||
logo,
|
||||
size,
|
||||
containerClassName,
|
||||
imageClassName,
|
||||
fallbackClassName,
|
||||
tone = "muted",
|
||||
}: InvoiceLogoProps) {
|
||||
const resolvedLogo = resolveLogoSrc(logo);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
tone === "accent" && "bg-primary/10",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{resolvedLogo ? (
|
||||
<Image
|
||||
src={resolvedLogo}
|
||||
alt={`Logo do cartão ${cardName}`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("h-full w-full object-contain", imageClassName)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold uppercase text-muted-foreground",
|
||||
tone === "accent" && "text-primary",
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
{buildInvoiceInitials(cardName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiLoader4Line,
|
||||
RiMoneyDollarCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import {
|
||||
formatInvoicePaymentDate,
|
||||
getInvoiceStatusBadgeVariant,
|
||||
type InvoiceDialogState,
|
||||
parseInvoiceDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_LABEL,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { InvoiceLogo } from "./invoice-logo";
|
||||
|
||||
type InvoicePaymentDialogProps = {
|
||||
invoice: DashboardInvoice | null;
|
||||
open: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function InvoicePaymentDialog({
|
||||
invoice,
|
||||
open,
|
||||
modalState,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: InvoicePaymentDialogProps) {
|
||||
const isProcessing = modalState === "processing" || isPending;
|
||||
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || isProcessing) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "success" ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
|
||||
<RiCheckboxCircleLine className="size-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-base">
|
||||
Pagamento confirmado!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Atualizamos o status da fatura. O lançamento do pagamento
|
||||
aparecerá no extrato em instantes.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button type="button" onClick={onClose} className="sm:w-auto">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||
como paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invoice ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<InvoiceLogo
|
||||
cardName={invoice.cardName}
|
||||
logo={invoice.logo}
|
||||
size={40}
|
||||
tone="accent"
|
||||
containerClassName="size-10"
|
||||
fallbackClassName="text-xs"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Cartão
|
||||
</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
||||
.label
|
||||
}
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
|
||||
paymentInfo ? (
|
||||
<p className="text-sm text-success">
|
||||
{paymentInfo.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Valor da Fatura
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
className="text-lg font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RiCheckboxCircleLine className="size-4" />
|
||||
<span className="text-xs font-semibold uppercase">
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getInvoiceStatusBadgeVariant(
|
||||
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
||||
)}
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing || !invoice}
|
||||
className="relative"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
29
src/features/dashboard/components/invoices/invoices-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RiBillLine } from "@remixicon/react";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { InvoiceListItem } from "./invoice-list-item";
|
||||
|
||||
type InvoicesListProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
onPay: (invoiceId: string) => void;
|
||||
};
|
||||
|
||||
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiBillLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma fatura para o período selecionado"
|
||||
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{invoices.map((invoice) => (
|
||||
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||
import { InvoicesList } from "./invoices-list";
|
||||
|
||||
type InvoicesWidgetViewProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
selectedInvoice: DashboardInvoice | null;
|
||||
isModalOpen: boolean;
|
||||
modalState: InvoiceDialogState;
|
||||
isPending: boolean;
|
||||
onOpenPaymentDialog: (invoiceId: string) => void;
|
||||
onClosePaymentDialog: () => void;
|
||||
onConfirmPayment: () => void;
|
||||
};
|
||||
|
||||
export function InvoicesWidgetView({
|
||||
invoices,
|
||||
selectedInvoice,
|
||||
isModalOpen,
|
||||
modalState,
|
||||
isPending,
|
||||
onOpenPaymentDialog,
|
||||
onClosePaymentDialog,
|
||||
onConfirmPayment,
|
||||
}: InvoicesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
|
||||
</div>
|
||||
|
||||
<InvoicePaymentDialog
|
||||
invoice={selectedInvoice}
|
||||
open={isModalOpen}
|
||||
modalState={modalState}
|
||||
isPending={isPending}
|
||||
onClose={onClosePaymentDialog}
|
||||
onConfirm={onConfirmPayment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
103
src/features/dashboard/components/my-accounts-widget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { CardFooter } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type MyAccountsWidgetProps = {
|
||||
accounts: DashboardAccount[];
|
||||
totalBalance: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MyAccountsWidget({
|
||||
accounts,
|
||||
totalBalance,
|
||||
period,
|
||||
}: MyAccountsWidgetProps) {
|
||||
const visibleAccounts = accounts.filter(
|
||||
(account) => !account.excludeFromBalance,
|
||||
);
|
||||
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between py-2">
|
||||
Saldo Total
|
||||
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||
</div>
|
||||
|
||||
<div 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);
|
||||
|
||||
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">
|
||||
<div className="relative size-10 overflow-hidden">
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
fill
|
||||
className="object-contain rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/accounts/${
|
||||
account.id
|
||||
}/statement?periodo=${formatPeriodForUrl(period)}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{account.name}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/features/dashboard/components/notes-widget.tsx
Normal file
37
src/features/dashboard/components/notes-widget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardNote } from "@/features/dashboard/notes-queries";
|
||||
import { useNotesWidgetController } from "@/features/dashboard/use-notes-widget-controller";
|
||||
import { NotesWidgetView } from "./notes/notes-widget-view";
|
||||
|
||||
type NotesWidgetProps = {
|
||||
notes: DashboardNote[];
|
||||
};
|
||||
|
||||
export function NotesWidget({ notes }: NotesWidgetProps) {
|
||||
const {
|
||||
mappedNotes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
openEdit,
|
||||
openDetails,
|
||||
handleEditOpenChange,
|
||||
handleDetailsOpenChange,
|
||||
} = useNotesWidgetController(notes);
|
||||
|
||||
return (
|
||||
<NotesWidgetView
|
||||
notes={mappedNotes}
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onOpenEdit={openEdit}
|
||||
onOpenDetails={openDetails}
|
||||
onEditOpenChange={handleEditOpenChange}
|
||||
onDetailsOpenChange={handleDetailsOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
65
src/features/dashboard/components/notes/note-list-item.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { RiFileList2Line, RiPencilLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import {
|
||||
buildNoteDisplayTitle,
|
||||
formatNoteCreatedAt,
|
||||
getNoteTasksSummary,
|
||||
} from "@/features/notes/lib/formatters";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
type NoteListItemProps = {
|
||||
note: Note;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NoteListItem({
|
||||
note,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NoteListItemProps) {
|
||||
const displayTitle = buildNoteDisplayTitle(note.title);
|
||||
const createdAtLabel = formatNoteCreatedAt(note.createdAt);
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{getNoteTasksSummary(note)}
|
||||
</Badge>
|
||||
{createdAtLabel ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenEdit(note)}
|
||||
aria-label={`Editar anotação ${displayTitle}`}
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenDetails(note)}
|
||||
aria-label={`Ver detalhes da anotação ${displayTitle}`}
|
||||
>
|
||||
<RiFileList2Line className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
39
src/features/dashboard/components/notes/notes-list.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RiTodoLine } from "@remixicon/react";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { NoteListItem } from "./note-list-item";
|
||||
|
||||
type NotesListProps = {
|
||||
notes: Note[];
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
};
|
||||
|
||||
export function NotesList({
|
||||
notes,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
}: NotesListProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiTodoLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma anotação ativa"
|
||||
description="Crie anotações para acompanhar lembretes e tarefas financeiras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col">
|
||||
{notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NoteDetailsDialog } from "@/features/notes/components/note-details-dialog";
|
||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
|
||||
type NotesWidgetDialogsProps = {
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetDialogs({
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<NoteDialog
|
||||
mode="update"
|
||||
note={noteToEdit ?? undefined}
|
||||
open={isEditOpen}
|
||||
onOpenChange={onEditOpenChange}
|
||||
/>
|
||||
|
||||
<NoteDetailsDialog
|
||||
note={noteDetails}
|
||||
open={isDetailsOpen}
|
||||
onOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Note } from "@/features/notes/components/types";
|
||||
import { NotesList } from "./notes-list";
|
||||
import { NotesWidgetDialogs } from "./notes-widget-dialogs";
|
||||
|
||||
type NotesWidgetViewProps = {
|
||||
notes: Note[];
|
||||
noteToEdit: Note | null;
|
||||
isEditOpen: boolean;
|
||||
noteDetails: Note | null;
|
||||
isDetailsOpen: boolean;
|
||||
onOpenEdit: (note: Note) => void;
|
||||
onOpenDetails: (note: Note) => void;
|
||||
onEditOpenChange: (open: boolean) => void;
|
||||
onDetailsOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function NotesWidgetView({
|
||||
notes,
|
||||
noteToEdit,
|
||||
isEditOpen,
|
||||
noteDetails,
|
||||
isDetailsOpen,
|
||||
onOpenEdit,
|
||||
onOpenDetails,
|
||||
onEditOpenChange,
|
||||
onDetailsOpenChange,
|
||||
}: NotesWidgetViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<NotesList
|
||||
notes={notes}
|
||||
onOpenEdit={onOpenEdit}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NotesWidgetDialogs
|
||||
noteToEdit={noteToEdit}
|
||||
isEditOpen={isEditOpen}
|
||||
noteDetails={noteDetails}
|
||||
isDetailsOpen={isDetailsOpen}
|
||||
onEditOpenChange={onEditOpenChange}
|
||||
onDetailsOpenChange={onDetailsOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/features/dashboard/components/payers-widget.tsx
Normal file
130
src/features/dashboard/components/payers-widget.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSFill,
|
||||
RiArrowUpSFill,
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardPagador } from "@/features/dashboard/payers-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type PayersWidgetProps = {
|
||||
pagadores: DashboardPagador[];
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "??";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const firstPart = parts[0];
|
||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
|
||||
}
|
||||
const firstChar = parts[0]?.[0] ?? "";
|
||||
const secondChar = parts[1]?.[0] ?? "";
|
||||
return `${firstChar}${secondChar}`.toUpperCase() || "??";
|
||||
};
|
||||
|
||||
export function PayersWidget({ pagadores }: PayersWidgetProps) {
|
||||
return (
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{pagadores.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum pagador para o período"
|
||||
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{pagadores.map((pagador) => {
|
||||
const initials = buildInitials(pagador.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof pagador.percentageChange === "number" &&
|
||||
Number.isFinite(pagador.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? pagador.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pagador.id}
|
||||
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(pagador.avatarUrl)}
|
||||
alt={`Avatar de ${pagador.name}`}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/payers/${pagador.id}`}
|
||||
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{pagador.name}
|
||||
</span>
|
||||
{pagador.isAdmin && (
|
||||
<RiVerifiedBadgeFill
|
||||
className="size-4 shrink-0 text-blue-500"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{pagador.email ?? "Sem email cadastrado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={pagador.totalExpenses} />
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
? "text-success"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{percentageChange > 0 && (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
)}
|
||||
{percentageChange < 0 && (
|
||||
<RiArrowDownSFill className="size-3" />
|
||||
)}
|
||||
{formatPercentage(percentageChange)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { usePaymentOverviewWidgetController } from "@/features/dashboard/use-payment-overview-widget-controller";
|
||||
import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-widget-view";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||
|
||||
return (
|
||||
<PaymentOverviewWidgetView
|
||||
activeTab={activeTab}
|
||||
paymentConditionsData={paymentConditionsData}
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
formatPaymentBreakdownPercentage,
|
||||
formatPaymentBreakdownTransactionsLabel,
|
||||
} from "@/features/dashboard/payment-breakdown-formatters";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
const ICON_WRAPPER_CLASS =
|
||||
"flex size-9.5 shrink-0 items-center justify-center rounded-full bg-muted text-foreground";
|
||||
|
||||
export type PaymentBreakdownListItemData = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
amount: number;
|
||||
transactions: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
type PaymentBreakdownListItemProps = {
|
||||
item: PaymentBreakdownListItemData;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownListItem({
|
||||
item,
|
||||
}: PaymentBreakdownListItemProps) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-b border-dashed pb-3 last:border-b-0 last:pb-0">
|
||||
<div className={ICON_WRAPPER_CLASS}>{item.icon}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<MoneyValues amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatPaymentBreakdownTransactionsLabel(item.transactions)}
|
||||
</span>
|
||||
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Progress value={item.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import {
|
||||
PaymentBreakdownListItem,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list-item";
|
||||
|
||||
export type { PaymentBreakdownListItemData } from "./payment-breakdown-list-item";
|
||||
|
||||
type PaymentBreakdownListProps = {
|
||||
items: PaymentBreakdownListItemData[];
|
||||
emptyIcon: ReactNode;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
};
|
||||
|
||||
export function PaymentBreakdownList({
|
||||
items,
|
||||
emptyIcon,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
}: PaymentBreakdownListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<PaymentBreakdownListItem key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import { getConditionIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentConditionsWidgetProps = {
|
||||
data: PaymentConditionsData;
|
||||
};
|
||||
|
||||
const resolveConditionIcon = (condition: string) =>
|
||||
getConditionIcon(condition) ?? <RiCheckLine className="size-5" aria-hidden />;
|
||||
|
||||
export function PaymentConditionsWidget({
|
||||
data,
|
||||
}: PaymentConditionsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||
(condition) => ({
|
||||
id: condition.condition,
|
||||
title: condition.condition,
|
||||
icon: resolveConditionIcon(condition.condition),
|
||||
amount: condition.amount,
|
||||
transactions: condition.transactions,
|
||||
percentage: condition.percentage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={<RiSlideshowLine className="size-6 text-muted-foreground" />}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="As distribuições por condição aparecerão conforme novos lançamentos."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
} from "./payment-breakdown-list";
|
||||
|
||||
type PaymentMethodsWidgetProps = {
|
||||
data: PaymentMethodsData;
|
||||
};
|
||||
|
||||
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||
getPaymentMethodIcon(paymentMethod) ?? (
|
||||
<RiBankCard2Line className="size-5" aria-hidden />
|
||||
);
|
||||
|
||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
||||
id: method.paymentMethod,
|
||||
title: method.paymentMethod,
|
||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||
amount: method.amount,
|
||||
transactions: method.transactions,
|
||||
percentage: method.percentage,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
items={items}
|
||||
emptyIcon={
|
||||
<RiMoneyDollarCircleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
emptyTitle="Nenhuma despesa encontrada"
|
||||
emptyDescription="Cadastre despesas para visualizar a distribuição por forma de pagamento."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentOverviewTab } from "@/features/dashboard/payment-overview-tabs";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { PaymentConditionsWidget } from "./payment-conditions-widget";
|
||||
import { PaymentMethodsWidget } from "./payment-methods-widget";
|
||||
|
||||
type PaymentOverviewWidgetViewProps = {
|
||||
activeTab: PaymentOverviewTab;
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
onTabChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidgetView({
|
||||
activeTab,
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
onTabChange,
|
||||
}: PaymentOverviewWidgetViewProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="conditions" className="text-xs">
|
||||
<RiSlideshowLine className="mr-1 size-3.5" />
|
||||
Condições
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="methods" className="text-xs">
|
||||
<RiMoneyDollarCircleLine className="mr-1 size-3.5" />
|
||||
Formas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="conditions" className="mt-2">
|
||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="methods" className="mt-2">
|
||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
12
src/features/dashboard/components/payment-status-widget.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { PaymentStatusWidgetView } from "./payment-status/payment-status-widget-view";
|
||||
|
||||
type PaymentStatusWidgetProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||
return <PaymentStatusWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
|
||||
type PaymentStatusCategorySectionProps = {
|
||||
title: string;
|
||||
total: number;
|
||||
confirmed: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export function PaymentStatusCategorySection({
|
||||
title,
|
||||
total,
|
||||
confirmed,
|
||||
pending,
|
||||
}: PaymentStatusCategorySectionProps) {
|
||||
const absTotal = Math.abs(total);
|
||||
const absConfirmed = Math.abs(confirmed);
|
||||
const confirmedPercentage =
|
||||
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<MoneyValues
|
||||
amount={total}
|
||||
className="text-sm font-medium tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Progress value={confirmedPercentage} className="h-2" />
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-primary" />
|
||||
<MoneyValues amount={confirmed} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-warning/40" />
|
||||
<MoneyValues amount={pending} className="tabular-nums" />
|
||||
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { RiWallet3Line } from "@remixicon/react";
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { CardContent } from "@/shared/components/ui/card";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { PaymentStatusCategorySection } from "./payment-status-category-section";
|
||||
|
||||
type PaymentStatusWidgetViewProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidgetView({
|
||||
data,
|
||||
}: PaymentStatusWidgetViewProps) {
|
||||
const isEmpty = data.income.total === 0 && data.expenses.total === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<CardContent className="px-0">
|
||||
<WidgetEmptyState
|
||||
icon={<RiWallet3Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum valor a receber ou pagar no período"
|
||||
description="Registre lançamentos para visualizar os valores confirmados e pendentes."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardContent className="space-y-6 px-0">
|
||||
<PaymentStatusCategorySection
|
||||
title="A Receber"
|
||||
total={data.income.total}
|
||||
confirmed={data.income.confirmed}
|
||||
pending={data.income.pending}
|
||||
/>
|
||||
|
||||
<div className="border-t border-dashed" />
|
||||
|
||||
<PaymentStatusCategorySection
|
||||
title="A Pagar"
|
||||
total={data.expenses.total}
|
||||
confirmed={data.expenses.confirmed}
|
||||
pending={data.expenses.pending}
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { CATEGORY_TYPE_LABEL } from "@/shared/lib/categories/constants";
|
||||
|
||||
type PurchasesByCategoryWidgetProps = {
|
||||
data: PurchasesByCategoryData;
|
||||
};
|
||||
|
||||
const formatTransactionDate = (date: Date | string) => {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const formatted = formatter.format(d);
|
||||
// Capitaliza a primeira letra do dia da semana
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "purchases-by-category-selected";
|
||||
|
||||
export function PurchasesByCategoryWidget({
|
||||
data,
|
||||
}: PurchasesByCategoryWidgetProps) {
|
||||
const firstCategoryId = data.categories[0]?.id ?? "";
|
||||
const hasRestoredSelectionRef = useRef(false);
|
||||
const hasPersistedSelectionRef = useRef(false);
|
||||
const [selectedCategoryId, setSelectedCategoryId] =
|
||||
useState<string>(firstCategoryId);
|
||||
|
||||
// Agrupa categorias por tipo
|
||||
const categoriesByType = useMemo(() => {
|
||||
const grouped: Record<string, typeof data.categories> = {};
|
||||
|
||||
for (const category of data.categories) {
|
||||
if (!grouped[category.type]) {
|
||||
grouped[category.type] = [];
|
||||
}
|
||||
const typeGroup = grouped[category.type];
|
||||
if (typeGroup) {
|
||||
typeGroup.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.categories]);
|
||||
|
||||
// Restaura a categoria salva apenas depois da montagem para manter SSR e cliente consistentes.
|
||||
useEffect(() => {
|
||||
if (hasRestoredSelectionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRestoredSelectionRef.current = true;
|
||||
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved && data.categories.some((cat) => cat.id === saved)) {
|
||||
setSelectedCategoryId(saved);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
}, [data.categories, firstCategoryId]);
|
||||
|
||||
// Salva a categoria selecionada quando mudar, sem sobrescrever o valor salvo na primeira montagem.
|
||||
useEffect(() => {
|
||||
if (!hasPersistedSelectionRef.current) {
|
||||
hasPersistedSelectionRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
sessionStorage.setItem(STORAGE_KEY, selectedCategoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, [selectedCategoryId]);
|
||||
|
||||
// Atualiza a categoria selecionada se ela não existir mais na lista
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryId && firstCategoryId) {
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedCategoryId &&
|
||||
!data.categories.some((cat) => cat.id === selectedCategoryId)
|
||||
) {
|
||||
setSelectedCategoryId(firstCategoryId);
|
||||
}
|
||||
}, [data.categories, firstCategoryId, selectedCategoryId]);
|
||||
|
||||
const currentTransactions = useMemo(() => {
|
||||
if (!selectedCategoryId) {
|
||||
return [];
|
||||
}
|
||||
return data.transactionsByCategory[selectedCategoryId] ?? [];
|
||||
}, [selectedCategoryId, data.transactionsByCategory]);
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
return data.categories.find((cat) => cat.id === selectedCategoryId);
|
||||
}, [data.categories, selectedCategoryId]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiStore3Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma categoria encontrada"
|
||||
description="Crie categorias de despesas ou receitas para visualizar as compras."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={selectedCategoryId}
|
||||
onValueChange={setSelectedCategoryId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(categoriesByType).map(([type, categories]) => (
|
||||
<div key={type}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{CATEGORY_TYPE_LABEL[
|
||||
type as keyof typeof CATEGORY_TYPE_LABEL
|
||||
] ?? type}
|
||||
</div>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentTransactions.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiArrowDownSFill 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={37} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { RiRefreshLine } from "@remixicon/react";
|
||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type RecurringExpensesWidgetProps = {
|
||||
data: RecurringExpensesData;
|
||||
};
|
||||
|
||||
const formatOccurrences = (value: number | null) => {
|
||||
if (!value) {
|
||||
return "Recorrência contínua";
|
||||
}
|
||||
|
||||
return `${value} recorrências`;
|
||||
};
|
||||
|
||||
export function RecurringExpensesWidget({
|
||||
data,
|
||||
}: RecurringExpensesWidgetProps) {
|
||||
if (data.expenses.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma despesa recorrente"
|
||||
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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={37} />
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/features/dashboard/components/recurring-series-widget.tsx
Normal file
153
src/features/dashboard/components/recurring-series-widget.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiPauseCircleLine,
|
||||
RiPlayCircleLine,
|
||||
RiRefreshLine,
|
||||
RiStopCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { RecurringSeriesData } from "@/features/dashboard/recurring/recurring-series-queries";
|
||||
import {
|
||||
cancelRecurringSeriesAction,
|
||||
pauseRecurringSeriesAction,
|
||||
resumeRecurringSeriesAction,
|
||||
} from "@/features/recurring/actions";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatMonthYearLabel } from "@/shared/utils/period";
|
||||
|
||||
type RecurringSeriesWidgetProps = {
|
||||
data: RecurringSeriesData;
|
||||
};
|
||||
|
||||
export function RecurringSeriesWidget({ data }: RecurringSeriesWidgetProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
if (data.series.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhuma série recorrente"
|
||||
description="Séries recorrentes aparecerão aqui quando forem criadas."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePause = (seriesId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await pauseRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResume = (seriesId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await resumeRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (seriesId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Tem certeza que deseja cancelar esta série recorrente? Lançamentos passados serão mantidos.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await cancelRecurringSeriesAction({ seriesId });
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data.series.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 border-b border-dashed pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<p className="truncate text-foreground text-sm font-medium">
|
||||
{item.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant={item.status === "active" ? "default" : "secondary"}
|
||||
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{item.status === "active" ? "Ativo" : "Pausado"}
|
||||
</Badge>
|
||||
</div>
|
||||
<MoneyValues amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-0.5">
|
||||
<span>
|
||||
Dia {item.dayOfMonth} · {item.paymentMethod}
|
||||
{item.categoryName ? ` · ${item.categoryName}` : ""}
|
||||
</span>
|
||||
<span>Próx: {formatMonthYearLabel(item.nextPeriod)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
{item.status === "active" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={isPending}
|
||||
onClick={() => handlePause(item.id)}
|
||||
>
|
||||
<RiPauseCircleLine className="size-3.5 mr-1" />
|
||||
Pausar
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={isPending}
|
||||
onClick={() => handleResume(item.id)}
|
||||
>
|
||||
<RiPlayCircleLine className="size-3.5 mr-1" />
|
||||
Continuar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleCancel(item.id)}
|
||||
>
|
||||
<RiStopCircleLine className="size-3.5 mr-1" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/features/dashboard/components/sortable-widget.tsx
Normal file
48
src/features/dashboard/components/sortable-widget.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type SortableWidgetProps = {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
export function SortableWidget({
|
||||
id,
|
||||
children,
|
||||
isEditing,
|
||||
}: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled: !isEditing });
|
||||
|
||||
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 touch-none select-none",
|
||||
)}
|
||||
{...(isEditing ? { ...attributes, ...listeners } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import type { TopExpensesData } from "@/features/dashboard/expenses/top-expenses-queries";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import { TopEstablishmentsWidget } from "./top-establishments-widget";
|
||||
import { TopExpensesWidget } from "./top-expenses-widget";
|
||||
|
||||
type SpendingOverviewWidgetProps = {
|
||||
topExpensesAll: TopExpensesData;
|
||||
topExpensesCardOnly: TopExpensesData;
|
||||
topEstablishmentsData: TopEstablishmentsData;
|
||||
};
|
||||
|
||||
export function SpendingOverviewWidget({
|
||||
topExpensesAll,
|
||||
topExpensesCardOnly,
|
||||
topEstablishmentsData,
|
||||
}: SpendingOverviewWidgetProps) {
|
||||
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
|
||||
"expenses",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as "expenses" | "establishments")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="expenses" className="text-xs">
|
||||
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||
Top gastos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="establishments" className="text-xs">
|
||||
<RiStore2Line className="mr-1 size-3.5" />
|
||||
Estabelecimentos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expenses" className="mt-2">
|
||||
<TopExpensesWidget
|
||||
allExpenses={topExpensesAll}
|
||||
cardOnlyExpenses={topExpensesCardOnly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="establishments" className="mt-2">
|
||||
<TopEstablishmentsWidget data={topEstablishmentsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RiStore2Line } from "@remixicon/react";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type TopEstablishmentsWidgetProps = {
|
||||
data: TopEstablishmentsData;
|
||||
};
|
||||
|
||||
const formatOccurrencesLabel = (occurrences: number) => {
|
||||
if (occurrences === 1) {
|
||||
return "1 lançamento";
|
||||
}
|
||||
return `${occurrences} lançamentos`;
|
||||
};
|
||||
|
||||
export function TopEstablishmentsWidget({
|
||||
data,
|
||||
}: TopEstablishmentsWidgetProps) {
|
||||
return (
|
||||
<div className="flex flex-col px-0">
|
||||
{data.establishments.length === 0 ? (
|
||||
<WidgetEmptyState
|
||||
icon={<RiStore2Line className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum estabelecimento encontrado"
|
||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{data.establishments.map((establishment) => {
|
||||
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={37} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
140
src/features/dashboard/components/top-expenses-widget.tsx
Normal file
140
src/features/dashboard/components/top-expenses-widget.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
TopExpense,
|
||||
TopExpensesData,
|
||||
} from "@/features/dashboard/expenses/top-expenses-queries";
|
||||
import { EstabelecimentoLogo } from "@/features/transactions/components/shared/establishment-logo";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
|
||||
type TopExpensesWidgetProps = {
|
||||
allExpenses: TopExpensesData;
|
||||
cardOnlyExpenses: TopExpensesData;
|
||||
};
|
||||
|
||||
const formatTransactionDate = (date: Date | string) => {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const formatted = formatter.format(d);
|
||||
// Capitaliza a primeira letra do dia da semana
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
};
|
||||
|
||||
const shouldIncludeExpense = (expense: TopExpense) => {
|
||||
const normalizedName = expense.name.trim().toLowerCase();
|
||||
|
||||
if (normalizedName === "saldo inicial") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedName.includes("fatura")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCardExpense = (expense: TopExpense) =>
|
||||
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false;
|
||||
|
||||
export function TopExpensesWidget({
|
||||
allExpenses,
|
||||
cardOnlyExpenses,
|
||||
}: TopExpensesWidgetProps) {
|
||||
const [cardOnly, setCardOnly] = useState(false);
|
||||
const normalizedAllExpenses = useMemo(() => {
|
||||
return allExpenses.expenses.filter(shouldIncludeExpense);
|
||||
}, [allExpenses]);
|
||||
|
||||
const normalizedCardOnlyExpenses = useMemo(() => {
|
||||
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
|
||||
const seen = new Set<string>();
|
||||
|
||||
return merged.filter((expense) => {
|
||||
if (seen.has(expense.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(expense.id);
|
||||
return true;
|
||||
});
|
||||
}, [cardOnlyExpenses, normalizedAllExpenses]);
|
||||
|
||||
const data = cardOnly
|
||||
? { expenses: normalizedCardOnlyExpenses }
|
||||
: { expenses: normalizedAllExpenses };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor="card-only-toggle"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{cardOnly
|
||||
? "Somente cartões de crédito ou débito."
|
||||
: "Todas as despesas"}
|
||||
</label>
|
||||
<Switch
|
||||
id="card-only-toggle"
|
||||
checked={cardOnly}
|
||||
onCheckedChange={setCardOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.expenses.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Nenhuma despesa encontrada"
|
||||
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{data.expenses.map((expense) => {
|
||||
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={37} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
9
src/features/dashboard/components/welcome-widget.ts
Normal file
9
src/features/dashboard/components/welcome-widget.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
formatBusinessCurrentDate,
|
||||
getBusinessGreeting,
|
||||
} from "@/shared/utils/date";
|
||||
|
||||
export const formatCurrentDate = (date = new Date()) =>
|
||||
formatBusinessCurrentDate(date);
|
||||
|
||||
export const getGreeting = (date = new Date()) => getBusinessGreeting(date);
|
||||
99
src/features/dashboard/components/widget-settings-dialog.tsx
Normal file
99
src/features/dashboard/components/widget-settings-dialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { widgetsConfig } from "@/features/dashboard/widgets/widgets-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type WidgetSettingsDialogProps = {
|
||||
hiddenWidgets: string[];
|
||||
onToggleWidget: (widgetId: string) => void;
|
||||
onReset: () => void;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function WidgetSettingsDialog({
|
||||
hiddenWidgets,
|
||||
onToggleWidget,
|
||||
onReset,
|
||||
triggerClassName,
|
||||
}: WidgetSettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("gap-2", triggerClassName)}
|
||||
>
|
||||
<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);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user