mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 19:21:46 +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>
194 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|