feat(accounting): Layer A frontend — chart of accounts CRUD + Treuhänder export UI
- ledger.service.ts: accounts/VAT-codes/mappings CRUD + export client - ChartOfAccountsPage: full CRUD for the Swiss/LI KMU chart + MWST codes, category→account mapping, default/system accounts + tax-treatment/rate→VAT maps, with a 'guideline only' note - LedgerExportPage: period + currency + target tool (generic/Banana/bexio) → collective-journal CSV download, with accrual-only + Treuhänder disclaimer - AccountingLayout: 'Treuhänder export' (taxReport flag) + 'Chart of accounts' (accounting flag) sub-nav entries; App routes wired - en/de translations (accounting.taxTreatment.* enum + ledger.* namespace) de/en authored natively; no machine-translated locales touched here.
This commit is contained in:
@@ -67,6 +67,8 @@ import { ClientsLayout } from './components/admin/ClientsLayout';
|
||||
import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout';
|
||||
import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage';
|
||||
import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage';
|
||||
import { ChartOfAccountsPage } from './pages/admin/accounting/ChartOfAccountsPage';
|
||||
import { LedgerExportPage } from './pages/admin/accounting/LedgerExportPage';
|
||||
import { RequireFeature } from './components/admin/RequireFeature';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
@@ -278,7 +280,11 @@ function App() {
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="taxReport" />}>
|
||||
<Route path="tax-report" element={<TaxReportPage />} />
|
||||
<Route path="export" element={<LedgerExportPage />} />
|
||||
</Route>
|
||||
{/* Chart of accounts + VAT codes (Layer A) — gated by
|
||||
the accounting master flag alongside the section. */}
|
||||
<Route path="ledger" element={<ChartOfAccountsPage />} />
|
||||
<Route index element={<AccountingIndex />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Landmark, Calculator, Inbox, Wallet } from 'lucide-react';
|
||||
import { Landmark, Calculator, Inbox, Wallet, BookOpen, FileSpreadsheet } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
@@ -50,7 +50,21 @@ export const AccountingLayout: React.FC = () => {
|
||||
icon: Calculator,
|
||||
featureFlag: 'taxReport',
|
||||
},
|
||||
// Future: expenses ledger, Erfolgsrechnung.
|
||||
{
|
||||
key: 'export',
|
||||
to: '/admin/accounting/export',
|
||||
label: t('accounting.subnav.export', 'Treuhänder export'),
|
||||
icon: FileSpreadsheet,
|
||||
featureFlag: 'taxReport',
|
||||
},
|
||||
{
|
||||
key: 'ledger',
|
||||
to: '/admin/accounting/ledger',
|
||||
label: t('accounting.subnav.chartOfAccounts', 'Chart of accounts'),
|
||||
icon: BookOpen,
|
||||
featureFlag: 'accounting',
|
||||
},
|
||||
// Future: Erfolgsrechnung (Layer B).
|
||||
];
|
||||
|
||||
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
|
||||
|
||||
@@ -3450,7 +3450,15 @@
|
||||
"subnav": {
|
||||
"incomingInvoices": "Eingangsrechnungen",
|
||||
"expenses": "Aufwände",
|
||||
"taxReport": "Steuer"
|
||||
"taxReport": "Steuer",
|
||||
"export": "Treuhänder-Export",
|
||||
"chartOfAccounts": "Kontenplan"
|
||||
},
|
||||
"taxTreatment": {
|
||||
"domestic": "Inland (mit MWST)",
|
||||
"reverse_charge_service": "Bezugsteuer (Dienstleistungen)",
|
||||
"foreign_vat_non_reclaimable": "Ausländische MWST (nicht abziehbar)",
|
||||
"import_goods": "Wareneinfuhr"
|
||||
},
|
||||
"disposition": {
|
||||
"rebill": "An Kunde weiterverrechnen",
|
||||
@@ -3757,6 +3765,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"intro": "Dient nur dem Treuhänder-Export — picpeak führt keine doppelte Buchhaltung. Der vorbelegte Kontenplan + die MWST-Codes folgen dem Schweizer/LI KMU-Kontenrahmen; passe sie an das Setup deines Treuhänders an.",
|
||||
"settingsSaved": "Zuordnungen gespeichert.",
|
||||
"saveDefaults": "Zuordnungen speichern",
|
||||
"accountType": {
|
||||
"asset": "Aktiven", "liability": "Passiven", "equity": "Eigenkapital", "revenue": "Ertrag", "expense": "Aufwand"
|
||||
},
|
||||
"vatDirection": {
|
||||
"input": "Vorsteuer", "output": "Umsatzsteuer"
|
||||
},
|
||||
"defaults": {
|
||||
"title": "Standard- & Systemkonten",
|
||||
"none": "— keines —",
|
||||
"ledger_account_debitoren": "Forderungen (Debitoren)",
|
||||
"ledger_account_kreditoren": "Verbindlichkeiten (Kreditoren)",
|
||||
"ledger_account_bank": "Bank",
|
||||
"ledger_account_cash": "Kasse",
|
||||
"ledger_account_default_revenue": "Standard-Ertragskonto",
|
||||
"ledger_account_default_expense": "Standard-Aufwandkonto",
|
||||
"ledger_account_mileage": "Kilometer-Konto",
|
||||
"ledger_account_per_diem": "Spesenpauschalen-Konto",
|
||||
"ledger_account_rebilled_revenue": "Weiterverrechnete Spesen (Ertrag)"
|
||||
},
|
||||
"vatMap": { "title": "MWST-Code nach steuerlicher Behandlung (Kosten)" },
|
||||
"outputVatMap": { "title": "MWST-Code nach Umsatzsatz" },
|
||||
"categoryMap": { "title": "Aufwandkategorie → Konto" },
|
||||
"accounts": { "title": "Kontenplan" },
|
||||
"vatCodes": { "title": "MWST-Codes" },
|
||||
"account": {
|
||||
"addTitle": "Konto hinzufügen", "editTitle": "Konto bearbeiten",
|
||||
"number": "Kontonummer", "name": "Name", "type": "Art",
|
||||
"confirmDelete": "Dieses Konto löschen?"
|
||||
},
|
||||
"vat": {
|
||||
"addTitle": "MWST-Code hinzufügen", "editTitle": "MWST-Code bearbeiten",
|
||||
"code": "Code", "name": "Name", "rate": "Satz %", "direction": "Richtung",
|
||||
"account": "MWST-Konto", "noAccount": "— keines —", "confirmDelete": "Diesen MWST-Code löschen?"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder-Export",
|
||||
"intro": "Lade das Sammeljournal (Erträge + Kosten als periodengerechte Buchungssätze mit Konto- und MWST-Codes) zum Import in die Buchhaltungssoftware deines Treuhänders herunter.",
|
||||
"format": "Zielsoftware",
|
||||
"format_generic": "Generisch (alle Spalten)",
|
||||
"format_banana": "Banana Buchhaltung",
|
||||
"format_bexio": "bexio",
|
||||
"download": "CSV herunterladen",
|
||||
"failed": "Export fehlgeschlagen.",
|
||||
"disclaimer": "Nur periodengerecht (Belegdaten) — Zahlungen/Bankbewegungen sind nicht enthalten. Konto- + MWST-Codes folgen deiner Kontenplan-Zuordnung. Den Import vor der Einreichung immer mit dem Treuhänder prüfen."
|
||||
}
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Steuerliste",
|
||||
"intro": "Periodenbezogene Umsatzliste mit Netto- und MwSt-Aufschlüsselung, gruppiert nach MwSt-Satz. Stornierte Rechnungen bleiben für die lückenlose Nummernfolge sichtbar, sind aber nicht in den Summen enthalten.",
|
||||
|
||||
@@ -3450,7 +3450,15 @@
|
||||
"subnav": {
|
||||
"incomingInvoices": "Incoming invoices",
|
||||
"expenses": "Expenses",
|
||||
"taxReport": "Tax"
|
||||
"taxReport": "Tax",
|
||||
"export": "Treuhänder export",
|
||||
"chartOfAccounts": "Chart of accounts"
|
||||
},
|
||||
"taxTreatment": {
|
||||
"domestic": "Domestic (with VAT)",
|
||||
"reverse_charge_service": "Reverse charge (services)",
|
||||
"foreign_vat_non_reclaimable": "Foreign VAT (non-reclaimable)",
|
||||
"import_goods": "Import of goods"
|
||||
},
|
||||
"disposition": {
|
||||
"rebill": "Re-bill to client",
|
||||
@@ -3757,6 +3765,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"intro": "Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.",
|
||||
"settingsSaved": "Mappings saved.",
|
||||
"saveDefaults": "Save mappings",
|
||||
"accountType": {
|
||||
"asset": "Asset", "liability": "Liability", "equity": "Equity", "revenue": "Revenue", "expense": "Expense"
|
||||
},
|
||||
"vatDirection": {
|
||||
"input": "Input (Vorsteuer)", "output": "Output (Umsatzsteuer)"
|
||||
},
|
||||
"defaults": {
|
||||
"title": "Default & system accounts",
|
||||
"none": "— none —",
|
||||
"ledger_account_debitoren": "Accounts receivable (Debitoren)",
|
||||
"ledger_account_kreditoren": "Accounts payable (Kreditoren)",
|
||||
"ledger_account_bank": "Bank",
|
||||
"ledger_account_cash": "Cash",
|
||||
"ledger_account_default_revenue": "Default revenue account",
|
||||
"ledger_account_default_expense": "Default expense account",
|
||||
"ledger_account_mileage": "Mileage account",
|
||||
"ledger_account_per_diem": "Per-diem account",
|
||||
"ledger_account_rebilled_revenue": "Re-billed expenses (revenue)"
|
||||
},
|
||||
"vatMap": { "title": "VAT code by tax treatment (costs)" },
|
||||
"outputVatMap": { "title": "VAT code by revenue rate" },
|
||||
"categoryMap": { "title": "Expense category → account" },
|
||||
"accounts": { "title": "Chart of accounts" },
|
||||
"vatCodes": { "title": "VAT codes" },
|
||||
"account": {
|
||||
"addTitle": "Add account", "editTitle": "Edit account",
|
||||
"number": "Account number", "name": "Name", "type": "Type",
|
||||
"confirmDelete": "Delete this account?"
|
||||
},
|
||||
"vat": {
|
||||
"addTitle": "Add VAT code", "editTitle": "Edit VAT code",
|
||||
"code": "Code", "name": "Name", "rate": "Rate %", "direction": "Direction",
|
||||
"account": "VAT account", "noAccount": "— none —", "confirmDelete": "Delete this VAT code?"
|
||||
},
|
||||
"export": {
|
||||
"title": "Treuhänder export",
|
||||
"intro": "Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s accounting software.",
|
||||
"format": "Target tool",
|
||||
"format_generic": "Generic (all columns)",
|
||||
"format_banana": "Banana Accounting",
|
||||
"format_bexio": "bexio",
|
||||
"download": "Download CSV",
|
||||
"failed": "Export failed.",
|
||||
"disclaimer": "Accrual basis only (document dates) — payments/bank movements are not included. Account + VAT codes follow your Chart-of-accounts mapping. Always review the import with your Treuhänder before filing."
|
||||
}
|
||||
},
|
||||
"taxReport": {
|
||||
"title": "Tax report",
|
||||
"intro": "Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Cancelled invoices stay visible for audit-trail continuity but are excluded from totals.",
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* This data drives the export only — picpeak is not a double-entry ledger.
|
||||
*/
|
||||
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, 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,
|
||||
} 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',
|
||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||
];
|
||||
|
||||
// ── account modal ──────────────────────────────────────────────────────
|
||||
const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onDone: () => void }> = ({ account, onClose, onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!account;
|
||||
const [number, setNumber] = useState(account?.number ?? '');
|
||||
const [name, setName] = useState(account?.name ?? '');
|
||||
const [type, setType] = useState<AccountType>(account?.type ?? 'expense');
|
||||
const save = useMutation({
|
||||
mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }),
|
||||
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.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}</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><label className={labelCls}>{t('ledger.account.number', 'Account number')}</label><Input value={number} onChange={(e) => setNumber(e.target.value)} placeholder="6700" /></div>
|
||||
<div><label className={labelCls}>{t('ledger.account.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div><label className={labelCls}>{t('ledger.account.type', 'Type')}</label>
|
||||
<select value={type} onChange={(e) => setType(e.target.value as AccountType)} className={selectCls}>
|
||||
{ACCOUNT_TYPES.map((tp) => <option key={tp} value={tp}>{t(`ledger.accountType.${tp}`, tp)}</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 || !number || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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).
|
||||
const [settings, setSettings] = useState<LedgerSettings>({});
|
||||
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'] }); };
|
||||
|
||||
const delAccount = useMutation({
|
||||
mutationFn: (id: number) => ledgerService.deleteAccount(id),
|
||||
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'),
|
||||
});
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => ledgerService.updateSettings(settings),
|
||||
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 />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{t('ledger.intro', 'Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.')}</span>
|
||||
</p>
|
||||
|
||||
{/* Default + system accounts */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.defaults.title', 'Default & system accounts')}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{SETTING_ACCOUNT_KEYS.map((key) => (
|
||||
<div key={key}>
|
||||
<label className={labelCls}>{t(`ledger.defaults.${key}`, key)}</label>
|
||||
<select value={settings[key] as string ?? ''} onChange={(e) => setAcctSetting(key, e.target.value)} className={selectCls}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{accountOptions.map((a) => <option key={a.id} value={a.number}>{a.number} · {a.name}</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={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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category → account */}
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.categoryMap.title', 'Expense category → account')}</h2>
|
||||
<div className="space-y-2">
|
||||
{(mappings?.categories ?? []).map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3">
|
||||
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">{categoryLabel(c as any, t)}</span>
|
||||
<select value={c.ledger_account_id ?? ''} onChange={(e) => setCat.mutate({ id: c.id, accId: e.target.value ? Number(e.target.value) : null })} className={selectCls} style={{ maxWidth: 320 }}>
|
||||
<option value="">{t('ledger.defaults.none', '— none —')}</option>
|
||||
{accountOptions.filter((a) => a.type === 'expense').map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart of accounts */}
|
||||
<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.accounts.title', 'Chart of accounts')}</h2>
|
||||
<Button size="sm" onClick={() => setAccountModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.account.addTitle', 'Add account')}</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.account.number', 'No.')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.name', 'Name')}</th>
|
||||
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.type', 'Type')}</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">
|
||||
{(accounts ?? []).map((a) => (
|
||||
<tr key={a.id} className={a.active ? '' : 'opacity-50'}>
|
||||
<td className="py-1.5 pr-3 tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{a.number}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{a.name}</td>
|
||||
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.accountType.${a.type}`, a.type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setAccountModal({ account: a })} 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.account.confirmDelete', 'Delete this account?') as string)) delAccount.mutate(a.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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartOfAccountsPage;
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Accounting → Treuhänder export (Layer A).
|
||||
*
|
||||
* Picks a period + currency + target tool, then downloads the collective
|
||||
* journal (accrual Buchungssätze) as CSV for import into the Treuhänder's
|
||||
* double-entry software. Output is a guideline — disclaimer on the page.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FileSpreadsheet, Download, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, CardContent, LocalizedDateInput } from '../../../components/common';
|
||||
import { ledgerService, type ExportFormat } from '../../../services/ledger.service';
|
||||
|
||||
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 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
|
||||
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
|
||||
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||
const FORMATS: ExportFormat[] = ['generic', 'banana', 'bexio'];
|
||||
|
||||
function periodForPreset(preset: PeriodPreset, today = new Date()): { from: string; to: string } {
|
||||
const y = today.getFullYear();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` };
|
||||
if (preset === 'lastYear') return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` };
|
||||
const quarter = Math.floor(today.getMonth() / 3);
|
||||
if (preset === 'thisQuarter') {
|
||||
const sm = quarter * 3; const em = sm + 2;
|
||||
return { from: `${y}-${pad(sm + 1)}-01`, to: `${y}-${pad(em + 1)}-${pad(new Date(y, em + 1, 0).getDate())}` };
|
||||
}
|
||||
let qy = y; let q = quarter - 1; if (q < 0) { q = 3; qy = y - 1; }
|
||||
const sm = q * 3; const em = sm + 2;
|
||||
return { from: `${qy}-${pad(sm + 1)}-01`, to: `${qy}-${pad(em + 1)}-${pad(new Date(qy, em + 1, 0).getDate())}` };
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, filename: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.style.display = 'none';
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
}
|
||||
|
||||
export const LedgerExportPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [preset, setPreset] = useState<PeriodPreset>('thisYear');
|
||||
const initial = useMemo(() => periodForPreset('thisYear'), []);
|
||||
const [from, setFrom] = useState(initial.from);
|
||||
const [to, setTo] = useState(initial.to);
|
||||
const [currency, setCurrency] = useState('CHF');
|
||||
const [format, setFormat] = useState<ExportFormat>('generic');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onPreset = (next: PeriodPreset) => {
|
||||
setPreset(next);
|
||||
if (next !== 'custom') { const p = periodForPreset(next); setFrom(p.from); setTo(p.to); }
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url, filename } = await ledgerService.downloadExportUrl({ from, to, currency, format });
|
||||
triggerDownload(url, filename);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || e.message || t('ledger.export.failed', 'Export failed.'));
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center flex-shrink-0">
|
||||
<FileSpreadsheet className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.export.title', 'Treuhänder export')}</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">{t('ledger.export.intro', 'Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s accounting software.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-w-md">
|
||||
<div>
|
||||
<label className={labelCls}>{t('taxReport.filters.period', 'Period')}</label>
|
||||
<select value={preset} onChange={(e) => onPreset(e.target.value as PeriodPreset)} className={selectCls}>
|
||||
<option value="thisYear">{t('taxReport.filters.thisYear', 'This year')}</option>
|
||||
<option value="lastYear">{t('taxReport.filters.lastYear', 'Last year')}</option>
|
||||
<option value="thisQuarter">{t('taxReport.filters.thisQuarter', 'This quarter')}</option>
|
||||
<option value="lastQuarter">{t('taxReport.filters.lastQuarter', 'Last quarter')}</option>
|
||||
<option value="custom">{t('taxReport.filters.custom', 'Custom range')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className={labelCls}>{t('taxReport.filters.from', 'From')}</label><LocalizedDateInput value={from} onChange={(iso) => { setFrom(iso); setPreset('custom'); }} /></div>
|
||||
<div><label className={labelCls}>{t('taxReport.filters.to', 'To')}</label><LocalizedDateInput value={to} onChange={(iso) => { setTo(iso); setPreset('custom'); }} /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>{t('taxReport.filters.currency', 'Currency')}</label>
|
||||
<select value={currency} onChange={(e) => setCurrency(e.target.value)} className={selectCls}>
|
||||
<option value="CHF">CHF</option><option value="EUR">EUR</option><option value="USD">USD</option><option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('ledger.export.format', 'Target tool')}</label>
|
||||
<select value={format} onChange={(e) => setFormat(e.target.value as ExportFormat)} className={selectCls}>
|
||||
{FORMATS.map((f) => <option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button onClick={handleExport} disabled={busy || !from || !to} isLoading={busy} leftIcon={<Download className="w-4 h-4" />}>
|
||||
{t('ledger.export.download', 'Download CSV')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex items-start gap-2 mt-5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{t('ledger.export.disclaimer', 'Accrual basis only (document dates) — payments/bank movements are not included. Account + VAT codes follow your Chart-of-accounts mapping. Always review the import with your Treuhänder before filing.')}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LedgerExportPage;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin → Ledger (Accounting Layer A) API client. Hits /api/admin/ledger/*.
|
||||
*
|
||||
* Chart of accounts + VAT codes CRUD, category/settings mappings, and the
|
||||
* Treuhänder collective-journal export (generic / Banana / bexio).
|
||||
*/
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
|
||||
export type VatDirection = 'output' | 'input';
|
||||
export type ExportFormat = 'generic' | 'banana' | 'bexio';
|
||||
|
||||
export interface LedgerAccount {
|
||||
id: number;
|
||||
number: string;
|
||||
name: string;
|
||||
type: AccountType;
|
||||
is_seed: boolean;
|
||||
active: boolean;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface VatCode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
rate: number;
|
||||
direction: VatDirection;
|
||||
account_id: number | null;
|
||||
is_seed: boolean;
|
||||
active: boolean;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
export interface LedgerCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
ledger_account_id: number | null;
|
||||
}
|
||||
|
||||
export interface LedgerSettings {
|
||||
ledger_account_debitoren?: string;
|
||||
ledger_account_kreditoren?: string;
|
||||
ledger_account_bank?: string;
|
||||
ledger_account_cash?: string;
|
||||
ledger_account_default_revenue?: string;
|
||||
ledger_account_default_expense?: string;
|
||||
ledger_account_mileage?: string;
|
||||
ledger_account_per_diem?: string;
|
||||
ledger_account_rebilled_revenue?: string;
|
||||
ledger_vat_map?: Record<string, string>;
|
||||
ledger_output_vat_map?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LedgerMappings { categories: LedgerCategory[]; settings: LedgerSettings; }
|
||||
|
||||
export interface ExportParams { from: string; to: string; currency: string; format: ExportFormat; }
|
||||
|
||||
export const ledgerService = {
|
||||
// ── accounts ──
|
||||
async listAccounts(): Promise<LedgerAccount[]> { const { data } = await api.get('/admin/ledger/accounts'); return data.items; },
|
||||
async createAccount(payload: { number: string; name: string; type: AccountType }): Promise<LedgerAccount> {
|
||||
const { data } = await api.post('/admin/ledger/accounts', payload); return data.account;
|
||||
},
|
||||
async updateAccount(id: number, payload: Partial<{ number: string; name: string; type: AccountType; active: boolean }>): Promise<LedgerAccount> {
|
||||
const { data } = await api.patch(`/admin/ledger/accounts/${id}`, payload); return data.account;
|
||||
},
|
||||
async deleteAccount(id: number): Promise<void> { await api.delete(`/admin/ledger/accounts/${id}`); },
|
||||
|
||||
// ── VAT codes ──
|
||||
async listVatCodes(): Promise<VatCode[]> { const { data } = await api.get('/admin/ledger/vat-codes'); return data.items; },
|
||||
async createVatCode(payload: { code: string; name: string; rate: number; direction: VatDirection; accountId?: number | null }): Promise<VatCode> {
|
||||
const { data } = await api.post('/admin/ledger/vat-codes', payload); return data.vatCode;
|
||||
},
|
||||
async updateVatCode(id: number, payload: Partial<{ code: string; name: string; rate: number; direction: VatDirection; accountId: number | null; active: boolean }>): Promise<VatCode> {
|
||||
const { data } = await api.patch(`/admin/ledger/vat-codes/${id}`, payload); return data.vatCode;
|
||||
},
|
||||
async deleteVatCode(id: number): Promise<void> { await api.delete(`/admin/ledger/vat-codes/${id}`); },
|
||||
|
||||
// ── mappings ──
|
||||
async getMappings(): Promise<LedgerMappings> { const { data } = await api.get('/admin/ledger/mappings'); return data; },
|
||||
async setCategoryAccount(id: number, ledgerAccountId: number | null): Promise<LedgerCategory> {
|
||||
const { data } = await api.patch(`/admin/ledger/mappings/category/${id}`, { ledgerAccountId }); return data.category;
|
||||
},
|
||||
async updateSettings(patch: Partial<LedgerSettings>): Promise<{ updated: string[] }> {
|
||||
const { data } = await api.patch('/admin/ledger/mappings/settings', patch); return data;
|
||||
},
|
||||
|
||||
// ── export ──
|
||||
async downloadExportUrl(params: ExportParams): Promise<{ url: string; filename: string }> {
|
||||
const usp = new URLSearchParams({ from: params.from, to: params.to, currency: params.currency, format: params.format });
|
||||
const res = await api.get(`/admin/ledger/export?${usp.toString()}`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const filename = `journal_${params.from}_to_${params.to}_${params.currency}_${params.format}.csv`;
|
||||
return { url, filename };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user