Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts: # frontend/src/i18n/locales/de.json # frontend/src/i18n/locales/en.json
This commit is contained in:
@@ -8,9 +8,11 @@ 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';
|
||||
@@ -22,12 +24,19 @@ export const AccountingTab: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
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<number>(NaN);
|
||||
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||
const [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
|
||||
const [requireProof, setRequireProof] = useState(false);
|
||||
const [vatRegistered, setVatRegistered] = useState(false);
|
||||
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
|
||||
const [vatLabel, setVatLabel] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -36,20 +45,41 @@ export const AccountingTab: React.FC = () => {
|
||||
setRequireProof(data.accounting_require_proof);
|
||||
setVatRegistered(data.accounting_vat_registered);
|
||||
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
|
||||
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,
|
||||
}),
|
||||
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'),
|
||||
});
|
||||
|
||||
@@ -69,9 +99,14 @@ export const AccountingTab: React.FC = () => {
|
||||
<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>
|
||||
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Daily allowance (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>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.profileFields.hourlyRate', 'Default hourly rate')}</label>
|
||||
<DecimalInput value={hourlyMajor} onChange={setHourlyMajor} fractionDigits={2} 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', '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 })}</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" />
|
||||
@@ -85,7 +120,7 @@ export const AccountingTab: React.FC = () => {
|
||||
the tax report's VAT-payable). */}
|
||||
<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.vat.title', 'VAT registration & reclaim')}
|
||||
{t('settings.accounting.vat.title', 'VAT')}
|
||||
</h3>
|
||||
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<input type="checkbox" checked={vatRegistered} onChange={(e) => setVatRegistered(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
|
||||
@@ -113,6 +148,19 @@ export const AccountingTab: React.FC = () => {
|
||||
{t('settings.accounting.vat.reclaimCountriesHint', 'Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.vat.defaultOutputCode', 'Default VAT code for new invoices')}</label>
|
||||
<select value={defaultOutputVatCode} onChange={(e) => setDefaultOutputVatCode(e.target.value)} className={inputCls}>
|
||||
<option value="">{t('settings.accounting.vat.defaultOutputCodeNone', '— none (start at 0%) —')}</option>
|
||||
{outputVatCodes.map((c) => <option key={c.id} value={c.code}>{c.name} ({Number(c.rate).toFixed(1)}%)</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}</p>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent></Card>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -324,6 +324,13 @@ export const FeaturesTab: React.FC = () => {
|
||||
sidebarLabel={t('settings.features.accounting.sidebar', 'Accounting')}
|
||||
enabled={staged.accounting}
|
||||
onToggle={(next) => setFlag('accounting', next)}
|
||||
// Invoices force-enable Accounting (invoice VAT settings live here),
|
||||
// so the master can't be turned off while Bills is on.
|
||||
disabled={staged.bills}
|
||||
lockedReason={staged.bills ? t(
|
||||
'settings.features.accounting.requiredByBills',
|
||||
'On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section.',
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
|
||||
Reference in New Issue
Block a user