feat(transactions): add Transactions CRUD page — list, create, update evidence, delete

This commit is contained in:
admin-valentin 2026-07-31 16:34:23 +00:00
parent 5fb0eab34f
commit 13151d0eba

View file

@ -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<EvidenceStatus, string> = {
missing: 'Lipsă dovadă',
partial: 'Parțial',
complete: 'Complet',
not_required: 'Nu e necesar',
};
const EVIDENCE_COLORS: Record<EvidenceStatus, string> = {
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<typeof createTxSchema>;
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<Transaction[]>('/v1/transactions', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: organizations = [] } = useQuery({
queryKey: ['organizations', tenantId],
queryFn: () => apiFetch<Organization[]>('/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<CreateTxForm>({
resolver: zodResolver(createTxSchema),
defaultValues: { currency: 'EUR', type: 'invoice', evidenceStatus: 'missing' },
});
const createTx = useMutation({
mutationFn: (values: CreateTxForm) =>
apiFetch<Transaction>('/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<Transaction>(`/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 (
<div className="max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Tranzacții</h1>
<p className="text-sm text-ink-faint">
Tranzacțiile financiare înregistrate în workspace, cu status de dovadă contabilă.
</p>
</div>
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((v) => !v)}>
{isFormOpen ? 'Anulează' : 'Tranzacție nouă'}
</button>
</div>
{isFormOpen && (
<form
onSubmit={handleSubmit((values) => createTx.mutate(values))}
noValidate
className="card mb-6 grid grid-cols-2 gap-4 p-5"
>
<div className="col-span-2">
<label className="label" htmlFor="tx-org">Organizație</label>
{organizations.length === 0 ? (
<p className="mt-1 text-xs text-ink-faint">
Adaugă mai întâi o organizație în secțiunea Business OS Organizații.
</p>
) : (
<select id="tx-org" className="field" {...register('organizationId')}>
<option value="">Selectează organizația</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
)}
{errors.organizationId && (
<p className="mt-1 text-xs text-signal-danger">{errors.organizationId.message}</p>
)}
</div>
<div>
<label className="label" htmlFor="tx-type">Tip</label>
<input
id="tx-type"
type="text"
className="field"
list="tx-type-list"
placeholder="invoice, payment, expense…"
{...register('type')}
/>
<datalist id="tx-type-list">
{TX_TYPES.map((t) => <option key={t} value={t} />)}
</datalist>
{errors.type && <p className="mt-1 text-xs text-signal-danger">{errors.type.message}</p>}
</div>
<div>
<label className="label" htmlFor="tx-date">Data tranzacției</label>
<input id="tx-date" type="date" className="field" {...register('transactionDate')} />
{errors.transactionDate && (
<p className="mt-1 text-xs text-signal-danger">{errors.transactionDate.message}</p>
)}
</div>
<div>
<label className="label" htmlFor="tx-amount">Sumă</label>
<input
id="tx-amount"
type="number"
step="0.01"
min="0"
className="field"
placeholder="ex: 1500.00"
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && <p className="mt-1 text-xs text-signal-danger">{errors.amount.message}</p>}
</div>
<div>
<label className="label" htmlFor="tx-currency">Valută</label>
<input
id="tx-currency"
type="text"
maxLength={3}
className="field uppercase"
placeholder="EUR"
{...register('currency')}
/>
{errors.currency && <p className="mt-1 text-xs text-signal-danger">{errors.currency.message}</p>}
</div>
<div className="col-span-2">
<label className="label" htmlFor="tx-evidence">Status dovadă contabilă</label>
<select id="tx-evidence" className="field" {...register('evidenceStatus')}>
{(Object.entries(EVIDENCE_LABELS) as [EvidenceStatus, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
{createTx.isError && (
<p className="col-span-2 text-sm text-signal-danger">
{createTx.error instanceof Error ? createTx.error.message : 'Eroare la creare'}
</p>
)}
<div className="col-span-2">
<button
type="submit"
disabled={isSubmitting || createTx.isPending || organizations.length === 0}
className="btn-primary"
>
Salvează
</button>
</div>
</form>
)}
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && transactions.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">
Nicio tranzacție înregistrată. Adaugă prima mai sus.
</p>
)}
<ul className="space-y-2">
{transactions.map((tx) => (
<li key={tx.id} className="card flex items-center justify-between gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">
{formatAmount(tx.amountMinorUnits, tx.currency)}
</span>
<span className="rounded bg-paper-sunken px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-ink-soft">
{tx.type}
</span>
</div>
<p className="mt-0.5 text-xs text-ink-faint">
{orgMap.get(tx.organizationId) ?? tx.organizationId.slice(0, 8)}
{' · '}
{new Date(tx.transactionDate).toLocaleDateString('ro-RO')}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
aria-label="Status dovadă"
className={`rounded-lg border border-ink-line bg-paper-raised px-2.5 py-1.5 text-xs font-medium
focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20
${EVIDENCE_COLORS[tx.evidenceStatus as EvidenceStatus] ?? 'text-ink-faint'}`}
value={tx.evidenceStatus}
onChange={(e) =>
updateEvidence.mutate({ id: tx.id, evidenceStatus: e.target.value as EvidenceStatus })
}
>
{(Object.entries(EVIDENCE_LABELS) as [EvidenceStatus, string][]).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<button
type="button"
onClick={() => deleteTx.mutate(tx.id)}
disabled={deleteTx.isPending}
className="text-xs text-signal-danger hover:underline disabled:opacity-50"
>
Șterge
</button>
</div>
</li>
))}
</ul>
</div>
);
}