diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 9b5b9d25..d6a8c40d 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -18,3 +18,4 @@ export { SEOTab } from './tabs/SEOTab'; export { ThumbnailsTab } from './tabs/ThumbnailsTab'; export { ApiTokensTab } from './tabs/ApiTokensTab'; export { WebhooksTab } from './tabs/WebhooksTab'; +export { AccountingTab } from './tabs/AccountingTab'; diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx new file mode 100644 index 00000000..dfe653e9 --- /dev/null +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -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(NaN); + const [perDiemMajor, setPerDiemMajor] = useState(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 ; + + return ( +
+
+

{t('settings.accounting.title', 'Accounting')}

+

{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}

+
+ + +
+ + +

{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}

+
+
+ + +

{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}

+
+ +

{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}

+
+ +
+ +
+
+ ); +}; + +export default AccountingTab; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 031a72c6..0709e222 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1705,6 +1705,17 @@ }, "crm": { "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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index df74dd80..fe45860b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1263,6 +1263,17 @@ }, "crm": { "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": { diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 6f69d187..7fb18180 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -40,6 +40,7 @@ import { ThumbnailsTab, ApiTokensTab, WebhooksTab, + AccountingTab, } from '../../features/settings'; import { EmailConfigPage } from './EmailConfigPage'; import { BrandingPage } from './BrandingPage'; @@ -52,7 +53,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage'; import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage'; import { BlockLibraryPage } from './contracts/BlockLibraryPage'; 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 // `navGroups` below and in the switch at the bottom of the component. @@ -81,7 +82,8 @@ type TabType = | 'businessProfile' | 'crm' | 'contracts' - | 'reminderTemplates'; + | 'reminderTemplates' + | 'accounting'; interface NavItem { key: TabType; @@ -101,7 +103,7 @@ const ALL_TAB_KEYS: TabType[] = [ 'security', 'imageSecurity', 'seo', 'apiTokens', 'webhooks', 'status', 'analytics', 'backup', - 'businessProfile', 'crm', 'contracts', 'reminderTemplates', + 'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', ]; function isValidTab(value: string | null): value is TabType { @@ -251,6 +253,9 @@ export const SettingsPage: React.FC = () => { ...(flags.reminderEmails ? [{ 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' && } {activeTab === 'contracts' && } {activeTab === 'reminderTemplates' && } + {activeTab === 'accounting' && } {activeTab === 'status' && (