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