feat(accounting): relocate VAT codes + rate maps into Settings → Accounting

Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.

Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
This commit is contained in:
Luca
2026-06-16 00:45:50 +02:00
parent d7107aaf0a
commit 4ff5b84cb6
3 changed files with 215 additions and 137 deletions
@@ -0,0 +1,196 @@
/**
* VAT-codes manager — the single home for VAT codes + the rate→code /
* treatment→code maps (Settings → Accounting). Relocated from the
* Chart-of-accounts page so all VAT config lives in one place.
*
* NOTE: ledgerService.updateSettings is a PARTIAL merge, so this component saves
* ONLY the two map keys — the Chart-of-accounts page saves only its account
* keys, and the two never overwrite each other.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { X, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../common';
import {
ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings,
} from '../../services/ledger.service';
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const OUTPUT_RATES = ['8.1', '2.6', '3.8', '0'];
const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () => void; onDone: () => void }> = ({ vat, accounts, onClose, onDone }) => {
const { t } = useTranslation();
const isEdit = !!vat;
const [code, setCode] = useState(vat?.code ?? '');
const [name, setName] = useState(vat?.name ?? '');
const [rate, setRate] = useState<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? '');
const save = useMutation({
mutationFn: () => {
const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) };
return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload);
},
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{t('ledger.vat.code', 'Code')}</label><Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="VST81" /></div>
<div><label className={labelCls}>{t('ledger.vat.rate', 'Rate %')}</label><Input value={rate} onChange={(e) => setRate(e.target.value)} inputMode="decimal" /></div>
</div>
<div><label className={labelCls}>{t('ledger.vat.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.vat.direction', 'Direction')}</label>
<select value={direction} onChange={(e) => setDirection(e.target.value as VatDirection)} className={selectCls}>
<option value="input">{t('ledger.vatDirection.input', 'Input (Vorsteuer)')}</option>
<option value="output">{t('ledger.vatDirection.output', 'Output (Umsatzsteuer)')}</option>
</select>
</div>
<div><label className={labelCls}>{t('ledger.vat.account', 'VAT account')}</label>
<select value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')} className={selectCls}>
<option value="">{t('ledger.vat.noAccount', '— none —')}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !code || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
export const VatCodesManager: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const [vatModal, setVatModal] = useState<{ vat?: VatCode } | null>(null);
const { data: accounts } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
const { data: vatCodes, isLoading: lv } = useQuery({ queryKey: ['ledger-vat-codes'], queryFn: () => ledgerService.listVatCodes() });
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
// Local copy of ONLY the VAT maps (the account keys stay on the CoA page).
const [maps, setMaps] = useState<Pick<LedgerSettings, 'ledger_vat_map' | 'ledger_output_vat_map'>>({});
useEffect(() => {
if (mappings?.settings) {
setMaps({
ledger_vat_map: mappings.settings.ledger_vat_map,
ledger_output_vat_map: mappings.settings.ledger_output_vat_map,
});
}
}, [mappings?.settings]);
const inputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'input'), [vatCodes]);
const outputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'output'), [vatCodes]);
const refetch = () => { qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
const delVat = useMutation({
mutationFn: (id: number) => ledgerService.deleteVatCode(id),
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetch(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
// PARTIAL save — only the two map keys, never the account keys.
const saveMaps = useMutation({
mutationFn: () => ledgerService.updateSettings({
ledger_vat_map: maps.ledger_vat_map || {},
ledger_output_vat_map: maps.ledger_output_vat_map || {},
}),
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const setVatMap = (tt: string, code: string) => setMaps((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } }));
const setOutputVatMap = (rate: string, code: string) => setMaps((s) => ({ ...s, ledger_output_vat_map: { ...(s.ledger_output_vat_map || {}), [rate]: code } }));
if (lv || lm) return <Loading />;
return (
<div className="space-y-6">
{/* VAT codes table */}
<Card><CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.vatCodes.title', 'VAT codes')}</h2>
<Button size="sm" onClick={() => setVatModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.vat.addTitle', 'Add VAT code')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.code', 'Code')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('ledger.vat.rate', 'Rate %')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.direction', 'Direction')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(vatCodes ?? []).map((v) => (
<tr key={v.id} className={v.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 font-medium text-neutral-900 dark:text-neutral-100">{v.code}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{v.name}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-neutral-700 dark:text-neutral-300">{Number(v.rate).toFixed(1)}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.vatDirection.${v.direction}`, v.direction)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setVatModal({ vat: v })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.vat.confirmDelete', 'Delete this VAT code?') as string)) delVat.mutate(v.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent></Card>
{/* Rate→code + treatment→code maps */}
<Card><CardContent className="p-5">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{OUTPUT_RATES.map((rate) => (
<div key={rate}>
<label className={labelCls}>{rate}%</label>
<select value={maps.ledger_output_vat_map?.[rate] ?? ''} onChange={(e) => setOutputVatMap(rate, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{outputVat.map((v) => <option key={v.id} value={v.code}>{v.code}</option>)}
</select>
</div>
))}
</div>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TAX_TREATMENTS.map((tt) => (
<div key={tt}>
<label className={labelCls}>{t(`accounting.taxTreatment.${tt}`, tt)}</label>
<select value={maps.ledger_vat_map?.[tt] ?? ''} onChange={(e) => setVatMap(tt, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{inputVat.map((v) => <option key={v.id} value={v.code}>{v.code} · {v.name}</option>)}
</select>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => saveMaps.mutate()} disabled={saveMaps.isPending}>{saveMaps.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
</div>
</CardContent></Card>
{vatModal && <VatModal vat={vatModal.vat} accounts={accounts ?? []} onClose={() => setVatModal(null)} onDone={() => { setVatModal(null); refetch(); }} />}
</div>
);
};
@@ -12,6 +12,7 @@ import { Button, Card, CardContent, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { accountingService } from '../../../services/accounting.service';
import { sortedCountryOptions } from '../../../constants/countries';
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
@@ -116,6 +117,10 @@ export const AccountingTab: React.FC = () => {
<div>
<Button onClick={() => save.mutate()} disabled={save.isPending}><Save className="w-4 h-4 mr-2" /> {save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
{/* VAT codes + rate→code / treatment→code maps — relocated here from the
Chart-of-accounts page so all VAT config lives in one place. */}
<VatCodesManager />
</div>
);
};
@@ -1,9 +1,10 @@
/**
* Accounting → Chart of accounts (Layer A).
*
* Full CRUD for the Swiss/LI KMU-Kontenrahmen + MWST codes, plus the mappings
* the Treuhänder export relies on: which account each expense category books
* to, the default/system accounts, and the tax-treatment → VAT-code maps.
* Full CRUD for the Swiss/LI KMU-Kontenrahmen accounts, plus the mappings the
* Treuhänder export relies on: which account each expense category books to and
* the default/system accounts. VAT codes + their rate/treatment maps live in
* Settings → Accounting (VatCodesManager), so all VAT config sits in one place.
*
* This data drives the export only — picpeak is not a double-entry ledger.
*/
@@ -14,15 +15,13 @@ import { toast } from 'react-toastify';
import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import {
ledgerService, type LedgerAccount, type VatCode, type AccountType, type VatDirection, type LedgerSettings,
ledgerService, type LedgerAccount, type AccountType, type LedgerSettings,
} from '../../../services/ledger.service';
import { categoryLabel } from '../../../services/accounting.service';
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense'];
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const OUTPUT_RATES = ['8.1', '2.6', '3.8', '0'];
const SETTING_ACCOUNT_KEYS: (keyof LedgerSettings)[] = [
'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash',
'ledger_account_default_revenue', 'ledger_account_default_expense',
@@ -66,66 +65,12 @@ const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onD
);
};
// ── VAT code modal ─────────────────────────────────────────────────────
const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () => void; onDone: () => void }> = ({ vat, accounts, onClose, onDone }) => {
const { t } = useTranslation();
const isEdit = !!vat;
const [code, setCode] = useState(vat?.code ?? '');
const [name, setName] = useState(vat?.name ?? '');
const [rate, setRate] = useState<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? '');
const save = useMutation({
mutationFn: () => {
const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) };
return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload);
},
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{t('ledger.vat.code', 'Code')}</label><Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="VST81" /></div>
<div><label className={labelCls}>{t('ledger.vat.rate', 'Rate %')}</label><Input value={rate} onChange={(e) => setRate(e.target.value)} inputMode="decimal" /></div>
</div>
<div><label className={labelCls}>{t('ledger.vat.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.vat.direction', 'Direction')}</label>
<select value={direction} onChange={(e) => setDirection(e.target.value as VatDirection)} className={selectCls}>
<option value="input">{t('ledger.vatDirection.input', 'Input (Vorsteuer)')}</option>
<option value="output">{t('ledger.vatDirection.output', 'Output (Umsatzsteuer)')}</option>
</select>
</div>
<div><label className={labelCls}>{t('ledger.vat.account', 'VAT account')}</label>
<select value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')} className={selectCls}>
<option value="">{t('ledger.vat.noAccount', '— none —')}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !code || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
export const ChartOfAccountsPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const [accountModal, setAccountModal] = useState<{ account?: LedgerAccount } | null>(null);
const [vatModal, setVatModal] = useState<{ vat?: VatCode } | null>(null);
const { data: accounts, isLoading: la } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
const { data: vatCodes, isLoading: lv } = useQuery({ queryKey: ['ledger-vat-codes'], queryFn: () => ledgerService.listVatCodes() });
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
// Local editable copy of the settings (default accounts + VAT maps).
@@ -133,8 +78,6 @@ export const ChartOfAccountsPage: React.FC = () => {
useEffect(() => { if (mappings?.settings) setSettings(mappings.settings); }, [mappings?.settings]);
const accountOptions = useMemo(() => (accounts ?? []).filter((a) => a.active), [accounts]);
const inputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'input'), [vatCodes]);
const outputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'output'), [vatCodes]);
const refetchAll = () => { qc.invalidateQueries({ queryKey: ['ledger-accounts'] }); qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
@@ -143,27 +86,27 @@ export const ChartOfAccountsPage: React.FC = () => {
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const delVat = useMutation({
mutationFn: (id: number) => ledgerService.deleteVatCode(id),
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const setCat = useMutation({
mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
// Save ONLY the account keys — the VAT maps now live in Settings → Accounting
// (VatCodesManager) and updateSettings is a partial merge, so scoping the
// patch here prevents a stale full-settings save from reverting the maps.
const saveSettings = useMutation({
mutationFn: () => ledgerService.updateSettings(settings),
mutationFn: () => {
const patch: Partial<LedgerSettings> = {};
for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k];
return ledgerService.updateSettings(patch);
},
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value }));
const setVatMap = (tt: string, code: string) => setSettings((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } }));
const setOutputVatMap = (rate: string, code: string) => setSettings((s) => ({ ...s, ledger_output_vat_map: { ...(s.ledger_output_vat_map || {}), [rate]: code } }));
if (la || lv || lm) return <Loading />;
if (la || lm) return <Loading />;
return (
<div className="space-y-6">
@@ -188,32 +131,6 @@ export const ChartOfAccountsPage: React.FC = () => {
))}
</div>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TAX_TREATMENTS.map((tt) => (
<div key={tt}>
<label className={labelCls}>{t(`accounting.taxTreatment.${tt}`, tt)}</label>
<select value={settings.ledger_vat_map?.[tt] ?? ''} onChange={(e) => setVatMap(tt, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{inputVat.map((v) => <option key={v.id} value={v.code}>{v.code} · {v.name}</option>)}
</select>
</div>
))}
</div>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{OUTPUT_RATES.map((rate) => (
<div key={rate}>
<label className={labelCls}>{rate}%</label>
<select value={settings.ledger_output_vat_map?.[rate] ?? ''} onChange={(e) => setOutputVatMap(rate, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{outputVat.map((v) => <option key={v.id} value={v.code}>{v.code}</option>)}
</select>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>{saveSettings.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
</div>
@@ -275,47 +192,7 @@ export const ChartOfAccountsPage: React.FC = () => {
</CardContent>
</Card>
{/* VAT codes */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.vatCodes.title', 'VAT codes')}</h2>
<Button size="sm" onClick={() => setVatModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.vat.addTitle', 'Add VAT code')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.code', 'Code')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('ledger.vat.rate', 'Rate %')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.direction', 'Direction')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(vatCodes ?? []).map((v) => (
<tr key={v.id} className={v.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 font-medium text-neutral-900 dark:text-neutral-100">{v.code}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{v.name}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-neutral-700 dark:text-neutral-300">{Number(v.rate).toFixed(1)}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.vatDirection.${v.direction}`, v.direction)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setVatModal({ vat: v })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.vat.confirmDelete', 'Delete this VAT code?') as string)) delVat.mutate(v.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{accountModal && <AccountModal account={accountModal.account} onClose={() => setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />}
{vatModal && <VatModal vat={vatModal.vat} accounts={accounts ?? []} onClose={() => setVatModal(null)} onDone={() => { setVatModal(null); refetchAll(); }} />}
</div>
);
};