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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user