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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 client’s 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 client’s 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[] }> {
|
||||
|
||||
Reference in New Issue
Block a user