feat(accounting): consolidate VAT/financial config into Settings → Accounting

- Remove the orphaned "Default VAT rate %" from Business profile; the rates
  are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
  is now code-only — options are exactly the Accounting output codes, no
  free-text custom rate. Off-list legacy values on existing invoices are
  preserved as a read-only "(not configured)" option so issued documents
  aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
  AccountingProfileFields card; storage stays on business_profile, own save).
  Wire vat_label onto the PDF VAT-line label via the issuer block (covers
  invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
  dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
  preserved). Add a moved-note callout. Strip the moved fields from the
  Business-profile save so it can't clobber an Accounting-tab edit.
This commit is contained in:
Luca
2026-06-18 15:10:44 +02:00
parent 315d15afd4
commit dc7b87bb87
7 changed files with 183 additions and 58 deletions
@@ -0,0 +1,82 @@
/**
* 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<number>(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 <Loading />;
return (
<Card><CardContent className="p-5 space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('settings.accounting.profileFields.title', 'VAT label & hourly rate')}
</h3>
<div>
<label className={labelCls}>{t('settings.accounting.profileFields.vatLabel', 'VAT label (e.g. MwSt., VAT)')}</label>
<Input value={vatLabel} onChange={(e) => setVatLabel(e.target.value)} className={inputCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}</p>
</div>
<div>
<label className={labelCls}>{t('settings.accounting.profileFields.hourlyRate', 'Default hourly rate')}</label>
<DecimalInput
value={hourlyMajor}
fractionDigits={2}
onChange={setHourlyMajor}
className={inputCls}
placeholder={t('settings.accounting.profileFields.hourlyRatePlaceholder', 'e.g. 120.00') as string}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{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 })}
</p>
</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>
</CardContent></Card>
);
};
export default AccountingProfileFields;
+22 -24
View File
@@ -1,17 +1,23 @@
/**
* VAT-rate picker for the invoice/quote editors. A dropdown of the configured
* OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape
* hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code
* string (snapshotted on the document for the accounting export); "Other" emits
* the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint,
* so it works even when the accounting feature is off.
* VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options
* are the configured OUTPUT VAT codes (Settings → Accounting) — there is no
* free-text custom rate; to use a different rate, add a VAT code in Accounting.
* Controlled by `(rate, code)`: selecting a code emits its rate + code string
* (snapshotted on the document for the accounting export). Reads the un-gated
* /admin/vat-codes endpoint so it works even when the accounting feature is off.
*
* Legacy preservation: when editing a document whose stored rate/code isn't an
* accounting code anymore (an old invoice, or a deleted code), that value is
* shown as a read-only "(not configured)" option so it stays selected and is
* never silently changed — issued invoices are immutable. The admin can still
* switch it to a current code.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service';
const CUSTOM = '__custom__';
const LEGACY = '__legacy__';
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';
@@ -32,11 +38,11 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
});
// Selected option: prefer the snapshotted code; else a code whose rate matches
// (legacy rows / no code stored); else "custom".
// (legacy rows / no code stored); else the document's value is "off-list".
const matched: VatCodeOption | undefined =
(code ? codes.find((c) => c.code === code) : undefined)
|| (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const isCustom = !matched;
const showLegacy = !matched;
return (
<div>
@@ -46,32 +52,24 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
<select
className={selectCls}
disabled={disabled}
value={isCustom ? CUSTOM : String(matched!.id)}
value={matched ? String(matched.id) : LEGACY}
onChange={(e) => {
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
if (e.target.value === LEGACY) { onChange(rate, code); return; } // keep the legacy value
const c = codes.find((x) => String(x.id) === e.target.value);
if (c) onChange(Number(c.rate), c.code);
}}
>
{showLegacy && (
<option value={LEGACY}>
{t('vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
</option>
)}
{codes.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name} ({Number(c.rate).toFixed(1)}%)
</option>
))}
<option value={CUSTOM}>{t('vat.customRate', 'Other (custom rate)')}</option>
</select>
{isCustom && (
<input
type="number"
step="0.1"
min="0"
className={`${selectCls} mt-2`}
disabled={disabled}
value={rate}
placeholder={t('vat.ratePercent', 'VAT rate %') as string}
onChange={(e) => onChange(Number(e.target.value) || 0, null)}
/>
)}
</div>
);
};