mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets
Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/ foram movidos para subdiretórios temáticos (bills/, invoices/, notes/, notifications/, overview/, payments/, goals-progress/, categories/). ~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura baseada em widget-registry com components/widgets/. Novos componentes: category-breakdown-chart/list, goals-progress-item, percentage-change-indicator. Imports atualizados em fetch-dashboard-data e transaction-filters limpos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
128
src/features/dashboard/components/widgets/attachments-widget.tsx
Normal file
128
src/features/dashboard/components/widgets/attachments-widget.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAttachmentLine,
|
||||
RiFileLine,
|
||||
RiFilePdf2Line,
|
||||
RiImageLine,
|
||||
} from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
|
||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatDateOnly } from "@/shared/utils/date";
|
||||
import { formatBytes } from "@/shared/utils/number";
|
||||
|
||||
type AttachmentsSnapshot = {
|
||||
totalCount: number;
|
||||
totalBytes: number;
|
||||
imageCount: number;
|
||||
pdfCount: number;
|
||||
recentAttachments: AttachmentForPeriod[];
|
||||
};
|
||||
|
||||
type AttachmentsWidgetProps = {
|
||||
snapshot: AttachmentsSnapshot;
|
||||
};
|
||||
|
||||
export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
if (snapshot.totalCount === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiAttachmentLine className="size-6 text-muted-foreground" />}
|
||||
title="Nenhum anexo no período"
|
||||
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<RiAttachmentLine className="size-3.5" />
|
||||
{snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{formatBytes(snapshot.totalBytes)}
|
||||
</span>
|
||||
{snapshot.imageCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<RiImageLine className="size-3.5 text-blue-500" />
|
||||
{snapshot.imageCount}
|
||||
</span>
|
||||
)}
|
||||
{snapshot.pdfCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<RiFilePdf2Line className="size-3.5 text-red-500" />
|
||||
{snapshot.pdfCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{snapshot.recentAttachments.map((attachment, index) => {
|
||||
const isPdf = attachment.mimeType === "application/pdf";
|
||||
const isImage = attachment.mimeType.startsWith("image/");
|
||||
|
||||
return (
|
||||
<li key={`${attachment.attachmentId}-${attachment.transactionId}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
className="flex w-full items-center gap-2 py-2 text-left"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{isPdf && <RiFilePdf2Line className="size-6 text-red-500" />}
|
||||
{isImage && <RiImageLine className="size-6 text-blue-500" />}
|
||||
{!isPdf && !isImage && (
|
||||
<RiFileLine className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block truncate text-sm font-medium text-foreground hover:underline">
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs break-all">
|
||||
{attachment.fileName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{attachment.transactionName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{formatDateOnly(attachment.purchaseDate, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}) ?? "—"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground/60">
|
||||
{formatBytes(attachment.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<AttachmentPreview
|
||||
attachments={snapshot.recentAttachments}
|
||||
selectedIndex={selectedIndex}
|
||||
onClose={() => setSelectedIndex(-1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/features/dashboard/components/widgets/bill-widget.tsx
Normal file
35
src/features/dashboard/components/widgets/bill-widget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
||||
import { useBillWidgetController } from "@/features/dashboard/bills/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
"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 {
|
||||
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 (
|
||||
<div 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, colorIndex) => {
|
||||
if (!category) return null;
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
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-sm font-medium 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={String(entry.dataKey ?? entry.name)}
|
||||
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">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
|
||||
type CategoryTrendsWidgetProps = {
|
||||
categories: DashboardCategoryBreakdownItem[];
|
||||
};
|
||||
|
||||
export function CategoryTrendsWidget({
|
||||
categories,
|
||||
}: CategoryTrendsWidgetProps) {
|
||||
const trending = categories
|
||||
.filter((c) => c.percentageChange !== null && c.previousAmount > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.abs(b.percentageChange ?? 0) - Math.abs(a.percentageChange ?? 0),
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
if (trending.length === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
|
||||
title="Dados insuficientes"
|
||||
description="As variações aparecem após lançamentos em dois meses consecutivos."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{trending.map((category) => {
|
||||
const change = category.percentageChange ?? 0;
|
||||
|
||||
return (
|
||||
<li key={category.categoryId}>
|
||||
<div className="-mx-2 flex items-center gap-3 rounded-md p-2">
|
||||
<CategoryIconBadge
|
||||
icon={category.categoryIcon}
|
||||
name={category.categoryName}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{category.categoryName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<MoneyValues amount={category.previousAmount} /> vs{" "}
|
||||
<MoneyValues
|
||||
amount={category.currentAmount}
|
||||
className="font-semibold"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<PercentageChangeIndicator
|
||||
value={change}
|
||||
label={formatPercentage(change, {
|
||||
absolute: true,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
positiveTrend="down"
|
||||
className="shrink-0 text-sm font-semibold"
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { GoalsProgressData } from "@/features/dashboard/goals-progress/goals-progress-queries";
|
||||
import { useGoalsProgressWidgetController } from "@/features/dashboard/goals-progress/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
270
src/features/dashboard/components/widgets/inbox-widget.tsx
Normal file
270
src/features/dashboard/components/widgets/inbox-widget.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
|
||||
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widget-registry/widget-config";
|
||||
import {
|
||||
discardInboxItemAction,
|
||||
markInboxAsProcessedAction,
|
||||
} from "@/features/inbox/actions";
|
||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
|
||||
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "agora";
|
||||
if (minutes < 60) return `há ${minutes}min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `há ${days}d`;
|
||||
}
|
||||
|
||||
type InboxWidgetProps = {
|
||||
snapshot: DashboardInboxSnapshot;
|
||||
quickActionOptions: DashboardWidgetQuickActionOptions;
|
||||
};
|
||||
|
||||
function getDateString(date: Date | string | null | undefined): string | null {
|
||||
if (!date) return null;
|
||||
if (typeof date === "string") return date.slice(0, 10);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function InboxWidget({
|
||||
snapshot,
|
||||
quickActionOptions,
|
||||
}: InboxWidgetProps) {
|
||||
const router = useRouter();
|
||||
const [processOpen, setProcessOpen] = useState(false);
|
||||
const [discardOpen, setDiscardOpen] = useState(false);
|
||||
const [itemToProcess, setItemToProcess] = useState<
|
||||
DashboardInboxSnapshot["recentItems"][number] | null
|
||||
>(null);
|
||||
const [itemToDiscard, setItemToDiscard] = useState<
|
||||
DashboardInboxSnapshot["recentItems"][number] | null
|
||||
>(null);
|
||||
|
||||
const handleProcessOpenChange = (open: boolean) => {
|
||||
setProcessOpen(open);
|
||||
if (!open) setItemToProcess(null);
|
||||
};
|
||||
|
||||
const handleDiscardOpenChange = (open: boolean) => {
|
||||
setDiscardOpen(open);
|
||||
if (!open) setItemToDiscard(null);
|
||||
};
|
||||
|
||||
const handleProcessRequest = (
|
||||
item: DashboardInboxSnapshot["recentItems"][number],
|
||||
) => {
|
||||
setItemToProcess(item);
|
||||
setProcessOpen(true);
|
||||
};
|
||||
|
||||
const handleDiscardRequest = (
|
||||
item: DashboardInboxSnapshot["recentItems"][number],
|
||||
) => {
|
||||
setItemToDiscard(item);
|
||||
setDiscardOpen(true);
|
||||
};
|
||||
|
||||
const refreshWidget = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDiscardConfirm = async () => {
|
||||
if (!itemToDiscard) return;
|
||||
|
||||
const result = await discardInboxItemAction({
|
||||
inboxItemId: itemToDiscard.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
refreshWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
};
|
||||
|
||||
const handleLancamentoSuccess = async () => {
|
||||
if (!itemToProcess) return;
|
||||
|
||||
const result = await markInboxAsProcessedAction({
|
||||
inboxItemId: itemToProcess.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Notificação processada!");
|
||||
refreshWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
};
|
||||
|
||||
const defaultPurchaseDate =
|
||||
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
||||
const defaultName = itemToProcess?.parsedName
|
||||
? itemToProcess.parsedName
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
: null;
|
||||
const defaultAmount = itemToProcess?.parsedAmount
|
||||
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
||||
: null;
|
||||
|
||||
const matchedCardId = useMemo(() => {
|
||||
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
||||
if (!appName) return null;
|
||||
|
||||
for (const option of quickActionOptions.cardOptions) {
|
||||
const label = option.label.toLowerCase();
|
||||
if (label.includes(appName) || appName.includes(label)) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [itemToProcess?.sourceAppName, quickActionOptions.cardOptions]);
|
||||
|
||||
if (snapshot.pendingCount === 0) {
|
||||
return (
|
||||
<WidgetEmptyState
|
||||
icon={<RiCheckboxCircleFill color="green" className="size-6" />}
|
||||
title="Tudo em dia"
|
||||
description="Nenhum pré-lançamento aguardando revisão."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{snapshot.recentItems.map((item) => {
|
||||
const displayName = item.parsedName ?? item.originalText.slice(0, 40);
|
||||
const parsedAmount =
|
||||
item.parsedAmount !== null
|
||||
? Number.parseFloat(item.parsedAmount)
|
||||
: null;
|
||||
const amount =
|
||||
parsedAmount !== null && Number.isFinite(parsedAmount)
|
||||
? parsedAmount
|
||||
: null;
|
||||
const logoKey = item.sourceAppName?.toLowerCase() ?? "";
|
||||
const rawLogo = snapshot.logoMap[logoKey] ?? null;
|
||||
const logoSrc = resolveLogoSrc(rawLogo);
|
||||
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={item.sourceAppName ?? ""}
|
||||
width={38}
|
||||
height={38}
|
||||
className="size-9.5 shrink-0 rounded-full object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{displayName.length > 30
|
||||
? `${displayName.slice(0, 30)}...`
|
||||
: displayName}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.sourceAppName && <span>{item.sourceAppName}</span>}
|
||||
<span className="text-muted-foreground/60">
|
||||
{relativeTime(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
{amount !== null && (
|
||||
<MoneyValues className="font-medium" amount={amount} />
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleProcessRequest(item)}
|
||||
aria-label="Processar notificação"
|
||||
title="Processar"
|
||||
>
|
||||
<RiCheckLine className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDiscardRequest(item)}
|
||||
aria-label="Descartar notificação"
|
||||
title="Descartar"
|
||||
>
|
||||
<RiDeleteBinLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<TransactionDialog
|
||||
mode="create"
|
||||
open={processOpen}
|
||||
onOpenChange={handleProcessOpenChange}
|
||||
payerOptions={quickActionOptions.payerOptions}
|
||||
splitPayerOptions={quickActionOptions.splitPayerOptions}
|
||||
defaultPayerId={quickActionOptions.defaultPayerId}
|
||||
accountOptions={quickActionOptions.accountOptions}
|
||||
cardOptions={quickActionOptions.cardOptions}
|
||||
categoryOptions={quickActionOptions.categoryOptions}
|
||||
estabelecimentos={quickActionOptions.estabelecimentos}
|
||||
defaultPurchaseDate={defaultPurchaseDate}
|
||||
defaultName={defaultName}
|
||||
defaultAmount={defaultAmount}
|
||||
defaultCardId={matchedCardId}
|
||||
defaultPaymentMethod={matchedCardId ? "Cartão de crédito" : null}
|
||||
defaultTransactionType="Despesa"
|
||||
forceShowTransactionType
|
||||
onSuccess={handleLancamentoSuccess}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={discardOpen}
|
||||
onOpenChange={handleDiscardOpenChange}
|
||||
title="Descartar notificação?"
|
||||
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
|
||||
confirmLabel="Descartar"
|
||||
confirmVariant="destructive"
|
||||
pendingLabel="Descartando..."
|
||||
onConfirm={handleDiscardConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { IncomeByCategoryData } from "@/features/dashboard/categories/income-by-category-queries";
|
||||
import { CategoryBreakdownWidgetView } from "../category-breakdown/category-breakdown-widget-view";
|
||||
|
||||
type IncomeByCategoryWidgetWithChartProps = {
|
||||
data: IncomeByCategoryData;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function IncomeByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: IncomeByCategoryWidgetWithChartProps) {
|
||||
return (
|
||||
<CategoryBreakdownWidgetView data={data} period={period} variant="income" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { RiLineChartLine } from "@remixicon/react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/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(--success)",
|
||||
},
|
||||
despesa: {
|
||||
label: "Despesa",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
balanco: {
|
||||
label: "Balanço",
|
||||
color: "var(--warning)",
|
||||
},
|
||||
} 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={String(entry.dataKey ?? entry.name)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config?.label}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="receita"
|
||||
fill={chartConfig.receita.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="despesa"
|
||||
fill={chartConfig.despesa.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="balanco"
|
||||
fill={chartConfig.balanco.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.receita.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.receita.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.despesa.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{chartConfig.balanco.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import { InstallmentExpensesWidgetView } from "../installment-expenses/installment-expenses-widget-view";
|
||||
|
||||
type InstallmentExpensesWidgetProps = {
|
||||
data: InstallmentExpensesData;
|
||||
};
|
||||
|
||||
export function InstallmentExpensesWidget({
|
||||
data,
|
||||
}: InstallmentExpensesWidgetProps) {
|
||||
return <InstallmentExpensesWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
||||
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
222
src/features/dashboard/components/widgets/my-accounts-widget.tsx
Normal file
222
src/features/dashboard/components/widgets/my-accounts-widget.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiBarChartBoxLine,
|
||||
RiExternalLinkLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widget-registry/widget-actions";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { CardFooter } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
|
||||
type MyAccountsWidgetProps = {
|
||||
accounts: DashboardAccount[];
|
||||
showExcludedAccounts: boolean;
|
||||
onShowExcludedAccountsChange?: (value: boolean) => void;
|
||||
totalBalance: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function MyAccountsWidget({
|
||||
accounts,
|
||||
showExcludedAccounts,
|
||||
onShowExcludedAccountsChange,
|
||||
totalBalance,
|
||||
period,
|
||||
}: MyAccountsWidgetProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const excludedAccountsCount = accounts.filter(
|
||||
(account) => account.excludeFromBalance,
|
||||
).length;
|
||||
const visibleAccounts = showExcludedAccounts
|
||||
? accounts
|
||||
: accounts.filter((account) => !account.excludeFromBalance);
|
||||
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
||||
const hiddenExcludedAccountsCount = showExcludedAccounts
|
||||
? 0
|
||||
: excludedAccountsCount;
|
||||
const toggleButtonLabel = showExcludedAccounts
|
||||
? "Ocultar contas não consideradas"
|
||||
: "Mostrar contas não consideradas";
|
||||
|
||||
const handleToggleExcludedAccounts = () => {
|
||||
const nextShowExcludedAccounts = !showExcludedAccounts;
|
||||
onShowExcludedAccountsChange?.(nextShowExcludedAccounts);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateMyAccountsWidgetPreference({
|
||||
showExcludedAccounts: nextShowExcludedAccounts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
onShowExcludedAccountsChange?.(!nextShowExcludedAccounts);
|
||||
toast.error(result.error ?? "Erro ao salvar preferência");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3 py-1">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Saldo Total</p>
|
||||
<MoneyValues className="text-2xl font-medium" amount={totalBalance} />
|
||||
</div>
|
||||
|
||||
{excludedAccountsCount > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={isPending}
|
||||
className="mt-0.5 text-muted-foreground"
|
||||
aria-label={toggleButtonLabel}
|
||||
onClick={handleToggleExcludedAccounts}
|
||||
>
|
||||
{showExcludedAccounts ? (
|
||||
<RiEyeOffLine className="size-4" aria-hidden />
|
||||
) : (
|
||||
<RiEyeLine className="size-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="text-xs">{toggleButtonLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hiddenExcludedAccountsCount > 0 ? (
|
||||
<p className="pb-2 text-xs text-muted-foreground">
|
||||
{hiddenExcludedAccountsCount}{" "}
|
||||
{hiddenExcludedAccountsCount === 1
|
||||
? "conta não considerada oculta"
|
||||
: "contas não consideradas ocultas"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{accounts.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>
|
||||
) : displayedAccounts.length === 0 ? (
|
||||
<div className="-mt-10">
|
||||
<WidgetEmptyState
|
||||
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
|
||||
title="As contas não consideradas estão ocultas"
|
||||
description="Use o botão no topo do widget para mostrá-las novamente."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{displayedAccounts.map((account, index) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between transition-all duration-300 py-1.5 "
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
|
||||
<div className="relative size-9.5 overflow-hidden">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo da conta ${account.name}`}
|
||||
fill
|
||||
sizes="38px"
|
||||
className="object-contain rounded-full"
|
||||
priority={index === 0}
|
||||
/>
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
{account.excludeFromBalance ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help ml-2">
|
||||
<Badge className="font-normal" variant="info">
|
||||
Não considerada
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Esta conta aparece na lista, mas não entra no
|
||||
cálculo do saldo total porque está marcada para
|
||||
desconsiderar do saldo total.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<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
|
||||
className="font-medium"
|
||||
amount={account.balance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remainingCount > 0 ? (
|
||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||
+{remainingCount} contas não exibidas
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/features/dashboard/components/widgets/notes-widget.tsx
Normal file
37
src/features/dashboard/components/widgets/notes-widget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardNote } from "@/features/dashboard/notes/notes-queries";
|
||||
import { useNotesWidgetController } from "@/features/dashboard/notes/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
97
src/features/dashboard/components/widgets/payers-widget.tsx
Normal file
97
src/features/dashboard/components/widgets/payers-widget.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiExternalLinkLine,
|
||||
RiGroupLine,
|
||||
RiVerifiedBadgeFill,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
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 { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { buildInitials } from "@/shared/utils/initials";
|
||||
|
||||
type PayersWidgetProps = {
|
||||
payers: DashboardPagador[];
|
||||
};
|
||||
|
||||
export function PayersWidget({ payers }: PayersWidgetProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{payers.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."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{payers.map((payer) => {
|
||||
const initials = buildInitials(payer.name);
|
||||
const hasValidPercentageChange =
|
||||
typeof payer.percentageChange === "number" &&
|
||||
Number.isFinite(payer.percentageChange);
|
||||
const percentageChange = hasValidPercentageChange
|
||||
? payer.percentageChange
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={payer.id}
|
||||
className="flex items-center justify-between transition-all duration-300 py-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
|
||||
<Avatar className="size-9.5 shrink-0">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(payer.avatarUrl)}
|
||||
alt={`Avatar de ${payer.name}`}
|
||||
/>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
prefetch
|
||||
href={`/payers/${payer.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">{payer.name}</span>
|
||||
{payer.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">
|
||||
{payer.email ?? "Sem email cadastrado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={payer.totalExpenses}
|
||||
/>
|
||||
<PercentageChangeIndicator value={percentageChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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/payments/use-payment-overview-widget-controller";
|
||||
import { PaymentOverviewWidgetView } from "../payment-overview/payment-overview-widget-view";
|
||||
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
period: string;
|
||||
adminPayerSlug: string | null;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
period,
|
||||
adminPayerSlug,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||
|
||||
return (
|
||||
<PaymentOverviewWidgetView
|
||||
activeTab={activeTab}
|
||||
paymentConditionsData={paymentConditionsData}
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onTabChange={handleTabChange}
|
||||
period={period}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||
import { PaymentStatusWidgetView } from "../payment-status/payment-status-widget-view";
|
||||
|
||||
type PaymentStatusWidgetProps = {
|
||||
data: PaymentStatusData;
|
||||
};
|
||||
|
||||
export function PaymentStatusWidget({ data }: PaymentStatusWidgetProps) {
|
||||
return <PaymentStatusWidgetView data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
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";
|
||||
import { formatTransactionDate } from "@/shared/utils/date";
|
||||
|
||||
type PurchasesByCategoryWidgetProps = {
|
||||
data: PurchasesByCategoryData;
|
||||
};
|
||||
|
||||
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-medium 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."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{currentTransactions.map((transaction) => {
|
||||
return (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstablishmentLogo 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
|
||||
className="font-medium"
|
||||
amount={transaction.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { RiRefreshLine } from "@remixicon/react";
|
||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
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">
|
||||
{data.expenses.map((expense) => {
|
||||
return (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-center gap-2 transition-all duration-300 py-1.5"
|
||||
>
|
||||
<EstablishmentLogo 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 className="font-medium" 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type SortableWidgetProps = {
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
export function SortableWidget({
|
||||
id,
|
||||
children,
|
||||
isEditing,
|
||||
}: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled: !isEditing });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"relative",
|
||||
isDragging && "z-50 opacity-90",
|
||||
isEditing &&
|
||||
"cursor-grab active:cursor-grabbing touch-none select-none",
|
||||
)}
|
||||
{...(isEditing ? { ...attributes, ...listeners } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"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 data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiArrowUpDoubleLine className="mr-1 size-3.5" />
|
||||
Top gastos
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="establishments"
|
||||
className="text-xs data-[state=active]:bg-transparent"
|
||||
>
|
||||
<RiStore2Line className="mr-1 size-3.5" />
|
||||
Estabelecimentos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="expenses" className="mt-2">
|
||||
<TopExpensesWidget
|
||||
allExpenses={topExpensesAll}
|
||||
cardOnlyExpenses={topExpensesCardOnly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="establishments" className="mt-2">
|
||||
<TopEstablishmentsWidget data={topEstablishmentsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { RiStore2Line } from "@remixicon/react";
|
||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
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."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{data.establishments.map((establishment) => {
|
||||
return (
|
||||
<div
|
||||
key={establishment.id}
|
||||
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstablishmentLogo 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
|
||||
className="font-medium"
|
||||
amount={establishment.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowUpDoubleLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type {
|
||||
TopExpense,
|
||||
TopExpensesData,
|
||||
} from "@/features/dashboard/expenses/top-expenses-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||
import { formatTransactionDate } from "@/shared/utils/date";
|
||||
|
||||
type TopExpensesWidgetProps = {
|
||||
allExpenses: TopExpensesData;
|
||||
cardOnlyExpenses: TopExpensesData;
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
Apenas cartões
|
||||
</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>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{data.expenses.map((expense) => {
|
||||
return (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-center justify-between gap-3 transition-all duration-300 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EstablishmentLogo 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
|
||||
className="font-medium"
|
||||
amount={expense.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { widgetsConfig } from "@/features/dashboard/widget-registry/widget-config";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type WidgetSettingsDialogProps = {
|
||||
hiddenWidgets: string[];
|
||||
onToggleWidget: (widgetId: string) => void;
|
||||
onReset: () => void;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function WidgetSettingsDialog({
|
||||
hiddenWidgets,
|
||||
onToggleWidget,
|
||||
onReset,
|
||||
triggerClassName,
|
||||
}: WidgetSettingsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("gap-2", triggerClassName)}
|
||||
>
|
||||
<RiSettings4Line className="size-4" />
|
||||
Widgets
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar Widgets</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha quais widgets deseja exibir no seu dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto py-4">
|
||||
<div className="space-y-3">
|
||||
{widgetsConfig.map((widget) => {
|
||||
const isVisible = !hiddenWidgets.includes(widget.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-primary shrink-0">{widget.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{widget.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{widget.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => onToggleWidget(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiRefreshLine className="size-4" />
|
||||
Restaurar Padrão
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user