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:
Paul Nothaft
2026-06-18 21:28:18 +02:00
34 changed files with 1459 additions and 293 deletions
@@ -251,7 +251,12 @@ export const LineItemsTable: React.FC<Props> = ({
// never roll directly into net — they only feed their parent's
// auto-resolved line total.
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
const vatAmount = Math.round(subtotal * vatRate) / 100;
// subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents:
// round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing,
// which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend
// computeTotals + the PDF were always correct; only this live editor preview
// was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT.
const vatAmount = Math.round(subtotal * vatRate * 100) / 100;
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
// Display numbering: top-level items get 1, 2, 3...; sub-items
+28 -25
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,16 @@ 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) — BUT only when that rate is unambiguous. If
// two configured codes share the rate (e.g. two 8.1% codes), rate-matching
// could silently swap one for the other on the next save, so fall through to
// the legacy "(not configured)" option and make the admin pick explicitly
// (PR #636 review #4).
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;
|| (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1
? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const showLegacy = !matched;
return (
<div>
@@ -46,32 +57,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('ledger.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>
);
};
+28
View File
@@ -0,0 +1,28 @@
/**
* Currency codes for the Business-profile "Default currency" dropdown.
* CH/LI-first ordering (picpeak's primary scope), then the common EUR/USD/GBP
* and a broad set of other ISO 4217 codes. Stored value is the bare 3-letter
* code (e.g. "CHF").
*/
export const CURRENCY_CODES: string[] = [
'CHF', 'EUR', 'USD', 'GBP',
'AUD', 'CAD', 'CNY', 'CZK', 'DKK', 'HKD', 'HUF', 'ILS', 'INR', 'JPY',
'NOK', 'NZD', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR',
];
/**
* Normalise a stored/typed currency value to a known code. Upper-cases + trims
* (so an old free-text "chf" resolves to "CHF"). Returns the matched code, or
* the cleaned input if it isn't in the known list (caller preserves it as an
* extra option so nothing is lost), or '' for empty input.
*/
export function normalizeCurrency(value: string | null | undefined): string {
return (value || '').trim().toUpperCase();
}
/** Build the option list, prepending an unknown-but-set value so it's preserved. */
export function currencyOptions(current: string | null | undefined): string[] {
const cur = normalizeCurrency(current);
if (cur && !CURRENCY_CODES.includes(cur)) return [cur, ...CURRENCY_CODES];
return CURRENCY_CODES;
}
@@ -91,6 +91,10 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
// Invoices (Bills) force-enable the Accounting master — invoice VAT config +
// hourly rate live under Settings → Accounting. Before the accounting→children
// rule so sub-features keep their own state.
if (out.bills === true) out.accounting = true;
// Accounting sub-features require the Accounting master. Tax export is
// independent of Bills now — it relocated permanently into Accounting.
if (out.accounting === false) {
@@ -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
+48 -17
View File
@@ -1562,7 +1562,7 @@
"statusRevoked": "Widerrufen",
"statusExpired": "Abgelaufen",
"statusActive": "Aktiv",
"confirmRevoke": "Diesen Token wirklich widerrufen?",
"confirmRevoke": "\"{{name}}\" widerrufen? Bestehende Integrationen, die dieses Token nutzen, erhalten ab sofort 401.",
"revoke": "Widerrufen",
"empty": "Noch keine Tokens. Generieren Sie oben einen, um zu beginnen.",
"preview": "Vorschau"
@@ -1705,7 +1705,8 @@
"accounting": {
"title": "Buchhaltung",
"description": "Ein eigener Buchhaltungsbereich, getrennt vom CRM. Hier aktivieren und dann die Unterfunktionen unten einschalten (Steuerexport, Eingangsrechnungen). MwSt-/Steuerbehandlung dient nur als Orientierung — vor dem Verlassen darauf mit Ihrem Treuhänder prüfen.",
"sidebar": "Buchhaltung"
"sidebar": "Buchhaltung",
"requiredByBills": "Automatisch an, weil Rechnungen aktiviert ist — die MwSt-Einstellungen der Rechnungen liegen im Buchhaltungsbereich."
},
"incomingInvoices": {
"title": "Eingangsrechnungen",
@@ -1776,21 +1777,31 @@
},
"accounting": {
"title": "Buchhaltung",
"subtitle": "Standardsätze für interne Aufwände und die Belegpflicht.",
"subtitle": "Standardsätze, MwSt-Einstellungen 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.",
"perDiemRateHint": "Eine Tagespauschale, die als Aufwand gebucht wird (kein Kunden-Verrechnungssatz); pro Eintrag überschreibbar.",
"requireProof": "Beleg für jeden Aufwand verlangen",
"vat": {
"title": "MwSt-Registrierung & Vorsteuerabzug",
"title": "MwSt.",
"registered": "MwSt-pflichtig (Umsatzsteuer berechnen + Vorsteuer abziehen)",
"registeredHint": "Aus = Kleinunternehmen / unter der Schwelle: keine MwSt berechnet, Vorsteuer ist Aufwand (nicht abziehbar).",
"reclaimCountries": "Länder mit abziehbarer Vorsteuer",
"reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl."
"reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl.",
"defaultOutputCode": "Standard-MwSt-Code für neue Rechnungen",
"defaultOutputCodeNone": "— keiner (bei 0% beginnen) —",
"defaultOutputCodeHint": "Neue Rechnungen und Angebote starten mit diesem MwSt-Code. Bestehende Dokumente bleiben unverändert."
},
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
"savedToast": "Buchhaltungseinstellungen gespeichert."
"savedToast": "Buchhaltungseinstellungen gespeichert.",
"profileFields": {
"vatLabel": "MwSt-Bezeichnung (z. B. MwSt., VAT)",
"vatLabelHint": "Wird als Bezeichnung der MwSt-Zeile auf Rechnungs-/Angebots-PDFs gedruckt. Leer lassen, um die Standardbezeichnung der Dokumentsprache zu verwenden.",
"hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00",
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
}
}
},
"branding": {
@@ -3695,7 +3706,14 @@
"durchlaufend": "Durchlaufender Posten",
"eigener_aufwand": "Eigener Aufwand",
"duplikat": "Duplikat",
"abgelehnt": "Abgelehnt"
"abgelehnt": "Abgelehnt",
"help": {
"rebill": "Eigene Lieferantenkosten, die du an einen Kunden weiterverrechnest — meist mit Zuschlag. Wird als Kosten und als weiterverrechneter Ertrag gebucht.",
"durchlaufend": "Ein Betrag, den du nur im Namen des Kunden vorstreckst und exakt durchreichst — kein Zuschlag, MwSt-neutral (durchlaufender Posten). Kunde zuordnen, um ihn zum Selbstkostenpreis weiterzuverrechnen.",
"eigener_aufwand": "Eigene Kosten, die nicht weiterverrechnet werden. Kategorie wählen, damit der Posten richtig in der Erfolgsrechnung landet.",
"duplikat": "Duplikat einer bereits erfassten Rechnung — wird nicht verbucht.",
"abgelehnt": "Dokument ablehnen — wird nicht verbucht."
}
},
"markup": {
"none": "Keiner / aus Vertrag",
@@ -3713,6 +3731,7 @@
"saveCategorize": "Speichern",
"saveCategorizePay": "Speichern & als bezahlt markieren",
"categorize": "Kategorisieren",
"recategorize": "Neu kategorisieren",
"view": "Ansehen",
"empty": "Noch keine Dokumente — oben eines erfassen.",
"untitled": "Unbenanntes Dokument",
@@ -3744,7 +3763,13 @@
"eventId": "Event-ID (optional)",
"markup": "Zuschlag",
"reference": "Zahlungsreferenz",
"referenceHint": "QR-/ESR-Referenz oder Mitteilung"
"referenceHint": "QR-/ESR-Referenz oder Mitteilung",
"note": "Notiz",
"noteHint": "Interne Notiz zu dieser Rechnung (optional)",
"passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen.",
"supplierCountry": "Lieferantenland",
"supplierCountryNone": "— unbekannt —",
"supplierCountryHint": "Setzt die Steuerbehandlung automatisch: ausserhalb deiner Vorsteuer-Länder → ausländische MwSt (nicht abziehbar)."
}
},
"expenseStatus": {
@@ -3765,7 +3790,8 @@
"label": "Buchen auf",
"company": "Firma",
"event": "Event",
"eventId": "Event-ID"
"eventId": "Event-ID",
"inboundHint": "Auf welches Event diese Kosten in Auswertungen & Steuerexport entfallen (Firma = allgemeiner Aufwand). Unabhängig davon, an wen du weiterverrechnest."
},
"incoming": {
"triageTitle": "Eingangsrechnung kategorisieren",
@@ -3777,7 +3803,15 @@
"paid": "Bezahlt",
"paidToast": "Als bezahlt markiert.",
"categorizedToast": "Kategorisiert.",
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert.",
"pendingRebill": "Weiterverrechnung offen",
"pendingTitle": "Offene Weiterverrechnungen",
"pendingBody": "Kategorisierte Rechnungen, die auf die Weiterverrechnung warten. Posten eines Kunden zu einer Rechnung bündeln.",
"pendingCount": "{{count}} Posten",
"pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
},
"expense": {
"kind": "Art",
@@ -4135,7 +4169,8 @@
"direction": "Richtung",
"account": "MWST-Konto",
"noAccount": "— keines —",
"confirmDelete": "Diesen MWST-Code löschen?"
"confirmDelete": "Diesen MWST-Code löschen?",
"legacyRate": "{{rate}}% (nicht konfiguriert)"
},
"export": {
"title": "Treuhänder-Export",
@@ -4507,6 +4542,7 @@
},
"businessProfile": {
"savedToast": "Geschäftsprofil gespeichert.",
"movedToAccounting": "MwSt-Satz, MwSt-Bezeichnung und Standard-Stundensatz befinden sich jetzt unter Einstellungen → Buchhaltung.",
"title": "Geschäftsprofil",
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
"businessHours": {
@@ -4553,11 +4589,6 @@
"defaultCurrency": "Standardwährung",
"defaultLocale": "Standardsprache",
"timezone": "Zeitzone (IANA)",
"vatLabel": "MwSt-Bezeichnung",
"vatRateDefault": "Standard-MwSt-Satz %",
"defaultHourlyRate": "Standard-Stundensatz",
"defaultHourlyRatePlaceholder": "z. B. 120.00",
"defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
"defaultQrFormat": "Standard-QR-Format",
"footerLine": "Fusszeile"
},
+50 -19
View File
@@ -1120,7 +1120,7 @@
"statusRevoked": "Revoked",
"statusExpired": "Expired",
"statusActive": "Active",
"confirmRevoke": "Do you want to revoke this token? This action cannot be undone.",
"confirmRevoke": "Revoke \"{{name}}\"? Existing integrations using this token will start getting 401.",
"revoke": "Revoke",
"empty": "No tokens yet. Generate one above to get started.",
"preview": "Preview"
@@ -1263,7 +1263,8 @@
"accounting": {
"title": "Accounting",
"description": "A dedicated Accounting area, separate from CRM. Turn this on, then enable the sub-features below (Tax export, Incoming invoices). VAT / tax treatment is guidance only — verify with your Treuhänder before relying on it.",
"sidebar": "Accounting"
"sidebar": "Accounting",
"requiredByBills": "On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section."
},
"incomingInvoices": {
"title": "Incoming invoices",
@@ -1334,21 +1335,31 @@
},
"accounting": {
"title": "Accounting",
"subtitle": "Default rates for internal expenses and the proof requirement.",
"subtitle": "Default rates, VAT settings 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.",
"perDiemRate": "Daily allowance (CHF / day)",
"perDiemRateHint": "A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.",
"requireProof": "Require a proof file on every expense",
"vat": {
"title": "VAT registration & reclaim",
"title": "VAT",
"registered": "VAT-registered (charge output VAT + reclaim input VAT)",
"registeredHint": "Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).",
"reclaimCountries": "Countries where input VAT is reclaimable",
"reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select."
"reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.",
"defaultOutputCode": "Default VAT code for new invoices",
"defaultOutputCodeNone": "— none (start at 0%) —",
"defaultOutputCodeHint": "New invoices and quotes start with this VAT code selected. Existing documents are unaffected."
},
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
"savedToast": "Accounting settings saved."
"savedToast": "Accounting settings saved.",
"profileFields": {
"vatLabel": "VAT label (e.g. MwSt., VAT)",
"vatLabelHint": "Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.",
"hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00",
"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."
}
}
},
"analytics": {
@@ -3695,7 +3706,14 @@
"durchlaufend": "Pass-through",
"eigener_aufwand": "Company expense",
"duplikat": "Duplicate",
"abgelehnt": "Declined"
"abgelehnt": "Declined",
"help": {
"rebill": "Your own supplier cost that you invoice on to a client — usually with a markup. Booked as both a cost and re-billed revenue.",
"durchlaufend": "An amount you only front on behalf of a client and pass through at the exact figure — no markup, VAT-neutral (durchlaufender Posten). Attach the client to re-bill it at cost.",
"eigener_aufwand": "Your own cost, not re-billed to anyone. Pick a category so it lands in the right place in your P&L.",
"duplikat": "A duplicate of an invoice you already captured — excluded from the books.",
"abgelehnt": "Decline this document — excluded from the books."
}
},
"markup": {
"none": "None / from contract",
@@ -3713,6 +3731,7 @@
"saveCategorize": "Save",
"saveCategorizePay": "Save & mark paid",
"categorize": "Categorize",
"recategorize": "Re-categorize",
"view": "View",
"empty": "No documents yet — capture one above.",
"untitled": "Untitled document",
@@ -3744,7 +3763,13 @@
"eventId": "Event ID (optional)",
"markup": "Markup",
"reference": "Payment reference",
"referenceHint": "QR / ESR reference or message"
"referenceHint": "QR / ESR reference or message",
"note": "Note",
"noteHint": "Internal note for this invoice (optional)",
"passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.",
"supplierCountry": "Supplier country",
"supplierCountryNone": "— unknown —",
"supplierCountryHint": "Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable)."
}
},
"expenseStatus": {
@@ -3765,7 +3790,8 @@
"label": "Book to",
"company": "Company",
"event": "Event",
"eventId": "Event ID"
"eventId": "Event ID",
"inboundHint": "Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to."
},
"incoming": {
"triageTitle": "Categorize incoming invoice",
@@ -3777,7 +3803,15 @@
"paid": "Paid",
"paidToast": "Marked as paid.",
"categorizedToast": "Categorized.",
"categorizedPaidToast": "Categorized and marked paid."
"categorizedPaidToast": "Categorized and marked paid.",
"pendingRebill": "Pending re-bill",
"pendingTitle": "Pending re-bills",
"pendingBody": "Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.",
"pendingCount": "{{count}} item",
"pendingCount_other": "{{count}} items",
"billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
},
"expense": {
"kind": "Type",
@@ -3796,7 +3830,7 @@
"expenseKind": {
"amount": "Amount",
"mileage": "Mileage (km)",
"per_diem": "Per-diem"
"per_diem": "Daily allowance"
},
"category": {
"infrastructure": "Infrastructure & rent",
@@ -4135,7 +4169,8 @@
"direction": "Direction",
"account": "VAT account",
"noAccount": "— none —",
"confirmDelete": "Delete this VAT code?"
"confirmDelete": "Delete this VAT code?",
"legacyRate": "{{rate}}% (not configured)"
},
"export": {
"title": "Treuhänder export",
@@ -4505,6 +4540,7 @@
},
"businessProfile": {
"savedToast": "Business profile saved.",
"movedToAccounting": "The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.",
"title": "Business profile",
"subtitle": "Issuer block shown on every quote and invoice PDF.",
"businessHours": {
@@ -4551,11 +4587,6 @@
"defaultCurrency": "Default currency",
"defaultLocale": "Default locale",
"timezone": "Timezone (IANA)",
"vatLabel": "VAT label (e.g. MwSt., VAT)",
"vatRateDefault": "Default VAT rate %",
"defaultHourlyRate": "Default hourly rate",
"defaultHourlyRatePlaceholder": "e.g. 120.00",
"defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
"defaultQrFormat": "Default invoice QR",
"footerLine": "PDF footer line"
},
@@ -6,15 +6,17 @@
* client. PDFs are previewed as server-rasterised page images (never raw).
*/
import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw } from 'lucide-react';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw, Send, Pencil } from 'lucide-react';
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
import { sortedCountryOptions } from '../../../constants/countries';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
accountingService, categoryLabel,
@@ -150,10 +152,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
{field(t('accounting.inbox.field.total', 'Total'), doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : null)}
{field(t('accounting.inbox.field.invoiceDate', 'Invoice date'), doc.invoiceDate ? format(doc.invoiceDate) : null)}
{field(t('accounting.inbox.field.disposition', 'Disposition'), doc.disposition ? t(`accounting.disposition.${doc.disposition}`, doc.disposition) : null)}
{field(t('accounting.inbox.status.label', 'Status'), t(`accounting.inbox.status.${doc.status}`, doc.status))}
{doc.customerName && field(t('accounting.inbox.field.customer', 'Client'), doc.customerName)}
{field(t('accounting.inbox.status.label', 'Status'), doc.customerAccountId && !doc.billedInvoiceId
? t('accounting.incoming.pendingRebill', 'Pending re-bill')
: t(`accounting.inbox.status.${doc.status}`, doc.status))}
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
: t('common.no', 'No'))}
{doc.note && field(t('accounting.inbox.field.note', 'Note'), doc.note)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
@@ -165,18 +171,28 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
};
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [supplier, setSupplier] = useState(doc.supplierName || '');
const [amountMajor, setAmountMajor] = useState<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
const [currency, setCurrency] = useState(doc.currency || 'CHF');
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [reference, setReference] = useState(doc.paymentReference || '');
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [eventId, setEventId] = useState<number | null>(null);
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN);
const [note, setNote] = useState(doc.note || '');
const [supplierCountry, setSupplierCountry] = useState(doc.supplierCountry || '');
// Pre-fill from the existing disposition so a categorized invoice can be
// re-categorized (#1) — falls back to "company expense" for fresh docs.
const [disposition, setDisposition] = useState<Disposition>(doc.disposition || 'eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(doc.categoryId ?? undefined);
const [eventId, setEventId] = useState<number | null>(doc.eventId ?? null);
const [customer, setCustomer] = useState<SelectedCustomer[]>(
doc.customerAccountId ? [{ id: doc.customerAccountId, email: doc.customerEmail || '', displayName: doc.customerName }] : [],
);
const [markupType, setMarkupType] = useState<MarkupType>(doc.markupType || 'none');
const [markupValue, setMarkupValue] = useState<number>(
doc.markupType === 'percent' && doc.markupPercent != null ? doc.markupPercent
: doc.markupType === 'flat' && doc.markupFlatMinor != null ? doc.markupFlatMinor / 100
: NaN,
);
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
@@ -191,13 +207,15 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
// entered) — no second dialog — so "Save & mark paid" actually pays.
const save = useMutation({
mutationFn: async (pay: boolean) => {
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null });
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
await accountingService.categorizeInbound(doc.id, {
disposition,
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
...markupPayload(),
// Both rebill and passthrough can attach to a customer (#3).
customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null,
// Markup is a re-bill concept only — a pass-through bills at cost.
...(disposition === 'rebill' ? markupPayload() : { markupType: 'none', markupPercent: null, markupFlatMinor: null }),
});
if (pay) {
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
@@ -227,20 +245,35 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplier', 'Supplier')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplierCountry', 'Supplier country')}</label>
<select value={supplierCountry} onChange={(e) => setSupplierCountry(e.target.value)} className={selectCls}>
<option value="">{t('accounting.inbox.field.supplierCountryNone', '— unknown —')}</option>
{sortedCountryOptions(i18n.language).map((c) => <option key={c.code} value={c.code}>{c.label}</option>)}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}</p>
</div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.reference', 'Payment reference')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.note', 'Note')}</label>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} className={selectCls} placeholder={t('accounting.inbox.field.noteHint', 'Internal note for this invoice (optional)') as string} /></div>
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.disposition', 'Disposition')}</label>
<select value={disposition} onChange={(e) => setDisposition(e.target.value as Disposition)} className={selectCls}>
{DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
</select>
{/* Explain the selected disposition re-bill vs pass-through vs
company expense aren't obvious from the labels alone. */}
<p className="mt-1 rounded-md bg-neutral-50 dark:bg-neutral-800/60 px-2.5 py-1.5 text-xs text-neutral-600 dark:text-neutral-400">
{t(`accounting.disposition.help.${disposition}`, '')}
</p>
</div>
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.booking.inboundHint', 'Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to.')}</p>
</div>
)}
@@ -253,18 +286,24 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
</div>
)}
{disposition === 'rebill' && (
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} {disposition === 'rebill' ? '*' : ''}</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
{disposition === 'durchlaufend' && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}</p>}
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
{/* Markup is a re-bill concept only. A pass-through is invoiced
at cost (VAT-neutral), so no markup control here. */}
{disposition === 'rebill' && (<>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
</>)}
</div>
)}
</div>
@@ -283,6 +322,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
export const AccountingInboxPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const cameraRef = useRef<HTMLInputElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
@@ -294,6 +334,8 @@ export const AccountingInboxPage: React.FC = () => {
// without a manual reload (the poller runs server-side every 60s).
const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }), refetchInterval: 30000, refetchOnWindowFocus: true });
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
// Per-event customers carrying pending (categorised, unbilled) re-bills (#3).
const { data: pending } = useQuery({ queryKey: ['accounting-pending-rebills'], queryFn: () => accountingService.listPendingRebills(), refetchInterval: 30000 });
const upload = useMutation({
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
@@ -305,11 +347,24 @@ export const AccountingInboxPage: React.FC = () => {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const billPending = useMutation({
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
onSuccess: ({ invoiceId, count }) => {
toast.success(t('accounting.incoming.bundledToast', 'Bundled {{count}} re-bill(s) into one invoice.', { count }));
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] });
navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = '';
};
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
const refresh = () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] }); };
const pendingItems = pending ?? [];
const customerLabel = (p: typeof pendingItems[number]) => p.displayName || p.companyName || [p.firstName, p.lastName].filter(Boolean).join(' ') || p.email || `#${p.customerAccountId}`;
const handleTriageDone = () => { setTriageDoc(null); refresh(); };
@@ -329,6 +384,31 @@ export const AccountingInboxPage: React.FC = () => {
<Button variant="outline" onClick={() => uploadRef.current?.click()} disabled={upload.isPending}><Upload className="w-4 h-4 mr-2" /> {t('accounting.inbox.uploadFile', 'Upload file')}</Button>
</CardContent></Card>
{/* Pending re-bills (#3): per-event customers accumulate categorised
rebill/passthrough invoices here; bundle them into one invoice like
"Bill these hours". Monthly/manual customers never surface (they
consolidate onto their running draft at categorise time). */}
{pendingItems.length > 0 && (
<Card className="mb-6"><CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('accounting.incoming.pendingTitle', 'Pending re-bills')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">{t('accounting.incoming.pendingBody', 'Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.')}</p>
<div className="space-y-2">
{pendingItems.map((p) => (
<div key={p.customerAccountId} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-4 py-2">
<div className="flex-1 min-w-[12rem]">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{customerLabel(p)}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.incoming.pendingCount', '{{count}} item(s)', { count: p.itemCount })}
{' · '}{formatMoneyMinor(p.openAmountMinor, 'CHF')}
</div>
</div>
<Button size="sm" onClick={() => billPending.mutate(p.customerAccountId)} disabled={billPending.isPending}><Send className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.billPending', 'Bill these')}</Button>
</div>
))}
</div>
</CardContent></Card>
)}
{isLoading ? <Loading /> : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
@@ -362,6 +442,10 @@ export const AccountingInboxPage: React.FC = () => {
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
{' · '}{format(doc.createdAt)}
{doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
{/* Pending re-bill = attached to a client but not yet on an invoice. */}
{doc.customerAccountId && !doc.billedInvoiceId && (
<span className="text-indigo-600 dark:text-indigo-400">{' · '}{t('accounting.incoming.pendingRebill', 'Pending re-bill')}{doc.customerName ? `${doc.customerName}` : ''}</span>
)}
</div>
</button>
<Button size="sm" variant="ghost" onClick={() => setViewDoc(doc)}><Eye className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.view', 'View')}</Button>
@@ -372,7 +456,11 @@ export const AccountingInboxPage: React.FC = () => {
? <Button size="sm" variant="ghost" onClick={() => unpay.mutate(doc.id)} disabled={unpay.isPending}><RotateCcw className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markUnpaid', 'Mark unpaid')}</Button>
: <Button size="sm" variant="outline" onClick={() => setPayDoc(doc)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markPaid', 'Mark paid')}</Button>
)}
{doc.status === 'unsorted' && <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>}
{/* #1: re-categorize is available after the first triage too, so a
disposition can be changed (e.g. passthrough company expense). */}
{doc.status === 'unsorted'
? <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>
: <Button size="sm" variant="outline" onClick={() => setTriageDoc(doc)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.recategorize', 'Re-categorize')}</Button>}
</div>
))}
</div>
@@ -15,6 +15,8 @@ import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { accountingService } from '../../../services/accounting.service';
import { vatCodesService } from '../../../services/vatCodes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
@@ -197,6 +199,23 @@ export const BillEditorPage: React.FC = () => {
setCcPdfEmail((cur) => cur || currentAdmin.email);
}, [currentAdmin?.email, isEdit]);
// Seed the VAT from the configured default OUTPUT code (Settings →
// Accounting) on a brand-new, blank invoice — so new invoices don't silently
// start at 0%. Skips edits and conversions (quote/contract bring their own
// VAT), and never clobbers a value the admin already touched.
const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
const didSeedVatRef = useRef(false);
useEffect(() => {
if (isEdit || didSeedVatRef.current) return;
if (searchParams.get('fromContractId') || searchParams.get('fromQuoteId')) return;
if (vatCode || vatRate) return;
const code = acctSettings?.accounting_default_output_vat_code;
if (!code || !outputVatCodes) return;
const match = outputVatCodes.find((c) => c.code === code);
if (match) { didSeedVatRef.current = true; setVatRate(Number(match.rate)); setVatCode(match.code); }
}, [isEdit, searchParams, acctSettings, outputVatCodes, vatCode, vatRate]);
// Pre-fill the customer when the editor is opened from a customer
// detail page via `?customerAccountId=42`. Runs once on mount, only
// when creating a new invoice, and skips if the user has already
@@ -26,6 +26,8 @@ import { LineItemsTable, type EditableLineItem } from '../../../components/admin
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { accountingService } from '../../../services/accounting.service';
import { vatCodesService } from '../../../services/vatCodes.service';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -189,6 +191,23 @@ export const QuoteEditorPage: React.FC = () => {
}
})();
}, [isEdit, searchParams]);
// Seed the VAT from the configured default OUTPUT code (Settings →
// Accounting) on a brand-new, blank quote — so quotes (and the invoices they
// convert to) don't silently start at 0%. Never clobbers a touched value.
const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
const didSeedVatRef = useRef(false);
useEffect(() => {
if (isEdit || didSeedVatRef.current) return;
const code = acctSettings?.accounting_default_output_vat_code;
if (!code || !outputVatCodes) return;
const match = outputVatCodes.find((c) => c.code === code);
if (!match) return;
setForm((prev) => (prev.vatCode || prev.vatRate ? prev : { ...prev, vatRate: Number(match.rate), vatCode: match.code }));
didSeedVatRef.current = true;
}, [isEdit, acctSettings, outputVatCodes]);
// Customer search + inline-create state now lives inside
// <CustomerPicker> (migration C.5 extraction).
@@ -18,8 +18,8 @@ import {
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { toast } from 'react-toastify';
import { currencyOptions, normalizeCurrency } from '../../../constants/currencies';
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
@@ -45,7 +45,16 @@ export const SettingsBusinessProfilePage: React.FC = () => {
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
const saveProfile = useMutation({
mutationFn: () => profile ? businessProfileService.update(profile) : Promise.reject(),
// vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and
// vatRateDefault is retired (the rates are the Accounting VAT codes). Strip
// them from this save so an open Business-profile page can't clobber an edit
// made on the Accounting tab with its stale loaded value.
mutationFn: () => {
if (!profile) return Promise.reject();
const { vatLabel, defaultHourlyRateMinor, vatRateDefault, ...rest } = profile;
void vatLabel; void defaultHourlyRateMinor; void vatRateDefault;
return businessProfileService.update(rest);
},
onSuccess: () => {
toast.success(t('businessProfile.savedToast', 'Business profile saved.'));
qc.invalidateQueries({ queryKey: ['business-profile'] });
@@ -124,9 +133,29 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
know where they went. */}
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
{t('businessProfile.movedToAccounting', 'The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.defaultCurrency', 'Default currency') as string} value={profile.defaultCurrency}
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.field.defaultCurrency', 'Default currency')}
</label>
{/* Dropdown; the stored value is normalised (e.g. an old free-text
"chf" "CHF") so it pre-selects, and an unknown code is kept as
an extra option so nothing is lost. */}
<select
value={normalizeCurrency(profile.defaultCurrency)}
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{currencyOptions(profile.defaultCurrency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
{/* Migration 137 IANA timezone for the admin calendar + the
@@ -149,35 +178,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
))}
</select>
</div>
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
value={profile.vatRateDefault ?? 0}
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
{/* Install-wide fallback hourly rate (migration 113). Stored in
minor units; entered here in major units. Blank = no global
default, so hours-logging then needs a per-customer or
per-entry rate. Comma-tolerant via DecimalInput. */}
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => setProfile({
...profile,
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
})}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
/>
<p className="text-xs text-muted-theme mt-1">
{t('businessProfile.field.defaultHourlyRateHint',
'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: profile.defaultCurrency || 'CHF' })}
</p>
</div>
{/* VAT rate %, VAT label and the default hourly rate moved to
Settings Accounting (so all financial/VAT config lives in one
place). See the callout above. */}
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
@@ -35,6 +35,14 @@ export interface InboundDocument {
markupPercent: number | null;
markupFlatMinor: number | null;
billedInvoiceId: number | null;
/** Client a rebill/passthrough is attached to (migration 132). */
customerAccountId: number | null;
customerName: string | null;
customerEmail: string | null;
/** ISO-2 supplier country — auto-defaults the tax treatment (reclaim list). */
supplierCountry: string | null;
/** Free-text categorisation note. */
note: string | null;
supplierPaid: boolean;
supplierPaidAt: string | null;
supplierPaymentMethod: PaymentMethod | null;
@@ -79,6 +87,20 @@ export interface InvoiceExpensePayload {
markupFlatMinor?: number | null;
}
/** One customer with categorised-but-unbilled rebill/passthrough docs. */
export interface PendingRebillSummary {
customerAccountId: number;
companyName: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
email: string | null;
isPassive: boolean;
billingCadence: string | null;
itemCount: number;
openAmountMinor: number;
}
export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; }
export interface Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
@@ -92,6 +114,8 @@ export interface AccountingSettings {
/** ISO-2 countries whose input VAT can be reclaimed (drives cost
* tax-treatment + the report's VAT-payable). */
accounting_vat_reclaim_countries: string[];
/** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
accounting_default_output_vat_code: string;
}
export interface CategorizePayload {
@@ -148,6 +172,10 @@ export const accountingService = {
async updateInbound(id: number, fields: Partial<InboundDocument>): Promise<InboundDocument> { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
async categorizeInbound(id: number, payload: CategorizePayload): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; },
async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; },
/** Per-event customers with pending (categorised, unbilled) re-bills. */
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
/** Bundle one customer's pending re-bills into a single invoice. */
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
@@ -186,6 +214,8 @@ export const accountingService = {
accounting_vat_registered: data.accounting_vat_registered === true,
accounting_vat_reclaim_countries: Array.isArray(data.accounting_vat_reclaim_countries)
? data.accounting_vat_reclaim_countries : [],
accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
? data.accounting_default_output_vat_code : '',
};
},
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {