feat(transactions): add Transactions CRUD page — list, create, update evidence, delete
This commit is contained in:
parent
5fb0eab34f
commit
13151d0eba
1 changed files with 299 additions and 0 deletions
299
src/app/dashboard/transactions/page.tsx
Normal file
299
src/app/dashboard/transactions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue