Files
openmonetis/components/contas/account-dialog.tsx
Felipe Coutinho fd817683ca feat: implementar sistema de preferências do usuário e refatorar changelog
Adiciona sistema completo de preferências de usuário:
  - Cria tabela userPreferences no schema com campos disableMagnetlines, periodMonthsBefore e periodMonthsAfter
  - Implementa página de Ajustes com abas (Preferências, Alterar nome, Senha, E-mail, Deletar conta)
  - Adiciona componente PreferencesForm para configuração de magnetlines e períodos de exibição
  - Propaga periodPreferences para todos os componentes de lançamentos e calendário

  Refatora sistema de changelog:
  - Remove implementação anterior baseada em JSON estático
  - Adiciona nova página de changelog dinâmica em app/(dashboard)/changelog
  - Adiciona componente changelog-list.tsx
  - Remove arquivos obsoletos (changelog-notification, actions, data, utils, scripts)

  Adiciona controle de saldo inicial em contas:
  - Novo campo excludeInitialBalanceFromIncome em contas
  - Permite excluir saldo inicial do cálculo de receitas
  - Atualiza queries de lançamentos para respeitar esta configuração

  Melhorias adicionais:
  - Adiciona componente ui/accordion.tsx do shadcn/ui
  - Refatora formatPeriodLabel para displayPeriod centralizado
  - Propaga estabelecimentos para componentes de lançamentos
  - Remove variável DB_PROVIDER obsoleta do .env.example e documentação
  - Adiciona 6 migrações de banco de dados (0003-0008)
2026-01-03 14:18:03 +00:00

271 lines
7.3 KiB
TypeScript

"use client";
import {
createAccountAction,
updateAccountAction,
} from "@/app/(dashboard)/contas/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatInitialBalanceInput } from "@/lib/utils/currency";
import { AccountFormFields } from "./account-form-fields";
import type { Account, AccountFormValues } from "./types";
const DEFAULT_ACCOUNT_TYPES = [
"Conta Corrente",
"Conta Poupança",
"Carteira Digital",
"Conta Investimento",
"Pré-Pago | VR/VA",
] as const;
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
interface AccountDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
account?: Account;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
account,
logoOptions,
accountTypes,
accountStatuses,
}: {
account?: Account;
logoOptions: string[];
accountTypes: string[];
accountStatuses: string[];
}): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
return {
name: account?.name ?? derivedName,
accountType: account?.accountType ?? accountTypes[0] ?? "",
status: account?.status ?? accountStatuses[0] ?? "",
note: account?.note ?? "",
logo: selectedLogo,
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
excludeFromBalance: account?.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
account?.excludeInitialBalanceFromIncome ?? false,
};
};
export function AccountDialog({
mode,
trigger,
logoOptions,
account,
open,
onOpenChange,
}: AccountDialogProps) {
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
const accountTypes = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
if (account?.accountType) {
values.add(account.accountType);
}
return Array.from(values);
}, [account?.accountType]);
const accountStatuses = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
if (account?.status) {
values.add(account.status);
}
return Array.from(values);
}, [account?.status]);
const initialState = useMemo(
() =>
buildInitialValues({
account,
logoOptions,
accountTypes,
accountStatuses,
}),
[account, logoOptions, accountTypes, accountStatuses]
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
useFormState<AccountFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
setLogoDialogOpen(false);
},
});
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !account?.id) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = { ...formState };
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createAccountAction(payload)
: await updateAccountAction({
id: account?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[account?.id, formState, initialState, mode, setDialogOpen, setFormState]
);
const title = mode === "create" ? "Nova conta" : "Editar conta";
const description =
mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos."
: "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
return (
<>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
</div>
<AccountFormFields
values={formState}
accountTypes={accountTypes}
accountStatuses={accountStatuses}
onChange={updateField}
showInitialBalance={mode === "create"}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
}