From 13151d0eba4a1914530b050a3c69fe2b2049842b Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 16:34:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(transactions):=20add=20Transactions=20CRUD?= =?UTF-8?q?=20page=20=E2=80=94=20list,=20create,=20update=20evidence,=20de?= =?UTF-8?q?lete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/transactions/page.tsx | 299 ++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 src/app/dashboard/transactions/page.tsx diff --git a/src/app/dashboard/transactions/page.tsx b/src/app/dashboard/transactions/page.tsx new file mode 100644 index 0000000..79b9374 --- /dev/null +++ b/src/app/dashboard/transactions/page.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { apiFetch, type Transaction, type EvidenceStatus, type Organization } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +const EVIDENCE_LABELS: Record = { + missing: 'Lipsă dovadă', + partial: 'Parțial', + complete: 'Complet', + not_required: 'Nu e necesar', +}; + +const EVIDENCE_COLORS: Record = { + missing: 'text-signal-danger', + partial: 'text-signal-warn', + complete: 'text-signal-ok', + not_required: 'text-ink-faint', +}; + +const TX_TYPES = ['invoice', 'payment', 'expense', 'credit', 'debit', 'transfer']; + +const createTxSchema = z.object({ + organizationId: z.string().uuid('Selectează o organizație'), + type: z.string().min(1, 'Tipul este obligatoriu').max(64), + amount: z + .number({ invalid_type_error: 'Suma trebuie să fie un număr' }) + .positive('Suma trebuie să fie pozitivă'), + currency: z + .string() + .min(3, 'Valuta trebuie să aibă 3 caractere') + .max(3, 'Valuta trebuie să aibă 3 caractere') + .transform((v) => v.toUpperCase()), + transactionDate: z.string().min(1, 'Data este obligatorie'), + evidenceStatus: z.enum(['missing', 'partial', 'complete', 'not_required']).optional(), +}); +type CreateTxForm = z.infer; + +function formatAmount(minorUnits: string, currency: string): string { + const num = parseInt(minorUnits, 10); + if (isNaN(num)) return `? ${currency}`; + try { + return new Intl.NumberFormat('ro-RO', { + style: 'currency', + currency: currency.trim() || 'EUR', + minimumFractionDigits: 2, + }).format(num / 100); + } catch { + return `${(num / 100).toFixed(2)} ${currency}`; + } +} + +export default function TransactionsPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const queryClient = useQueryClient(); + const [isFormOpen, setIsFormOpen] = useState(false); + + const { data: transactions = [], isLoading } = useQuery({ + queryKey: ['transactions', tenantId], + queryFn: () => apiFetch('/v1/transactions', { tenantId }), + enabled: Boolean(tenantId), + }); + + const { data: organizations = [] } = useQuery({ + queryKey: ['organizations', tenantId], + queryFn: () => apiFetch('/v1/organizations', { tenantId }), + enabled: Boolean(tenantId), + }); + + const orgMap = new Map(organizations.map((o) => [o.id, o.name])); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createTxSchema), + defaultValues: { currency: 'EUR', type: 'invoice', evidenceStatus: 'missing' }, + }); + + const createTx = useMutation({ + mutationFn: (values: CreateTxForm) => + apiFetch('/v1/transactions', { + method: 'POST', + tenantId, + body: { + organizationId: values.organizationId, + type: values.type, + amountMinorUnits: Math.round(values.amount * 100), + currency: values.currency, + transactionDate: values.transactionDate, + evidenceStatus: values.evidenceStatus, + }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['transactions', tenantId] }); + reset({ currency: 'EUR', type: 'invoice', evidenceStatus: 'missing' }); + setIsFormOpen(false); + }, + }); + + const updateEvidence = useMutation({ + mutationFn: ({ id, evidenceStatus }: { id: string; evidenceStatus: EvidenceStatus }) => + apiFetch(`/v1/transactions/${id}`, { + method: 'PATCH', + tenantId, + body: { evidenceStatus }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['transactions', tenantId] }); + }, + }); + + const deleteTx = useMutation({ + mutationFn: (id: string) => + apiFetch<{ deleted: boolean }>(`/v1/transactions/${id}`, { method: 'DELETE', tenantId }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['transactions', tenantId] }); + }, + }); + + return ( +
+
+
+

Tranzacții

+

+ Tranzacțiile financiare înregistrate în workspace, cu status de dovadă contabilă. +

+
+ +
+ + {isFormOpen && ( +
createTx.mutate(values))} + noValidate + className="card mb-6 grid grid-cols-2 gap-4 p-5" + > +
+ + {organizations.length === 0 ? ( +

+ Adaugă mai întâi o organizație în secțiunea Business OS → Organizații. +

+ ) : ( + + )} + {errors.organizationId && ( +

{errors.organizationId.message}

+ )} +
+ +
+ + + + {TX_TYPES.map((t) => + {errors.type &&

{errors.type.message}

} +
+ +
+ + + {errors.transactionDate && ( +

{errors.transactionDate.message}

+ )} +
+ +
+ + + {errors.amount &&

{errors.amount.message}

} +
+ +
+ + + {errors.currency &&

{errors.currency.message}

} +
+ +
+ + +
+ + {createTx.isError && ( +

+ {createTx.error instanceof Error ? createTx.error.message : 'Eroare la creare'} +

+ )} + +
+ +
+
+ )} + + {isLoading &&

Se încarcă…

} + {!isLoading && transactions.length === 0 && ( +

+ Nicio tranzacție înregistrată. Adaugă prima mai sus. +

+ )} + +
    + {transactions.map((tx) => ( +
  • +
    +
    + + {formatAmount(tx.amountMinorUnits, tx.currency)} + + + {tx.type} + +
    +

    + {orgMap.get(tx.organizationId) ?? tx.organizationId.slice(0, 8)} + {' · '} + {new Date(tx.transactionDate).toLocaleDateString('ro-RO')} +

    +
    +
    + + +
    +
  • + ))} +
+
+ ); +}