diff --git a/frontend/src/components/admin/AccountingProfileFields.tsx b/frontend/src/components/admin/AccountingProfileFields.tsx deleted file mode 100644 index 943ffd0f..00000000 --- a/frontend/src/components/admin/AccountingProfileFields.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Business-profile financial fields surfaced on the Accounting tab: the - * **VAT label** (printed on invoice/quote PDFs) and the **default hourly rate** - * (install-wide fallback for hours logging). The values still live on - * `business_profile`; this is a self-contained card with its own save (mirrors - * VatCodesManager) so it can't clobber the rest of the business profile, and it - * shares the `business-profile` query cache so both pages stay in sync. - */ -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, Input, Loading } from '../common'; -import { DecimalInput } from '../common/DecimalInput'; -import { businessProfileService } from '../../services/businessProfile.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 AccountingProfileFields: React.FC = () => { - const { t } = useTranslation(); - const qc = useQueryClient(); - const { data, isLoading } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() }); - - const [vatLabel, setVatLabel] = useState(''); - const [hourlyMajor, setHourlyMajor] = useState(NaN); - const currency = data?.profile?.defaultCurrency || 'CHF'; - - useEffect(() => { - if (data?.profile) { - setVatLabel(data.profile.vatLabel || ''); - setHourlyMajor(data.profile.defaultHourlyRateMinor != null ? data.profile.defaultHourlyRateMinor / 100 : NaN); - } - }, [data]); - - const save = useMutation({ - mutationFn: () => businessProfileService.update({ - vatLabel: vatLabel || '', - defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null, - }), - onSuccess: () => { - toast.success(t('settings.accounting.profileFields.savedToast', 'Saved.')); - qc.invalidateQueries({ queryKey: ['business-profile'] }); - }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), - }); - - if (isLoading) return ; - - return ( - -

- {t('settings.accounting.profileFields.title', 'VAT label & hourly rate')} -

- -
- - setVatLabel(e.target.value)} className={inputCls} /> -

{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}

-
- -
- - -

- {t('settings.accounting.profileFields.hourlyRateHint', 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })} -

-
- - -
- ); -}; - -export default AccountingProfileFields; diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx index dc2b588a..fafe7922 100644 --- a/frontend/src/features/settings/tabs/AccountingTab.tsx +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -8,14 +8,14 @@ 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 { Button, Card, CardContent, Input, Loading } from '../../../components/common'; import { DecimalInput } from '../../../components/common/DecimalInput'; import { accountingService } from '../../../services/accounting.service'; +import { businessProfileService } from '../../../services/businessProfile.service'; import { vatCodesService } from '../../../services/vatCodes.service'; import { sortedCountryOptions } from '../../../constants/countries'; import { VatCodesManager } from '../../../components/admin/VatCodesManager'; import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager'; -import { AccountingProfileFields } from '../../../components/admin/AccountingProfileFields'; 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'; @@ -25,13 +25,18 @@ export const AccountingTab: React.FC = () => { const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() }); + // VAT label + default hourly rate live on business_profile, surfaced here so + // all financial/VAT config sits in one tab (and one Save). + const { data: profileSnap } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() }); const [kmMajor, setKmMajor] = useState(NaN); const [perDiemMajor, setPerDiemMajor] = useState(NaN); + const [hourlyMajor, setHourlyMajor] = useState(NaN); const [requireProof, setRequireProof] = useState(false); const [vatRegistered, setVatRegistered] = useState(false); const [reclaimCountries, setReclaimCountries] = useState([]); const [defaultOutputVatCode, setDefaultOutputVatCode] = useState(''); + const [vatLabel, setVatLabel] = useState(''); useEffect(() => { if (data) { @@ -43,19 +48,38 @@ export const AccountingTab: React.FC = () => { setDefaultOutputVatCode(data.accounting_default_output_vat_code || ''); } }, [data]); + useEffect(() => { + if (profileSnap?.profile) { + setVatLabel(profileSnap.profile.vatLabel || ''); + setHourlyMajor(profileSnap.profile.defaultHourlyRateMinor != null ? profileSnap.profile.defaultHourlyRateMinor / 100 : NaN); + } + }, [profileSnap]); const countries = sortedCountryOptions(i18n.language); + const currency = profileSnap?.profile?.defaultCurrency || 'CHF'; + // One Save persists BOTH the app_settings (rates/VAT/proof) and the two + // business_profile fields (VAT label + hourly rate). 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, - accounting_vat_registered: vatRegistered, - accounting_vat_reclaim_countries: reclaimCountries, - accounting_default_output_vat_code: defaultOutputVatCode, - }), - onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); }, + mutationFn: async () => { + await 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, + accounting_vat_registered: vatRegistered, + accounting_vat_reclaim_countries: reclaimCountries, + accounting_default_output_vat_code: defaultOutputVatCode, + }); + await businessProfileService.update({ + vatLabel: vatLabel || '', + defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null, + }); + }, + onSuccess: () => { + toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); + qc.invalidateQueries({ queryKey: ['accounting-settings'] }); + qc.invalidateQueries({ queryKey: ['business-profile'] }); + }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), }); @@ -75,9 +99,14 @@ export const AccountingTab: React.FC = () => {

{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.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}

+
+
+ + +

{t('settings.accounting.profileFields.hourlyRateHint', 'Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}