mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +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>
271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
"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/lib/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/widgets/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>
|
|
);
|
|
}
|