feat(accounting): Accounting settings tab (km / per-diem rate, require-proof)
New Settings -> Accounting tab (gated by the accounting flag) to edit the km rate, per-diem rate and the "require proof for expense" toggle (reads GET / writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor units; carries the "verify with your Treuhaender" disclaimer. Wired into SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel. i18n: settings.accounting.* (EN + DE, DE native). Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
This commit is contained in:
@@ -18,3 +18,4 @@ export { SEOTab } from './tabs/SEOTab';
|
|||||||
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
||||||
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||||
|
export { AccountingTab } from './tabs/AccountingTab';
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Accounting settings tab — rates used by internal expenses + the proof
|
||||||
|
* requirement. Rates are CHF; stored as integer minor units. Tax/legal
|
||||||
|
* guidance only — verify with your Treuhaender.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { Button, Card, CardContent, Loading } from '../../../components/common';
|
||||||
|
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||||
|
import { accountingService } from '../../../services/accounting.service';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const AccountingTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||||
|
|
||||||
|
const [kmMajor, setKmMajor] = useState<number>(NaN);
|
||||||
|
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||||
|
const [requireProof, setRequireProof] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setKmMajor(data.accounting_km_rate_minor / 100);
|
||||||
|
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
|
||||||
|
setRequireProof(data.accounting_require_proof);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => accountingService.updateSettings({
|
||||||
|
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
|
||||||
|
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
||||||
|
accounting_require_proof: requireProof,
|
||||||
|
}),
|
||||||
|
onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); },
|
||||||
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('settings.accounting.title', 'Accounting')}</h2>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card><CardContent className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('settings.accounting.kmRate', 'Mileage rate (CHF / km)')}</label>
|
||||||
|
<DecimalInput value={kmMajor} onChange={setKmMajor} fractionDigits={2} className={inputCls} />
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Per-diem rate (CHF / day)')}</label>
|
||||||
|
<DecimalInput value={perDiemMajor} onChange={setPerDiemMajor} fractionDigits={2} className={inputCls} />
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||||
|
<input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" />
|
||||||
|
{t('settings.accounting.requireProof', 'Require a proof file on every expense')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountingTab;
|
||||||
@@ -1705,6 +1705,17 @@
|
|||||||
},
|
},
|
||||||
"crm": {
|
"crm": {
|
||||||
"title": "CRM-Verhalten"
|
"title": "CRM-Verhalten"
|
||||||
|
},
|
||||||
|
"accounting": {
|
||||||
|
"title": "Buchhaltung",
|
||||||
|
"subtitle": "Standardsätze für interne Aufwände und die Belegpflicht.",
|
||||||
|
"kmRate": "Kilometersatz (CHF / km)",
|
||||||
|
"kmRateHint": "Standard für Kilometer-Aufwände; pro Eintrag überschreibbar.",
|
||||||
|
"perDiemRate": "Spesenpauschale (CHF / Tag)",
|
||||||
|
"perDiemRateHint": "Standard für Pauschal-Aufwände; pro Eintrag überschreibbar.",
|
||||||
|
"requireProof": "Beleg für jeden Aufwand verlangen",
|
||||||
|
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
|
||||||
|
"savedToast": "Buchhaltungseinstellungen gespeichert."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
|
|||||||
@@ -1263,6 +1263,17 @@
|
|||||||
},
|
},
|
||||||
"crm": {
|
"crm": {
|
||||||
"title": "CRM behaviour"
|
"title": "CRM behaviour"
|
||||||
|
},
|
||||||
|
"accounting": {
|
||||||
|
"title": "Accounting",
|
||||||
|
"subtitle": "Default rates for internal expenses and the proof requirement.",
|
||||||
|
"kmRate": "Mileage rate (CHF / km)",
|
||||||
|
"kmRateHint": "Default applied to mileage expenses; overridable per entry.",
|
||||||
|
"perDiemRate": "Per-diem rate (CHF / day)",
|
||||||
|
"perDiemRateHint": "Default applied to per-diem expenses; overridable per entry.",
|
||||||
|
"requireProof": "Require a proof file on every expense",
|
||||||
|
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
|
||||||
|
"savedToast": "Accounting settings saved."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
ThumbnailsTab,
|
ThumbnailsTab,
|
||||||
ApiTokensTab,
|
ApiTokensTab,
|
||||||
WebhooksTab,
|
WebhooksTab,
|
||||||
|
AccountingTab,
|
||||||
} from '../../features/settings';
|
} from '../../features/settings';
|
||||||
import { EmailConfigPage } from './EmailConfigPage';
|
import { EmailConfigPage } from './EmailConfigPage';
|
||||||
import { BrandingPage } from './BrandingPage';
|
import { BrandingPage } from './BrandingPage';
|
||||||
@@ -52,7 +53,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
|
|||||||
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
||||||
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
||||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||||
import { Briefcase, Receipt, ScrollText, Mail } from 'lucide-react';
|
import { Briefcase, Receipt, ScrollText, Mail, Landmark } from 'lucide-react';
|
||||||
|
|
||||||
// Tab keys driving the inner-nav. Must include every key used in
|
// Tab keys driving the inner-nav. Must include every key used in
|
||||||
// `navGroups` below and in the switch at the bottom of the component.
|
// `navGroups` below and in the switch at the bottom of the component.
|
||||||
@@ -81,7 +82,8 @@ type TabType =
|
|||||||
| 'businessProfile'
|
| 'businessProfile'
|
||||||
| 'crm'
|
| 'crm'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'reminderTemplates';
|
| 'reminderTemplates'
|
||||||
|
| 'accounting';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
key: TabType;
|
key: TabType;
|
||||||
@@ -101,7 +103,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
|||||||
'security', 'imageSecurity', 'seo',
|
'security', 'imageSecurity', 'seo',
|
||||||
'apiTokens', 'webhooks',
|
'apiTokens', 'webhooks',
|
||||||
'status', 'analytics', 'backup',
|
'status', 'analytics', 'backup',
|
||||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates',
|
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isValidTab(value: string | null): value is TabType {
|
function isValidTab(value: string | null): value is TabType {
|
||||||
@@ -251,6 +253,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
...(flags.reminderEmails
|
...(flags.reminderEmails
|
||||||
? [{ key: 'reminderTemplates' as const, label: t('settings.reminderTemplates.title', 'Reminder emails'), icon: Mail }]
|
? [{ key: 'reminderTemplates' as const, label: t('settings.reminderTemplates.title', 'Reminder emails'), icon: Mail }]
|
||||||
: []),
|
: []),
|
||||||
|
...(flags.accounting
|
||||||
|
? [{ key: 'accounting' as const, label: t('settings.accounting.title', 'Accounting'), icon: Landmark }]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -414,6 +419,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'crm' && <CrmSettingsPage />}
|
{activeTab === 'crm' && <CrmSettingsPage />}
|
||||||
{activeTab === 'contracts' && <BlockLibraryPage />}
|
{activeTab === 'contracts' && <BlockLibraryPage />}
|
||||||
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
||||||
|
{activeTab === 'accounting' && <AccountingTab />}
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<StatusTab
|
<StatusTab
|
||||||
|
|||||||
Reference in New Issue
Block a user