From 4ff5b84cb66e40be960c2be7a67334cf0cc98be2 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:45:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(accounting):=20relocate=20VAT=20codes=20+?= =?UTF-8?q?=20rate=20maps=20into=20Settings=20=E2=86=92=20Accounting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/admin/VatCodesManager.tsx | 196 ++++++++++++++++++ .../features/settings/tabs/AccountingTab.tsx | 5 + .../admin/accounting/ChartOfAccountsPage.tsx | 151 ++------------ 3 files changed, 215 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/admin/VatCodesManager.tsx diff --git a/frontend/src/components/admin/VatCodesManager.tsx b/frontend/src/components/admin/VatCodesManager.tsx new file mode 100644 index 00000000..f5972760 --- /dev/null +++ b/frontend/src/components/admin/VatCodesManager.tsx @@ -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(vat ? String(vat.rate) : '8.1'); + const [direction, setDirection] = useState(vat?.direction ?? 'input'); + const [accountId, setAccountId] = useState(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 ( +
+
+
+

{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}

+ +
+
+
+
setCode(e.target.value)} placeholder="VST81" />
+
setRate(e.target.value)} inputMode="decimal" />
+
+
setName(e.target.value)} />
+
+ +
+
+ +
+
+
+ + +
+
+
+ ); +}; + +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>({}); + 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 ; + + return ( +
+ {/* VAT codes table */} + +
+

{t('ledger.vatCodes.title', 'VAT codes')}

+ +
+
+ + + + + + + + + + + + {(vatCodes ?? []).map((v) => ( + + + + + + + + ))} + +
{t('ledger.vat.code', 'Code')}{t('ledger.vat.name', 'Name')}{t('ledger.vat.rate', 'Rate %')}{t('ledger.vat.direction', 'Direction')}{t('common.actions', 'Actions')}
{v.code}{v.name}{Number(v.rate).toFixed(1)}{t(`ledger.vatDirection.${v.direction}`, v.direction)} +
+ + +
+
+
+
+ + {/* Rate→code + treatment→code maps */} + +

{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}

+
+ {OUTPUT_RATES.map((rate) => ( +
+ + +
+ ))} +
+ +

{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}

+
+ {TAX_TREATMENTS.map((tt) => ( +
+ + +
+ ))} +
+ +
+ +
+
+ + {vatModal && setVatModal(null)} onDone={() => { setVatModal(null); refetch(); }} />} +
+ ); +}; diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx index 21890a50..9b5abad7 100644 --- a/frontend/src/features/settings/tabs/AccountingTab.tsx +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -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 = () => {
+ + {/* VAT codes + rate→code / treatment→code maps — relocated here from the + Chart-of-accounts page so all VAT config lives in one place. */} + ); }; diff --git a/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx b/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx index 2cc82e6d..ed106900 100644 --- a/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx +++ b/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx @@ -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(vat ? String(vat.rate) : '8.1'); - const [direction, setDirection] = useState(vat?.direction ?? 'input'); - const [accountId, setAccountId] = useState(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 ( -
-
-
-

{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}

- -
-
-
-
setCode(e.target.value)} placeholder="VST81" />
-
setRate(e.target.value)} inputMode="decimal" />
-
-
setName(e.target.value)} />
-
- -
-
- -
-
-
- - -
-
-
- ); -}; - 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 = {}; + 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 ; + if (la || lm) return ; return (
@@ -188,32 +131,6 @@ export const ChartOfAccountsPage: React.FC = () => { ))}
-

{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}

-
- {TAX_TREATMENTS.map((tt) => ( -
- - -
- ))} -
- -

{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}

-
- {OUTPUT_RATES.map((rate) => ( -
- - -
- ))} -
-
@@ -275,47 +192,7 @@ export const ChartOfAccountsPage: React.FC = () => { - {/* VAT codes */} - - -
-

{t('ledger.vatCodes.title', 'VAT codes')}

- -
-
- - - - - - - - - - - - {(vatCodes ?? []).map((v) => ( - - - - - - - - ))} - -
{t('ledger.vat.code', 'Code')}{t('ledger.vat.name', 'Name')}{t('ledger.vat.rate', 'Rate %')}{t('ledger.vat.direction', 'Direction')}{t('common.actions', 'Actions')}
{v.code}{v.name}{Number(v.rate).toFixed(1)}{t(`ledger.vatDirection.${v.direction}`, v.direction)} -
- - -
-
-
-
-
- {accountModal && setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />} - {vatModal && setVatModal(null)} onDone={() => { setVatModal(null); refetchAll(); }} />} ); };