mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51: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>
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
closestCorners,
|
|
DndContext,
|
|
type DragEndEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
rectSortingStrategy,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
} from "@dnd-kit/sortable";
|
|
import {
|
|
RiAddFill,
|
|
RiCheckLine,
|
|
RiCloseLine,
|
|
RiDragMove2Line,
|
|
RiEyeOffLine,
|
|
RiTodoLine,
|
|
} from "@remixicon/react";
|
|
import { useMemo, useState, useTransition } from "react";
|
|
import { toast } from "sonner";
|
|
import { SortableWidget } from "@/features/dashboard/components/widgets/sortable-widget";
|
|
import { WidgetSettingsDialog } from "@/features/dashboard/components/widgets/widget-settings-dialog";
|
|
import type { DashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
|
import {
|
|
resetWidgetPreferences,
|
|
updateWidgetPreferences,
|
|
type WidgetPreferences,
|
|
} from "@/features/dashboard/widget-registry/widget-actions";
|
|
import {
|
|
type DashboardWidgetQuickActionOptions,
|
|
type WidgetConfig,
|
|
widgetsConfig,
|
|
} from "@/features/dashboard/widget-registry/widget-config";
|
|
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
|
import { Button } from "@/shared/components/ui/button";
|
|
import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card";
|
|
|
|
type DashboardGridEditableProps = {
|
|
data: DashboardData;
|
|
period: string;
|
|
initialPreferences: WidgetPreferences | null;
|
|
quickActionOptions: DashboardWidgetQuickActionOptions;
|
|
};
|
|
|
|
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
|
|
|
export function DashboardGridEditable({
|
|
data,
|
|
period,
|
|
initialPreferences,
|
|
quickActionOptions,
|
|
}: DashboardGridEditableProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// Initialize widget order and hidden state
|
|
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
|
initialPreferences?.order ?? DEFAULT_WIDGET_ORDER,
|
|
);
|
|
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
|
initialPreferences?.hidden ?? [],
|
|
);
|
|
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
|
|
initialPreferences?.myAccountsShowExcluded ?? true,
|
|
);
|
|
|
|
// Keep track of original state for cancel
|
|
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
|
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
);
|
|
|
|
// Get ordered and visible widgets
|
|
const orderedWidgets = useMemo(() => {
|
|
// Create a map for quick lookup
|
|
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
|
|
|
|
// Get widgets in order, filtering out hidden ones
|
|
const ordered: WidgetConfig[] = [];
|
|
for (const id of widgetOrder) {
|
|
const widget = widgetMap.get(id);
|
|
if (widget && !hiddenWidgets.includes(id)) {
|
|
ordered.push(widget);
|
|
}
|
|
}
|
|
|
|
// Add any new widgets that might not be in the order yet
|
|
for (const widget of widgetsConfig) {
|
|
if (
|
|
!widgetOrder.includes(widget.id) &&
|
|
!hiddenWidgets.includes(widget.id)
|
|
) {
|
|
ordered.push(widget);
|
|
}
|
|
}
|
|
|
|
return ordered;
|
|
}, [widgetOrder, hiddenWidgets]);
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (over && active.id !== over.id) {
|
|
setWidgetOrder((items) => {
|
|
const oldIndex = items.indexOf(active.id as string);
|
|
const newIndex = items.indexOf(over.id as string);
|
|
return arrayMove(items, oldIndex, newIndex);
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleToggleWidget = (widgetId: string) => {
|
|
const newHidden = hiddenWidgets.includes(widgetId)
|
|
? hiddenWidgets.filter((id) => id !== widgetId)
|
|
: [...hiddenWidgets, widgetId];
|
|
|
|
setHiddenWidgets(newHidden);
|
|
|
|
// Salvar automaticamente ao toggle
|
|
startTransition(async () => {
|
|
await updateWidgetPreferences({
|
|
order: widgetOrder,
|
|
hidden: newHidden,
|
|
});
|
|
});
|
|
};
|
|
|
|
const handleHideWidget = (widgetId: string) => {
|
|
setHiddenWidgets((prev) => [...prev, widgetId]);
|
|
};
|
|
|
|
const handleStartEditing = () => {
|
|
setOriginalOrder(widgetOrder);
|
|
setOriginalHidden(hiddenWidgets);
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleCancelEditing = () => {
|
|
setWidgetOrder(originalOrder);
|
|
setHiddenWidgets(originalHidden);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
startTransition(async () => {
|
|
const result = await updateWidgetPreferences({
|
|
order: widgetOrder,
|
|
hidden: hiddenWidgets,
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success("Preferências salvas!");
|
|
setIsEditing(false);
|
|
} else {
|
|
toast.error(result.error ?? "Erro ao salvar");
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleReset = () => {
|
|
startTransition(async () => {
|
|
const result = await resetWidgetPreferences();
|
|
|
|
if (result.success) {
|
|
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
|
setHiddenWidgets([]);
|
|
setMyAccountsShowExcluded(true);
|
|
toast.success("Preferências restauradas!");
|
|
} else {
|
|
toast.error(result.error ?? "Erro ao restaurar");
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
{!isEditing ? (
|
|
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
|
|
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
|
|
<TransactionDialog
|
|
mode="create"
|
|
payerOptions={quickActionOptions.payerOptions}
|
|
splitPayerOptions={quickActionOptions.splitPayerOptions}
|
|
defaultPayerId={quickActionOptions.defaultPayerId}
|
|
accountOptions={quickActionOptions.accountOptions}
|
|
cardOptions={quickActionOptions.cardOptions}
|
|
categoryOptions={quickActionOptions.categoryOptions}
|
|
estabelecimentos={quickActionOptions.estabelecimentos}
|
|
defaultPeriod={period}
|
|
defaultTransactionType="Receita"
|
|
trigger={
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
|
>
|
|
<span className="flex items-center gap-0.5">
|
|
<RiAddFill className="size-3.5 shrink-0 text-success/80" />
|
|
</span>
|
|
<span className="sm:hidden">Receita</span>
|
|
<span className="hidden sm:inline">Nova receita</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
<TransactionDialog
|
|
mode="create"
|
|
payerOptions={quickActionOptions.payerOptions}
|
|
splitPayerOptions={quickActionOptions.splitPayerOptions}
|
|
defaultPayerId={quickActionOptions.defaultPayerId}
|
|
accountOptions={quickActionOptions.accountOptions}
|
|
cardOptions={quickActionOptions.cardOptions}
|
|
categoryOptions={quickActionOptions.categoryOptions}
|
|
estabelecimentos={quickActionOptions.estabelecimentos}
|
|
defaultPeriod={period}
|
|
defaultTransactionType="Despesa"
|
|
trigger={
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
|
>
|
|
<span className="flex items-center gap-0.5">
|
|
<RiAddFill className="size-3.5 shrink-0 text-destructive/80" />
|
|
</span>
|
|
<span className="sm:hidden">Despesa</span>
|
|
<span className="hidden sm:inline">Nova despesa</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
<NoteDialog
|
|
mode="create"
|
|
trigger={
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-12 w-full min-w-0 flex-col justify-center gap-0.5 px-1.5 text-sm whitespace-normal sm:h-8 sm:w-auto sm:flex-row sm:gap-2 sm:px-3 sm:whitespace-nowrap"
|
|
>
|
|
<RiTodoLine className="size-3.5 shrink-0 text-info/80" />
|
|
<span className="sm:hidden">Anotação</span>
|
|
<span className="hidden sm:inline">Nova anotação</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
|
|
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCancelEditing}
|
|
disabled={isPending}
|
|
className="gap-2"
|
|
>
|
|
<RiCloseLine className="size-4" />
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={isPending}
|
|
className="gap-2"
|
|
>
|
|
<RiCheckLine className="size-4" />
|
|
Salvar
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
|
|
<WidgetSettingsDialog
|
|
hiddenWidgets={hiddenWidgets}
|
|
onToggleWidget={handleToggleWidget}
|
|
onReset={handleReset}
|
|
triggerClassName="w-full sm:w-auto"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleStartEditing}
|
|
className="w-full gap-2 sm:w-auto"
|
|
>
|
|
<RiDragMove2Line className="size-4" />
|
|
Reordenar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={orderedWidgets.map((w) => w.id)}
|
|
strategy={rectSortingStrategy}
|
|
>
|
|
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
|
|
{orderedWidgets.map((widget) => (
|
|
<SortableWidget
|
|
key={widget.id}
|
|
id={widget.id}
|
|
isEditing={isEditing}
|
|
>
|
|
<div className="relative">
|
|
{isEditing && (
|
|
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<RiDragMove2Line className="size-8 text-primary" />
|
|
<span className="text-xs font-medium">
|
|
Arraste para mover
|
|
</span>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleHideWidget(widget.id);
|
|
}}
|
|
className="gap-1 mt-2"
|
|
>
|
|
<RiEyeOffLine className="size-4" />
|
|
Ocultar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<ExpandableWidgetCard
|
|
title={widget.title}
|
|
subtitle={widget.subtitle}
|
|
icon={widget.icon}
|
|
action={widget.action}
|
|
>
|
|
{widget.component({
|
|
data,
|
|
period,
|
|
adminPayerSlug:
|
|
quickActionOptions.payerOptions.find(
|
|
(p) => p.value === quickActionOptions.defaultPayerId,
|
|
)?.slug ?? null,
|
|
widgetPreferences: {
|
|
order: widgetOrder,
|
|
hidden: hiddenWidgets,
|
|
myAccountsShowExcluded,
|
|
},
|
|
quickActionOptions,
|
|
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
|
})}
|
|
</ExpandableWidgetCard>
|
|
</div>
|
|
</SortableWidget>
|
|
))}
|
|
</section>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{/* Hidden widgets indicator */}
|
|
{hiddenWidgets.length > 0 && !isEditing && (
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
{hiddenWidgets.length} widget(s) oculto(s) •{" "}
|
|
<button
|
|
onClick={handleReset}
|
|
className="text-primary hover:underline"
|
|
>
|
|
Restaurar todos
|
|
</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|