refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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"
/>
);
}

View 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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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" />
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,6 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
} from "@/features/dashboard/expenses/installment-analysis-queries";
export type { InstallmentAnalysisData, InstallmentGroup };

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View 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}
</>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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."
/>
);
}

View File

@@ -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."
/>
);
}

View File

@@ -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>
);
}

View 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} />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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);

View 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>
);
}