mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
Refatoração estrutural sem mudanças funcionais. Saldo líquido: −428 linhas. Removido: - 14 funções/constantes mortas verificadas via grep no repo todo: validateCategoriaOwnership, getInstallmentAnticipationsAction, getAnticipationDetailsAction, formatDecimalForDb, currencyFormatterNoCents, optionalDecimalSchema, formatMonthLabel, getGoalProgressStatusColorClass, MONTH_PERIOD_PARAM, calculateRemainingInstallments, e 5 funções fetch* não usadas em inbox/queries.ts. - 1 tipo morto (ImportRow) + 2 órfãos consequentes (InstallmentAnticipationWithRelations, GoalProgressStatus convertido em interno). - ~30 export keywords desnecessários (símbolos usados apenas no próprio arquivo). - Re-exports mortos em barrels: EstablishmentLogoPicker, CategoryReportSkeleton, WidgetSkeleton, toNameKey. - Arquivo features/reports/types.ts (barrel inteiro era órfão). Padronizado (PT-BR→EN em identificadores expostos): - 4 constantes globais (LANCAMENTOS_* → TRANSACTIONS_*). - 12 tipos/interfaces (Lancamento*/Pagador*/Estabelecimento* → equivalentes EN). - 13 funções/components exportados (fetchPagador*, EstabelecimentoInput, PagadorInfoCard, etc.). - 5 props cross-file (preLancamentosCount → inboxPendingCount, pagadorAvatarUrl → payerAvatarUrl, etc.). - Mantidas em PT-BR conforme exceção do CLAUDE.md: variáveis locais (pagador, categoria, lancamento), accessor key pagadorName (persistida em preferências), strings de UI. Reorganizado: - transactions/: 14 helpers soltos na raiz movidos para lib/; barrel actions.ts reduzido de 76 linhas de wrappers para 14 linhas de re-exports puros; anticipation-actions.ts movido para actions/anticipation.ts. - dashboard/: 8 helpers soltos consolidados em dashboard/lib/. - reports/: 5 query files na raiz consolidados em reports/lib/. - payers/: detail-actions.ts (21KB) e detail-queries.ts movidos para payers/lib/. - shared/components/: 9 dos 16 componentes soltos agrupados em brand/, widgets/, feedback/. - shared/lib/fetch-json.ts movido para shared/utils/fetch-json.ts. Validação: pnpm exec tsc --noEmit (0 erros), biome check (0 issues), knip (sem unused). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
5.4 KiB
TypeScript
193 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { RiInformationLine } from "@remixicon/react";
|
|
import Link from "next/link";
|
|
import { useMemo } from "react";
|
|
import { formatPeriodLabel } from "@/features/reports/lib/utils";
|
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
|
import StatusDot from "@/shared/components/feedback/status-dot";
|
|
import { Card } from "@/shared/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableFooter,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/shared/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/shared/components/ui/tooltip";
|
|
import type { CategoryReportItem } from "@/shared/lib/types/reports";
|
|
import { formatCurrency } from "@/shared/utils/currency";
|
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
|
import { CategoryCell } from "./category-cell";
|
|
|
|
interface CategoryTableProps {
|
|
title: string;
|
|
categories: CategoryReportItem[];
|
|
periods: string[];
|
|
}
|
|
|
|
export function CategoryTable({
|
|
title,
|
|
categories,
|
|
periods,
|
|
}: CategoryTableProps) {
|
|
// Calculate section totals
|
|
const sectionTotals = useMemo(() => {
|
|
const totalsMap = new Map<string, number>();
|
|
let grandTotal = 0;
|
|
|
|
for (const category of categories) {
|
|
grandTotal += category.total;
|
|
for (const period of periods) {
|
|
const monthData = category.monthlyData.get(period);
|
|
const current = totalsMap.get(period) ?? 0;
|
|
totalsMap.set(period, current + (monthData?.amount ?? 0));
|
|
}
|
|
}
|
|
|
|
const nonZeroPeriodCount = periods.filter(
|
|
(p) => (totalsMap.get(p) ?? 0) > 0,
|
|
).length;
|
|
|
|
return {
|
|
totalsMap,
|
|
grandTotal,
|
|
averageMonthlyTotal:
|
|
nonZeroPeriodCount > 0 ? grandTotal / nonZeroPeriodCount : 0,
|
|
};
|
|
}, [categories, periods]);
|
|
|
|
if (categories.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Card className="px-6 py-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[240px] min-w-[240px] font-medium">
|
|
Categoria
|
|
</TableHead>
|
|
{periods.map((period) => (
|
|
<TableHead
|
|
key={period}
|
|
className="text-right min-w-[120px] font-semibold"
|
|
>
|
|
{formatPeriodLabel(period)}
|
|
</TableHead>
|
|
))}
|
|
<TableHead className="text-right min-w-[140px] font-semibold">
|
|
<div className="flex items-center justify-end gap-1">
|
|
Média
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="cursor-default inline-flex">
|
|
<RiInformationLine className="size-3.5 text-muted-foreground" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[280px]">
|
|
A média considera apenas os meses com gastos registrados
|
|
(valores maiores que zero). Meses sem movimentação não
|
|
entram no cálculo.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="text-right min-w-[120px] font-semibold">
|
|
Total
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
{categories.map((category, index) => {
|
|
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
|
|
|
|
return (
|
|
<TableRow key={category.categoryId}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<StatusDot
|
|
color={
|
|
category.type === "receita"
|
|
? "bg-success"
|
|
: "bg-destructive"
|
|
}
|
|
/>
|
|
|
|
<CategoryIconBadge
|
|
icon={category.icon}
|
|
name={category.name}
|
|
/>
|
|
<Link
|
|
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
|
|
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2 font-semibold"
|
|
>
|
|
{category.name}
|
|
</Link>
|
|
</div>
|
|
</TableCell>
|
|
{periods.map((period, periodIndex) => {
|
|
const monthData = category.monthlyData.get(period);
|
|
const isFirstMonth = periodIndex === 0;
|
|
|
|
return (
|
|
<TableCell key={period} className="text-right p-0">
|
|
<CategoryCell
|
|
value={monthData?.amount ?? 0}
|
|
previousValue={monthData?.previousAmount ?? 0}
|
|
categoryType={category.type}
|
|
isFirstMonth={isFirstMonth}
|
|
/>
|
|
</TableCell>
|
|
);
|
|
})}
|
|
<TableCell className="text-right font-semibold text-info">
|
|
{(() => {
|
|
const nonZeroCount = periods.filter(
|
|
(p) => (category.monthlyData.get(p)?.amount ?? 0) > 0,
|
|
).length;
|
|
return formatCurrency(
|
|
nonZeroCount > 0 ? category.total / nonZeroCount : 0,
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell className="text-right font-medium">
|
|
{formatCurrency(category.total)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
|
|
<TableFooter>
|
|
<TableRow>
|
|
<TableCell className="font-medium">Total</TableCell>
|
|
{periods.map((period) => {
|
|
const periodTotal = sectionTotals.totalsMap.get(period) ?? 0;
|
|
return (
|
|
<TableCell key={period} className="text-right font-medium">
|
|
{formatCurrency(periodTotal)}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
<TableCell className="text-right font-semibold text-info">
|
|
{formatCurrency(sectionTotals.averageMonthlyTotal)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-semibold">
|
|
{formatCurrency(sectionTotals.grandTotal)}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableFooter>
|
|
</Table>
|
|
</Card>
|
|
);
|
|
}
|