Files
openmonetis/src/features/dashboard/components/widgets/purchases-by-category-widget.tsx
Felipe Coutinho 7d0781b035 refactor: faxina arquitetural — código morto, identificadores em inglês e estrutura padronizada
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>
2026-05-06 18:42:54 +00:00

194 lines
5.6 KiB
TypeScript

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