From 840df525817f1bcfa65dbf1fd47e4adbe349c13c Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:15:40 +0200 Subject: [PATCH 01/59] fix(crm): respect general_date_format on all admin date inputs Admin date inputs were inconsistent: raw on event creation and the bill editor rendered in the browser locale (en-US users saw MM/DD/YYYY regardless of Settings -> General), while the historical- invoice import modal used a private LocalizedDateField that displayed the configured format but showed a text box plus a tiny native date stub side-by-side ("two date fields, looks corrupted"). Extract a single shared LocalizedDateInput that displays/parses in the configured general_date_format on every browser and opens the native picker via a calendar icon button (showPicker on a visually-hidden native input), so there is one date field, not two. Wire it into event creation, the bill editor (event/issue/due dates), the import modal, and the tax- report range filters (dropping the Chromium-only lang={dateInputLang} workaround there). --- .../components/common/LocalizedDateInput.tsx | 168 ++++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/pages/admin/CreateEventPage.tsx | 8 +- .../src/pages/admin/bills/BillEditorPage.tsx | 10 +- .../src/pages/admin/bills/BillsListPage.tsx | 116 +----------- .../src/pages/admin/clients/TaxReportPage.tsx | 22 +-- 6 files changed, 188 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/common/LocalizedDateInput.tsx diff --git a/frontend/src/components/common/LocalizedDateInput.tsx b/frontend/src/components/common/LocalizedDateInput.tsx new file mode 100644 index 00000000..03fe0003 --- /dev/null +++ b/frontend/src/components/common/LocalizedDateInput.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { clsx } from 'clsx'; +import { Calendar } from 'lucide-react'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; + +/** + * Date input that displays + accepts values in the admin-configured + * format from Settings → General (`general_date_format`), independent + * of the browser locale. Stores + emits ISO (YYYY-MM-DD) so the rest + * of the form / API surface keeps the canonical shape. + * + * A native `` always renders in the browser's own + * locale (en-US users see MM/DD/YYYY) no matter what the app is + * configured for, so it can't be used directly. This component shows + * a plain text input in the configured format and parses on blur. A + * calendar icon button opens the native date picker (via showPicker()) + * off a visually-hidden native input, giving the click-to-pick + * affordance without rendering a second visible date box. + */ +interface LocalizedDateInputProps { + label?: string; + value: string; + onChange: (iso: string) => void; + error?: string; + /** Forwarded to the native picker so min/max date constraints work. */ + min?: string; + max?: string; + disabled?: boolean; +} + +export const LocalizedDateInput: React.FC = ({ + label, + value, + onChange, + error, + min, + max, + disabled, +}) => { + const { dateFormat } = useLocalizedDate(); + const nativeRef = React.useRef(null); + const inputId = React.useId(); + + // Normalise the configured format down to the four shapes the parser + // understands. Defaults to DD.MM.YYYY (the operator's primary locale) + // when unknown. + const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => { + const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase(); + if (f.startsWith('mm/dd')) return 'MM/DD/YYYY'; + if (f.startsWith('yyyy')) return 'YYYY-MM-DD'; + if (f.includes('/')) return 'DD/MM/YYYY'; + return 'DD.MM.YYYY'; + })(); + const placeholder = normalisedFormat.toLowerCase(); + + // ISO → display + const toDisplay = (iso: string): string => { + if (!iso) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return iso; + const [, y, mo, d] = m; + switch (normalisedFormat) { + case 'MM/DD/YYYY': return `${mo}/${d}/${y}`; + case 'YYYY-MM-DD': return `${y}-${mo}-${d}`; + case 'DD/MM/YYYY': return `${d}/${mo}/${y}`; + case 'DD.MM.YYYY': + default: return `${d}.${mo}.${y}`; + } + }; + + // display → ISO (accepts variant separators leniently) + const toIso = (raw: string): string => { + const s = raw.trim(); + if (!s) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + const parts = s.split(/[./-]/); + if (parts.length !== 3) return ''; + const [a, b, c] = parts; + let y: string, mo: string, d: string; + if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) { + [y, mo, d] = [a, b, c]; + } else if (normalisedFormat === 'MM/DD/YYYY') { + [mo, d, y] = [a, b, c]; + } else { + [d, mo, y] = [a, b, c]; + } + if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return ''; + return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; + }; + + const [text, setText] = React.useState(toDisplay(value)); + React.useEffect(() => { + setText(toDisplay(value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const openPicker = () => { + const el = nativeRef.current; + if (!el) return; + try { + el.showPicker(); + } catch { + // showPicker throws on unsupported browsers / outside a user + // gesture — the text field stays fully usable for typing. + } + }; + + return ( +
+ {label && ( + + )} +
+ setText(e.target.value)} + onBlur={() => { + const iso = toIso(text); + if (iso) { + onChange(iso); + setText(toDisplay(iso)); + } else if (!text.trim()) { + onChange(''); + } + }} + className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')} + aria-invalid={error ? 'true' : 'false'} + aria-describedby={error ? `${inputId}-error` : undefined} + /> + + {/* Visually hidden native picker — its only job is to provide + the calendar popup the icon button triggers. Value stays in + ISO so it's always parseable. */} + onChange(e.target.value)} + tabIndex={-1} + aria-hidden="true" + className="sr-only" + /> +
+ {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 46702452..dc5984ea 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -1,6 +1,7 @@ export { Button } from './Button'; export { CMSContentBlock } from './CMSContentBlock'; export { Input } from './Input'; +export { LocalizedDateInput } from './LocalizedDateInput'; export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Loading, LoadingSkeleton } from './Loading'; export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 99cd4422..e5df23f7 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -15,7 +15,7 @@ import { import { addDays } from 'date-fns'; import { toast } from 'react-toastify'; -import { Button, Input, Card, PasswordGenerator } from '../../components/common'; +import { Button, Input, Card, PasswordGenerator, LocalizedDateInput } from '../../components/common'; import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => { leftIcon={} /> - setFormData(prev => ({ ...prev, event_date: iso }))} error={errors.event_date} - leftIcon={} /> diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index ff6d0258..38edc81e 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service'; import { quotesService } from '../../../services/quotes.service'; import { contractsService } from '../../../services/contracts.service'; @@ -488,8 +488,8 @@ export const BillEditorPage: React.FC = () => {
setEventName(e.target.value)} /> - setEventDate(e.target.value)} /> + setEventTimeStart(e.target.value)} /> {

{t('bills.section.details', 'Details')}

- setIssueDate(e.target.value)} /> - setDueDate(e.target.value)} /> + + setScheduledSendAt(e.target.value)} />
diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index cba0e2e5..a4c413f8 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Search, Upload, X } from 'lucide-react'; import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; -import { Button, Card, Input, Loading } from '../../../components/common'; +import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common'; import { formatMoney } from '../../../components/admin/LineItemsTable'; import { customerAdminService } from '../../../services/customerAdmin.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -329,12 +329,12 @@ const ImportHistoricalInvoiceModal: React.FC = ({ onClose }) = value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /> - - = ({ onClose }) =
); }; - -/** - * Date field that displays + accepts values in the admin-configured - * format from Settings → General (`general_date_format`). Stores - * + emits ISO (YYYY-MM-DD) so the rest of the form / API surface - * keeps the canonical shape. - * - * Native `` always renders in the browser's - * locale (en-US users see MM/DD/YYYY), which mismatched what - * customers + the rest of the app see elsewhere. This component - * uses a plain text input + parses on blur, with the configured - * format shown as both placeholder and helper text. A small - * shadow native date input next to the field gives the click-to- - * open calendar without affecting the displayed format. - */ -interface LocalizedDateFieldProps { - label: string; - value: string; - onChange: (iso: string) => void; -} -const LocalizedDateField: React.FC = ({ label, value, onChange }) => { - const { dateFormat } = useLocalizedDate(); - // Normalise the configured format down to the four shapes our - // parser understands. Defaults to DD.MM.YYYY (the maintainer's - // primary locale) when unknown. - const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => { - const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase(); - if (f.startsWith('mm/dd')) return 'MM/DD/YYYY'; - if (f.startsWith('yyyy')) return 'YYYY-MM-DD'; - if (f.includes('/')) return 'DD/MM/YYYY'; - return 'DD.MM.YYYY'; - })(); - const placeholder = normalisedFormat.toLowerCase(); - - // ISO → display - const toDisplay = (iso: string): string => { - if (!iso) return ''; - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); - if (!m) return iso; - const [, y, mo, d] = m; - switch (normalisedFormat) { - case 'MM/DD/YYYY': return `${mo}/${d}/${y}`; - case 'YYYY-MM-DD': return `${y}-${mo}-${d}`; - case 'DD/MM/YYYY': return `${d}/${mo}/${y}`; - case 'DD.MM.YYYY': - default: return `${d}.${mo}.${y}`; - } - }; - - // display → ISO (accepts variant separators leniently) - const toIso = (raw: string): string => { - const s = raw.trim(); - if (!s) return ''; - // Already ISO? - if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; - // Split on . / - - const parts = s.split(/[./-]/); - if (parts.length !== 3) return ''; - let [a, b, c] = parts; - let y: string, mo: string, d: string; - if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) { - [y, mo, d] = [a, b, c]; - } else if (normalisedFormat === 'MM/DD/YYYY') { - [mo, d, y] = [a, b, c]; - } else { - // DD.MM.YYYY or DD/MM/YYYY - [d, mo, y] = [a, b, c]; - } - if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return ''; - return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; - }; - - const [text, setText] = React.useState(toDisplay(value)); - React.useEffect(() => { setText(toDisplay(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value]); - - return ( -
- -
- setText(e.target.value)} - onBlur={() => { - const iso = toIso(text); - if (iso) { - onChange(iso); - setText(toDisplay(iso)); - } else if (!text.trim()) { - onChange(''); - } - }} - /> - {/* Tiny native date picker shortcut — gives the calendar - without polluting the visible text input. Hidden value - stays in ISO so it's always parseable. */} - onChange(e.target.value)} - aria-label={label} - className="text-sm px-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800" - style={{ width: 36 }} - /> -
-

{placeholder}

-
- ); -}; - diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index 958ef04c..d60c3ade 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -18,7 +18,7 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common'; // Lightweight native select styled to match Input — the common barrel // doesn't export a Select component, and the form pieces here are @@ -89,7 +89,7 @@ function triggerBrowserDownload(url: string, filename: string) { export const TaxReportPage: React.FC = () => { const { t, i18n } = useTranslation(); - const { format: fmtDate, dateInputLang } = useLocalizedDate(); + const { format: fmtDate } = useLocalizedDate(); const [preset, setPreset] = useState('thisYear'); const initialPeriod = useMemo(() => periodForPreset('thisYear'), []); const [from, setFrom] = useState(initialPeriod.from); @@ -196,27 +196,21 @@ export const TaxReportPage: React.FC = () => {
-
-
From db2c482ae9d1049960df443c1e224a86b6e8ff2f Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:28:00 +0200 Subject: [PATCH 02/59] feat(crm): country dropdown + name guard for customer create/edit Replace the free-text 2-char country code field on the inline customer create form and the customer detail page with a dropdown that shows localized country names (Intl.DisplayNames, no hardcoded map) while still storing the ISO 3166-1 alpha-2 code. The create form now seeds the default country from the business profile instead of leaving it blank or guessing CH/FL. The free-text countryName override is kept for the rare case where an operator wants a custom display string. Standardize Liechtenstein on the ISO code LI instead of the colloquial plate code FL so it matches the PDF renderer's locale-aware lookup and the new dropdown. Migration 110 normalizes existing FL rows to LI on customer_accounts and business_profile (idempotent, case-insensitive). Require at least one human-readable identifier (company name or a contact name) at create time so the form can't produce a nameless row that's impossible to recognise in lists later. Enforced on both the frontend (isValid + toast) and the backend POST /admin/customers validator so the API can't be bypassed. i18n: en + de updated; other locales fall back to inline English defaults and should get a native review before release. --- .../110_normalize_country_code_fl_to_li.js | 40 ++++++++++ backend/src/routes/adminCustomers.js | 11 +++ .../components/admin/InlineCustomerCreate.tsx | 42 ++++++---- .../src/components/common/CountrySelect.tsx | 77 +++++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/constants/countries.ts | 31 ++++++++ frontend/src/i18n/locales/de.json | 5 +- frontend/src/i18n/locales/en.json | 5 +- .../src/pages/admin/CustomerDetailPage.tsx | 16 ++-- 9 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/core/110_normalize_country_code_fl_to_li.js create mode 100644 frontend/src/components/common/CountrySelect.tsx create mode 100644 frontend/src/constants/countries.ts diff --git a/backend/migrations/core/110_normalize_country_code_fl_to_li.js b/backend/migrations/core/110_normalize_country_code_fl_to_li.js new file mode 100644 index 00000000..26cba940 --- /dev/null +++ b/backend/migrations/core/110_normalize_country_code_fl_to_li.js @@ -0,0 +1,40 @@ +/** + * Migration: normalize the Liechtenstein country code from the + * colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code + * `LI`. + * + * Background: the customer create/edit UI used to accept a free-text + * 2-char country code and the placeholder suggested `FL` for + * Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware + * lookup (services/pdfService.js countryName) and the new country + * dropdown both key on ISO, so `FL` rows render as the bare code + * instead of "Liechtenstein". The dropdown now stores `LI`; this + * migration brings existing rows in line so they display correctly and + * match new records. + * + * Scope: customer_accounts.country_code and business_profile.country_code. + * Case-insensitive so a hand-entered `fl` is caught too. The free-text + * country_name override column is left untouched — it exists precisely + * for operators who want a custom display string. + * + * Idempotent: re-runs only touch rows still holding FL, so a second run + * is a no-op. + */ + +async function normalizeColumn(knex, table) { + if (!(await knex.schema.hasTable(table))) return; + if (!(await knex.schema.hasColumn(table, 'country_code'))) return; + await knex(table) + .whereRaw('UPPER(country_code) = ?', ['FL']) + .update({ country_code: 'LI' }); +} + +exports.up = async function(knex) { + await normalizeColumn(knex, 'customer_accounts'); + await normalizeColumn(knex, 'business_profile'); +}; + +// Irreversible by design: once normalized to the ISO code there's no +// way to know which `LI` rows were originally `FL`, and reverting would +// reintroduce the non-ISO value the rest of the system can't read. +exports.down = async function() {}; diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 6f178d66..dc95bc9f 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -245,6 +245,17 @@ router.post('/', [ body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }), body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }), body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }), + // At least one human-readable identifier so the record isn't a + // nameless row that's impossible to recognise in lists later. + body('prefill').custom((prefill) => { + const p = prefill || {}; + const hasName = ['company_name', 'display_name', 'first_name', 'last_name'] + .some((k) => typeof p[k] === 'string' && p[k].trim()); + if (!hasName) { + throw new Error('At least a company name or a contact name is required'); + } + return true; + }), ], handleAsync(async (req, res) => { validateRequest(req); const { id } = await customerAccountsService.createDirect({ diff --git a/frontend/src/components/admin/InlineCustomerCreate.tsx b/frontend/src/components/admin/InlineCustomerCreate.tsx index 41ad4b47..1bca4d5c 100644 --- a/frontend/src/components/admin/InlineCustomerCreate.tsx +++ b/frontend/src/components/admin/InlineCustomerCreate.tsx @@ -23,7 +23,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Save, Send, X } from 'lucide-react'; -import { Button, Input } from '../common'; +import { Button, CountrySelect, Input } from '../common'; import { customerAdminService, type CustomerAccountDetail, @@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod staleTime: 5 * 60 * 1000, }); const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en'; + const profileCountryCode = profileSnapshot?.profile?.countryCode || ''; - // Seed preferredLanguage with the profile default once the profile - // arrives (only if the field is still empty so we don't clobber - // explicit user input). + // Seed preferredLanguage + countryCode with the profile defaults once + // the profile arrives (only if the field is still empty so we don't + // clobber explicit user input). React.useEffect(() => { - if (profileDefaultLocale && !form.preferredLanguage) { - setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale }); - } + setForm((prev) => { + const next = { ...prev }; + if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale; + if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase(); + return next; + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [profileDefaultLocale]); + }, [profileDefaultLocale, profileCountryCode]); const setField = (key: keyof FormState) => (e: React.ChangeEvent) => setForm((prev) => ({ ...prev, [key]: e.target.value })); - const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email); + const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email); + // At least one human-readable identifier so the record isn't a + // nameless row that's impossible to recognise in lists later. + const hasName = !!(form.companyName.trim() || form.displayName.trim() + || form.firstName.trim() || form.lastName.trim()); + const isValid = hasEmail && hasName; const handleSave = async (mode: 'passive' | 'invite') => { - if (!isValid) { + if (!hasEmail) { toast.error(t('customers.create.emailRequired', 'A valid email is required.')); return; } + if (!hasName) { + toast.error(t('customers.create.nameRequired', + 'Enter at least a company name or a contact name.')); + return; + } setBusy(mode); try { const customer = await customerAdminService.createDirect(form.email, buildPrefill(form)); @@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod value={form.state} onChange={setField('state')} /> - setForm((prev) => ({ ...prev, countryCode: code }))} />
- - setForm((prev) => ({ ...prev, countryCode: code }))} />
{/* Free-text country name override (migration 107). When left empty the PDF renderer falls back to the locale- - aware lookup on the abbreviation; useful when the - abbreviation isn't an ISO code (e.g. "FL" for - Liechtenstein, which is "LI" in ISO). */} + aware lookup on the ISO code. Kept for the rare case + where an operator wants a custom display name that + differs from the standard localized label. */} Date: Tue, 2 Jun 2026 01:36:52 +0200 Subject: [PATCH 03/59] fix(crm): anchor imported invoice dates to issue_date, not import time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invoice-import endpoint stamped sent_at and paid_at with the moment of import (new Date()) instead of the document's historical dates. The CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a year-old paid invoice imported today wrongly counted toward the rolling window. The dashboard windowing is correct (cash-basis "received in the window") — the bug was the wrong paid_at on imported rows. POST /admin/invoices/import now anchors sent_at to issue_date and paid_at to issue_date (or an optional new paidAt param when the admin knows the real payment date), never to import time. Migration 111 backfills rows imported under the old behaviour: for every invoice with imported_pdf_path set, sent_at/paid_at are reset to issue_date. The old code never captured a real payment date, so issue_date is the only sensible anchor. Idempotent and scoped strictly to imported rows, so picpeak-issued invoices are untouched. paid_at/sent_at are operational timestamps, not the invoice's immutable legal content, so correcting the import-time error is safe under the §14/§11 UStG immutability rule. --- .../111_backfill_imported_invoice_dates.js | 47 +++++++++++++++++++ backend/src/routes/adminInvoices.js | 14 +++++- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/core/111_backfill_imported_invoice_dates.js diff --git a/backend/migrations/core/111_backfill_imported_invoice_dates.js b/backend/migrations/core/111_backfill_imported_invoice_dates.js new file mode 100644 index 00000000..47068067 --- /dev/null +++ b/backend/migrations/core/111_backfill_imported_invoice_dates.js @@ -0,0 +1,47 @@ +/** + * Migration: backfill historical send/payment dates on already-imported + * invoices. + * + * Background: the invoice-import endpoint (POST /admin/invoices/import) + * used to stamp `sent_at` and `paid_at` with the moment of import + * (`new Date()`) rather than the document's own historical dates. The + * CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a + * year-old paid invoice imported today wrongly counted toward the + * rolling window. The route now anchors both timestamps to `issue_date` + * (with an optional explicit `paidAt`); this migration brings the rows + * imported under the old behaviour in line. + * + * Scope: rows with `imported_pdf_path` set — i.e. historical documents, + * never invoices issued by picpeak itself. For those, no real payment + * date was ever captured (the column held the import timestamp), so the + * issue date is the best available anchor. Note: `paid_at`/`sent_at` are + * operational timestamps, not part of the invoice's immutable legal + * content — correcting an import-time bug on them doesn't alter the + * issued document. + * + * Idempotent: re-runs just re-assign the same issue_date value. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('invoices'))) return; + const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at']; + for (const c of cols) { + if (!(await knex.schema.hasColumn('invoices', c))) return; + } + + // sent_at → issue_date for every imported row that has one. + await knex('invoices') + .whereNotNull('imported_pdf_path') + .whereNotNull('sent_at') + .update({ sent_at: knex.ref('issue_date') }); + + // paid_at → issue_date for imported rows that recorded a payment. + await knex('invoices') + .whereNotNull('imported_pdf_path') + .whereNotNull('paid_at') + .update({ paid_at: knex.ref('issue_date') }); +}; + +// Irreversible by design: the original import-time stamps were wrong +// data, and there's no record of them to restore. +exports.down = async function() {}; diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index c8f4e7c5..dd077a10 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -417,6 +417,8 @@ router.post( // currency 3-letter ISO (optional, default profile/CHF) // status 'sent' | 'paid' | 'overdue' (default 'sent') // paidAmountMinor int (optional, for status='paid') +// paidAt ISO date (optional — the real historical payment +// date; defaults to issueDate, never import time) // language string (optional, default 'de') router.post( '/import', @@ -431,6 +433,7 @@ router.post( body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']), body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('paidAt').optional({ values: 'falsy' }).isISO8601(), body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), ], handleAsync(async (req, res) => { @@ -470,6 +473,13 @@ router.post( const status = req.body.status || 'sent'; const issueDate = req.body.issueDate; const dueDate = req.body.dueDate || issueDate; + // Imported docs are historical: their real send/payment dates are + // the document's own dates, NOT the moment of import. Stamping + // import-time here put year-old paid invoices inside the dashboard's + // rolling "Revenue · last 30 days" window (which keys on paid_at). + // Anchor to the historical date; let the admin override paid_at when + // they know the exact payment date. + const paidAt = req.body.paidAt || issueDate; const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase(); const language = req.body.language || customer.preferred_language || 'de'; @@ -488,14 +498,14 @@ router.post( installment_trigger: null, status, scheduled_send_at: null, - sent_at: status !== 'scheduled' ? new Date() : null, + sent_at: new Date(issueDate), net_amount_minor: totalMinor, // imported docs lack a breakdown vat_rate: 0, // VAT info lives in the imported PDF vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: totalMinor, paid_amount_minor: paidMinor, - paid_at: status === 'paid' ? new Date() : null, + paid_at: status === 'paid' ? new Date(paidAt) : null, // Store the path RELATIVE to STORAGE_PATH so the value survives // a host migration (Docker volume remount on a new host with a // different absolute path). From 522cf2aa4a58a3126ce08c780ba46d4b53149f23 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:52:45 +0200 Subject: [PATCH 04/59] fix(invoices): auto-track due date from send date + payment term Due date now derives from (scheduled send date else issue date) plus the selected Net-days template, both in the editor and on save. The bill editor renders it read-only with an Override toggle for manual entry; existing invoices preserve their stored due date. Backend adds a single resolveNetDays resolver that honors the split payment-net-days template (previously only the legacy FK was read) and the crm_payment_default_net_days setting, used by createInvoice and the installment-spawn path alike. --- backend/src/services/invoiceService.js | 70 ++++++++++++++----- frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + .../src/pages/admin/bills/BillEditorPage.tsx | 49 ++++++++++++- 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index ab02c247..19b3f818 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -104,6 +104,46 @@ function computeDueDate(scheduledSendAt, netDays = 30) { return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000); } +/** + * Resolve the net-days a new invoice's due date should be anchored to. + * Single source of truth so the editor (split picker), legacy callers, + * and quote→invoice conversion all land on the same number. Priority: + * + * 1. `payload.netDays` — explicit caller override (installment spawn + * passes the snapshot's net_days here). + * 2. Split picker (migration 124): payment_net_days_templates.net_days + * via `payload.paymentNetDaysTemplateId`. This is what the bill + * editor actually sends; the old code only read the legacy FK and + * so silently ignored Net 60 / 90 selections. + * 3. Legacy single FK: payment_term_templates.net_days via + * `payload.paymentTermTemplateId`. + * 4. The `crm_payment_default_net_days` setting (admin-configured). + * 5. 30 — historical hard default. + */ +async function resolveNetDays(payload, trx = db) { + if (payload && payload.netDays != null && payload.netDays !== '') { + const n = ensureInt(payload.netDays); + if (n) return n; + } + if (payload && payload.paymentNetDaysTemplateId) { + const probe = await trx('payment_net_days_templates') + .where({ id: payload.paymentNetDaysTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + if (payload && payload.paymentTermTemplateId) { + const probe = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); + if (setting) return setting; + return 30; +} + /** * Resolve the deal_uuid for a new invoice row (migration 140). Priority: * @@ -642,18 +682,13 @@ async function createInvoice(payload, adminId, trx = db) { // used `invoiceNumber` here. const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null; - // Resolve the selected payment-term template's net_days BEFORE - // computing the due date so Net 60 / 90 templates actually push - // the due date out. Falls back to 30 when no template is set - // (matches the historical default). - let resolvedNetDays = 30; - if (payload.paymentTermTemplateId) { - const probe = await trx('payment_term_templates') - .where({ id: payload.paymentTermTemplateId }) - .select('net_days') - .first(); - if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30; - } + // Resolve net_days BEFORE computing the due date so Net 60 / 90 + // selections actually push the due date out. resolveNetDays honors + // the split picker FK the editor sends, the legacy single FK, and + // the crm_payment_default_net_days setting (see helper). The clock + // starts on the SEND date when the invoice is scheduled, otherwise + // the issue date — so a future send pushes the due date out too. + const resolvedNetDays = await resolveNetDays(payload, trx); const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays) .toISOString().slice(0, 10); @@ -927,10 +962,13 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre } // netDays drives the due-date offset on every scheduled invoice - // created here. Defaults to 30 when the caller doesn't pass one; - // callers in quoteService now pass the converting quote's - // payment-term net_days so Net 60 / 90 templates flow through. - const resolvedNetDays = ensureInt(netDays) || 30; + // created here. Callers in quoteService pass the converting quote's + // payment-term net_days so Net 60 / 90 templates flow through; when + // absent we fall back to the crm_payment_default_net_days setting + // (then 30) rather than silently using 30, matching createInvoice. + const resolvedNetDays = ensureInt(netDays) + || ensureInt(await getAppSetting('crm_payment_default_net_days')) + || 30; const total = installments.length; const acceptanceTime = new Date(); const invoiceIds = []; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 415999bf..a6062a2c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3512,6 +3512,8 @@ "total": "Gesamt", "sourceQuote": "Vom Angebot", "issueDate": "Ausgestellt am", + "dueDateOverrideOn": "Manuelles Fälligkeitsdatum — Häkchen entfernen, um es automatisch aus Versanddatum + Zahlungsziel zu berechnen", + "dueDateOverrideOff": "Automatisch aus Versanddatum + Zahlungsziel — ankreuzen, um es manuell zu setzen", "scheduledSendAt": "Geplanter Versand (optional)", "installment": "Rate", "paid": "Bezahlt", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 306f7e5f..45da5bbe 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3557,6 +3557,8 @@ "field": { "issueDate": "Issued", "dueDate": "Due", + "dueDateOverrideOn": "Manual due date — untick to auto-set from send date + payment term", + "dueDateOverrideOff": "Auto from send date + payment term — tick to set manually", "scheduledSendAt": "Scheduled send", "installment": "Installment", "total": "Total", diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index 38edc81e..8426117d 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -48,6 +48,12 @@ export const BillEditorPage: React.FC = () => { const [currency, setCurrency] = useState('CHF'); const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10)); const [dueDate, setDueDate] = useState(''); + // Due date is normally view-only: it auto-tracks (send date else issue + // date) + the selected Net-days template, so the payment clock starts + // on the day the invoice actually goes out. Flipping this lets the + // admin type a different date by hand; we keep it pinned so the auto + // effect below stops clobbering their value. + const [dueDateOverridden, setDueDateOverridden] = useState(false); const [scheduledSendAt, setScheduledSendAt] = useState(''); // null = inherit profile default at render time. 'none' / 'swiss' / // 'epc' = explicit per-invoice override. (Existing invoices that @@ -124,6 +130,10 @@ export const BillEditorPage: React.FC = () => { setCurrency(inv.currency); setIssueDate(inv.issueDate); setDueDate(inv.dueDate); + // The invoice already carries a due date — preserve it rather than + // letting the auto effect recompute and surprise the admin. They + // can untick "Override" to re-enable auto-tracking. + setDueDateOverridden(true); setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : ''); // Preserve null when the saved invoice has no explicit format — // it inherits the profile default at render time. @@ -317,6 +327,25 @@ export const BillEditorPage: React.FC = () => { setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id); }, [isEdit, netDaysTemplates, timingTemplates, appSettings]); + // Auto-track the due date off (scheduled send date else issue date) + + // the selected Net-days template, mirroring the backend's + // computeDueDate. The clock starts the day the invoice goes out, so + // scheduling a future send pushes the due date out with it. Skipped + // once the admin overrides the field by hand. Date math is in UTC to + // match the backend (which parses the YYYY-MM-DD base as UTC midnight). + useEffect(() => { + if (dueDateOverridden) return; + const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return; + const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId); + const netDays = tpl?.netDays != null + ? Number(tpl.netDays) + : Number(appSettings?.crm_payment_default_net_days) || 30; + const d = new Date(`${base}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + netDays); + setDueDate(d.toISOString().slice(0, 10)); + }, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]); + const buildPayload = (): InvoiceCreatePayload => ({ customerAccountId: customerId || 0, currency, @@ -501,7 +530,25 @@ export const BillEditorPage: React.FC = () => {

{t('bills.section.details', 'Details')}

- +
+ + +
setScheduledSendAt(e.target.value)} />
From 7c39a30957a59c379b6abe6702798923488a9021 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:38:07 +0200 Subject: [PATCH 05/59] feat(bills): link event label to its event detail page The Anlass / event name on the invoice detail page and the bills list now links through to /admin/events/:id when the invoice references a real event row. The list link stops propagation so it doesn't trigger the row's invoice navigation. Falls back to plain text when the invoice carries only a free-text event snapshot. Customer portal unchanged (no admin route access). --- frontend/src/pages/admin/bills/BillDetailPage.tsx | 9 ++++++++- frontend/src/pages/admin/bills/BillsListPage.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index cee6b480..cfdd0ece 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -386,7 +386,14 @@ export const BillDetailPage: React.FC = () => {
{inv.eventName && ( -
{t('bills.field.eventName', 'Event')}
{inv.eventName}{inv.eventDate ? ` · ${inv.eventDate}` : ''}
+
{t('bills.field.eventName', 'Event')}
+
+ {inv.eventId ? ( + {inv.eventName} + ) : inv.eventName} + {inv.eventDate ? ` · ${inv.eventDate}` : ''} +
+
)}
{t('bills.field.issueDate', 'Issued')}
{fmtDate(inv.issueDate)}
{t('bills.field.dueDate', 'Due')}
{fmtDate(inv.dueDate)}
diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index a4c413f8..3a5100cf 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -167,7 +167,13 @@ export const BillsListPage: React.FC = () => { )} {inv.customer.companyName || inv.customer.displayName || inv.customer.email} - {inv.eventName || '—'} + + {inv.eventName + ? (inv.eventId + ? e.stopPropagation()}>{inv.eventName} + : inv.eventName) + : '—'} + {inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'} From 45c7cc80ab68d00d3dc9b84dcf5f29e5d918aef5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:54:23 +0200 Subject: [PATCH 06/59] fix(invoices): anchor issue date + Skonto window to the actual send date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheduled invoice's issue_date was stamped at creation, so a long- scheduled invoice printed a stale date by the time it shipped — the relative Skonto window ("pay within N working days") and the net-days due date were then counted from the authoring day, not the send day. sendInvoice now stamps issue_date = send date on the first send and re-derives the due date from it, preserving a manual due-date override. Adds resolveNetDaysForRow to read net days from the persisted snapshot. Deselecting Skonto before the scheduled send already propagates (the scheduler re-reads the row fresh and the render context honours skonto_disabled); no change needed there. --- backend/src/services/invoiceService.js | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 19b3f818..356f98c5 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -144,6 +144,25 @@ async function resolveNetDays(payload, trx = db) { return 30; } +/** + * Net-days for an already-persisted invoice row (no payload). Reads the + * snapshot's net_days, then the crm_payment_default_net_days setting, + * then 30. Used at send time to re-anchor the due date when the issue + * date is stamped. Mirrors resolveNetDays' tail. + */ +async function resolveNetDaysForRow(invoice) { + const snap = typeof invoice.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() + : invoice.payment_term_snapshot; + if (snap && snap.net_days != null) { + const n = ensureInt(snap.net_days); + if (n) return n; + } + const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); + if (setting) return setting; + return 30; +} + /** * Resolve the deal_uuid for a new invoice row (migration 140). Priority: * @@ -1904,6 +1923,39 @@ async function sendInvoice(id, adminId) { invoice.language = customer.preferred_language; } + // Stamp the issue date at the moment the invoice actually goes out. + // A scheduled invoice's issue_date is provisional — set to the + // authoring day at creation — but the legal issue date is when it + // ships. Anchoring it here keeps the printed invoice date, the Skonto + // window (a relative "pay within N working days" counted from that + // date) and the net-days due date all consistent with the send date. + // Only on the first send (status 'scheduled'); 'sent' / 'overdue' + // rows are immutable legal records and keep their stamped date. + if (invoice.status === 'scheduled') { + const sendDateIso = new Date().toISOString().slice(0, 10); + const netDays = await resolveNetDaysForRow(invoice); + // Re-anchor the due date too, but only when it was machine-set: if + // the stored due_date still equals the auto formula off the OLD + // base (scheduled_send_at, else the old issue_date), the admin never + // hand-edited it and we slide it to the new issue date. A divergent + // value means a manual override (the editor's "Override due date" + // toggle) — leave it untouched. + const oldBase = invoice.scheduled_send_at + ? new Date(invoice.scheduled_send_at) + : new Date(invoice.issue_date); + const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10); + const storedDue = invoice.due_date + ? new Date(invoice.due_date).toISOString().slice(0, 10) + : null; + const updates = { issue_date: sendDateIso, updated_at: new Date() }; + if (storedDue && storedDue === oldAutoDue) { + updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10); + } + await db('invoices').where({ id }).update(updates); + invoice.issue_date = updates.issue_date; + if (updates.due_date) invoice.due_date = updates.due_date; + } + const ctx = await buildInvoiceRenderContext(invoice, lineItems); const buffer = await pdfService.renderInvoiceToBuffer(ctx); From d788a6cde50a4146123bc7efadca314d6916c2a1 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:00:05 +0200 Subject: [PATCH 07/59] feat(crm): per-customer Skonto opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds customer_accounts.skonto_disabled (migration 112) so a customer that negotiated "no early-payment discount" can be flagged once instead of ticking the per-invoice toggle on every invoice. resolveSkontoPercent ForInvoice and the PDF render context both honour it, extending the resolution chain to customer → invoice → snapshot → quote → global. Checkbox added to the customer detail Billing card (en + de). --- .../core/112_add_customer_skonto_disabled.js | 34 +++++++++++++++++++ backend/src/routes/adminCustomers.js | 6 ++++ .../src/services/customerAccountsService.js | 4 +++ backend/src/services/invoiceService.js | 17 ++++++++-- frontend/src/i18n/locales/de.json | 2 ++ frontend/src/i18n/locales/en.json | 2 ++ .../src/pages/admin/CustomerDetailPage.tsx | 22 +++++++++++- .../src/services/customerAdmin.service.ts | 6 ++++ 8 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/core/112_add_customer_skonto_disabled.js diff --git a/backend/migrations/core/112_add_customer_skonto_disabled.js b/backend/migrations/core/112_add_customer_skonto_disabled.js new file mode 100644 index 00000000..89982547 --- /dev/null +++ b/backend/migrations/core/112_add_customer_skonto_disabled.js @@ -0,0 +1,34 @@ +/** + * Migration: per-customer Skonto opt-out. + * + * Background: invoices already carry a per-invoice `skonto_disabled` + * flag (migration 126). For B2B customers who negotiated "no early- + * payment discount" as a standing contract term, the admin had to tick + * that toggle on every single invoice. This adds a customer-level flag + * so the opt-out is set once and applies to all of that customer's + * invoices. The resolver chain becomes: + * customer.skonto_disabled → invoice.skonto_disabled → + * invoice snapshot → source-quote snapshot → global default. + * + * Default false so existing customers keep inheriting whatever Skonto + * the template / global default offers — no behaviour change on upgrade + * (see migration-preserve-existing-state guidance). + * + * Idempotent: guarded by hasColumn so a re-run is a no-op. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('customer_accounts'))) return; + if (await knex.schema.hasColumn('customer_accounts', 'skonto_disabled')) return; + await knex.schema.alterTable('customer_accounts', (table) => { + table.boolean('skonto_disabled').notNullable().defaultTo(false); + }); +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('customer_accounts'))) return; + if (!(await knex.schema.hasColumn('customer_accounts', 'skonto_disabled'))) return; + await knex.schema.alterTable('customer_accounts', (table) => { + table.dropColumn('skonto_disabled'); + }); +}; diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index dc95bc9f..4b2b8606 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -67,6 +67,10 @@ function transformCustomer(c) { // per-entry override on every logged block. featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1, hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null, + // Per-customer Skonto opt-out (migration 112). When true, none of + // this customer's invoices qualify for an early-payment discount, + // regardless of template / global defaults. + skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1, lastLogin: c.last_login, createdAt: c.created_at, updatedAt: c.updated_at, @@ -394,6 +398,8 @@ router.put('/:id', [ body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']), body('billing_cycle_day').optional().isInt({ min: -15, max: 28 }) .withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'), + // Per-customer Skonto opt-out (migration 112). + body('skonto_disabled').optional().isBoolean(), ], handleAsync(async (req, res) => { validateRequest(req); const customer = await customerAccountsService.updateCustomer( diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 9b32c759..5a11c6c7 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -555,6 +555,9 @@ async function updateCustomer(id, updates, updatedByAdminId) { // Hour-logging default rate (migration 129). Minor units; null // means admin must enter a per-entry override on every entry. 'hourly_rate_minor', + // Per-customer Skonto opt-out (migration 112). Boolean, coerced + // via formatBoolean below for SQLite compatibility. + 'skonto_disabled', ]; for (const f of fields) { if (updates[f] !== undefined) { @@ -567,6 +570,7 @@ async function updateCustomer(id, updates, updatedByAdminId) { } else if ( f === 'feature_calendar' || f === 'feature_quotes' || f === 'feature_bills' || f === 'feature_hours_logging' + || f === 'skonto_disabled' ) { allowed[f] = formatBoolean(updates[f]); } else if (f === 'hourly_rate_minor') { diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 356f98c5..83467b9d 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -1690,8 +1690,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) { // but still printed the discount row on the PDF. Zero out both // fields here so pdfService.drawPaymentBlock's // `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays` - // guard suppresses the row. - if (invoice.skonto_disabled) { + // guard suppresses the row. The per-customer opt-out (migration 112) + // is honoured here too — a customer flagged skonto_disabled never + // prints the discount row, mirroring resolveSkontoPercentForInvoice. + if (invoice.skonto_disabled || customer?.skonto_disabled) { paymentTerm.skontoPercent = null; paymentTerm.skontoWithinDays = null; } @@ -2667,6 +2669,17 @@ async function resolveSkontoPercentForInvoice(invoice) { // installments that shouldn't qualify for the discount even when // the global default offers it. if (invoice.skonto_disabled) return null; + // Per-customer opt-out (migration 112) — a customer that negotiated + // "no Skonto" as a contract term never qualifies, so the admin + // doesn't have to tick the per-invoice toggle on every invoice. + // Falls through customer → invoice → snapshot → quote → global. + if (invoice.customer_account_id) { + const cust = await db('customer_accounts') + .where({ id: invoice.customer_account_id }) + .select('skonto_disabled') + .first(); + if (cust && cust.skonto_disabled) return null; + } const parseSnap = (raw) => { if (!raw) return null; if (typeof raw === 'object') return raw; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a6062a2c..d292050a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3134,6 +3134,8 @@ "quarterly": "Quartalsweise", "cycleDay": "Stichtag", "cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).", + "skontoDisabled": "Kein Skonto für diesen Kunden", + "skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden – unabhängig von Vorlage oder globalen Standardwerten.", "triggerNow": "Rechnung jetzt ausstellen", "triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.", "triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 45da5bbe..97077185 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3134,6 +3134,8 @@ "quarterly": "Quarterly", "cycleDay": "Cycle day", "cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).", + "skontoDisabled": "No Skonto for this customer", + "skontoDisabledHint": "Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.", "triggerNow": "Trigger invoice now", "triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.", "triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.", diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx index dafbf18a..b56bf2d7 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.tsx +++ b/frontend/src/pages/admin/CustomerDetailPage.tsx @@ -40,7 +40,7 @@ type EditableFields = | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state' | 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes' | 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' - | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay'; + | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled'; // `fmtDate` (from useLocalizedDate, below) is the single canonical date // formatter. It honors the admin's `general_date_format` setting AND @@ -140,6 +140,7 @@ export const CustomerDetailPage: React.FC = () => { hourlyRateMinor: customer.hourlyRateMinor ?? null, billingCadence: customer.billingCadence ?? 'per_event', billingCycleDay: customer.billingCycleDay ?? 1, + skontoDisabled: customer.skontoDisabled ?? false, } as any); } }, [customer, form]); @@ -738,6 +739,25 @@ export const CustomerDetailPage: React.FC = () => { )}
+ {/* Per-customer Skonto opt-out (migration 112). For B2B + customers who negotiated "no early-payment discount" — set + once instead of ticking the per-invoice toggle every time. */} + + {/* Preview of the open monthly draft (migration 128). Shows every line item queued for the customer's current billing period so admin sees exactly what "Trigger invoice now" diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts index 2602d9af..9ecdbded 100644 --- a/frontend/src/services/customerAdmin.service.ts +++ b/frontend/src/services/customerAdmin.service.ts @@ -60,6 +60,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary { */ billingCadence?: 'per_event' | 'monthly' | 'quarterly'; billingCycleDay?: number; + /** Per-customer Skonto opt-out (migration 112). When true, none of + * this customer's invoices qualify for an early-payment discount, + * regardless of template / global defaults. */ + skontoDisabled?: boolean; notes: string | null; events: Array<{ id: number; @@ -158,6 +162,8 @@ export const customerAdminService = { // CRM billing cadence (migration 102 + 128). billingCadence: 'billing_cadence', billingCycleDay: 'billing_cycle_day', + // Per-customer Skonto opt-out (migration 112). + skontoDisabled: 'skonto_disabled', }; for (const [k, v] of Object.entries(payload)) { if (k in map) snake[map[k]] = v; From 06b4522deceb06ed1d90e188a62b82fc7e0e91d6 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:27:10 +0200 Subject: [PATCH 08/59] feat(billing): manual cadence + fix admin date inputs ignoring date-format setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual billing cadence Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses the monthly draft accumulator — invoices and billed hours pile onto one running draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush never matches. The draft ships only when an admin clicks "Trigger invoice now". No migration: billing_cadence is a free-form string column gated by validators. - adminCustomers.js: allow 'manual' in billing_cadence validator - invoiceService.js: route manual through accumulator; NULL periods + placeholder issue/due date in getOrCreateMonthlyDraft - customerHoursService.js: manual auto-appends hours to running draft; billUnbilledEntries refuses manual (CADENCE_MISMATCH) - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual, NULL-period-safe draft preview + trigger button, manual-specific copy - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd - en.json / de.json: manual, triggerConfirmManual, triggerHintManual, draftPreview.titleManual Date-format fixes Replace raw (browser-locale) with LocalizedDateInput so these admin surfaces honor the general_date_format setting: - ContractEditorPage.tsx (issue / valid-until / event dates) - QuoteEditorPage.tsx (event / valid-until dates) - EventDetailsPage.tsx (expiry date) - HoursSection.tsx (entry date) --- backend/src/routes/adminCustomers.js | 2 +- backend/src/services/customerHoursService.js | 15 +++-- backend/src/services/invoiceService.js | 40 ++++++++----- .../src/components/admin/HoursSection.tsx | 5 +- frontend/src/i18n/locales/de.json | 6 +- frontend/src/i18n/locales/en.json | 6 +- .../src/pages/admin/CustomerDetailPage.tsx | 58 ++++++++++++------- frontend/src/pages/admin/EventDetailsPage.tsx | 7 +-- .../admin/contracts/ContractEditorPage.tsx | 8 +-- .../pages/admin/quotes/QuoteEditorPage.tsx | 10 ++-- .../src/services/customerAdmin.service.ts | 12 ++-- 11 files changed, 104 insertions(+), 65 deletions(-) diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 4b2b8606..026af794 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -395,7 +395,7 @@ router.put('/:id', [ // generated invoice to billing_cycle_day of the next period. // Cycle day spans -15..-1 (days before month end) and 1..28 // (day of month) per migration 128 + service-layer clamp. - body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']), + body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']), body('billing_cycle_day').optional().isInt({ min: -15, max: 28 }) .withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'), // Per-customer Skonto opt-out (migration 112). diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index e07644e7..d35065fa 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -202,8 +202,10 @@ async function createEntry(customerId, payload, adminId) { const inserted = await trx('customer_hour_entries').insert(row).returning('id'); const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - // Monthly-mode customers get the auto-append treatment. - if (customer.billing_cadence === 'monthly') { + // Accumulator-mode customers (monthly + manual) get the auto-append + // treatment — the entry lands on the running draft instead of staying + // unbilled. Manual differs only in that its draft never auto-flushes. + if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') { const fullEntry = { ...row, id: entryId }; const rate = resolveEffectiveRate(fullEntry, customer); const lineItem = buildLineItemFromEntry(fullEntry, rate); @@ -387,15 +389,16 @@ async function deleteEntry(entryId, adminId) { /** * Per-event flow: mint a standalone invoice from all unbilled entries * for this customer, one line per entry. Refuses when the customer is - * monthly-mode (those entries auto-billed on save, so there should be - * no unbilled rows). Returns the new invoice id. + * in an accumulator mode (monthly / manual) — those entries auto-billed + * onto the running draft on save, so there should be no unbilled rows. + * Returns the new invoice id. */ async function billUnbilledEntries(customerId, adminId) { const customer = await db('customer_accounts').where({ id: customerId }).first(); if (!customer) throw new AppError('Customer not found', 404); - if (customer.billing_cadence === 'monthly') { + if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') { throw new AppError( - 'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.', + 'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.', 409, 'CADENCE_MISMATCH', ); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 83467b9d..bc863f0c 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -293,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) { const today = new Date(); today.setHours(0, 0, 0, 0); + // Manual cadence has no billing cycle: the draft accumulates + // indefinitely and ships ONLY via the admin "Trigger invoice now" + // gesture, so it carries NO period_end. The scheduler's auto-flush + // filter is `monthly_period_end <= today`, which a NULL period_end + // can never satisfy — keeping manual drafts out of the cron path. + const isManual = customer.billing_cadence === 'manual'; + // Resolve period_end: prefer the cadence in the current month, but // if it has already passed, roll to next month so the new draft // gathers items toward the NEXT bill. @@ -302,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) { const nextMonth = today.getMonth() + 1; target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay); } - const periodStart = new Date(target.getFullYear(), target.getMonth(), 1); - const periodEnd = target; + const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1); + const periodEnd = isManual ? null : target; + // Placeholder issue/due date for the empty draft row — recomputed at + // issuance time. Manual drafts have no period_end, so fall back to today. + const placeholderDate = (periodEnd || today).toISOString().slice(0, 10); // Look up any existing open draft for this customer. We deliberately // do NOT filter by monthly_period_end here — only one draft can be @@ -341,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) { event_id: null, language, currency, - issue_date: periodEnd.toISOString().slice(0, 10), - due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time + issue_date: placeholderDate, + due_date: placeholderDate, // recomputed at issuance time installment_index: 0, installment_total: 1, status: 'scheduled', @@ -355,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) { business_bank_account_id: bank?.id || null, qr_format: null, is_monthly_draft: true, - monthly_period_start: periodStart.toISOString().slice(0, 10), - monthly_period_end: periodEnd.toISOString().slice(0, 10), + monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null, + monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null, // Migration 140 — each monthly-draft cycle is its own deal (no // quote/contract chain). Fresh UUID at creation; subsequent line // appends just mutate this same row, so the uuid sticks. @@ -677,15 +687,19 @@ async function createInvoice(payload, adminId, trx = db) { const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); ensureCustomerCanBill(customer); - // Monthly-billing intercept (migration 128). For customers in - // billing_cadence='monthly' mode every createInvoice call APPENDS - // line items onto the running monthly-draft instead of minting a + // Accumulator intercept (migration 128). For customers in + // billing_cadence='monthly' OR 'manual' mode every createInvoice call + // APPENDS line items onto a single running draft instead of minting a // fresh invoice. Admin sees the editor flow exactly as before; the // returned id is the draft's id so the UI can redirect to the - // accumulator. `_skipMonthlyRouting` is the escape hatch used by - // internal helpers that need to mint a non-draft row (e.g. the - // accumulator itself, or future test fixtures). - if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) { + // accumulator. The two modes differ only in WHEN the draft ships: + // 'monthly' auto-flushes on the cadence day (scheduler), 'manual' + // never auto-flushes (no period_end) and ships only via the admin + // "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape + // hatch used by internal helpers that need to mint a non-draft row + // (e.g. the accumulator itself, or future test fixtures). + if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') + && !payload._skipMonthlyRouting) { const draft = await appendToMonthlyDraft(payload, customer, adminId, trx); return { invoiceIds: draft?.id ? [draft.id] : [] }; } diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index 13414230..62536a2a 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Clock } from 'lucide-react'; -import { Button, Card } from '../common'; +import { Button, Card, LocalizedDateInput } from '../common'; import { DecimalInput } from '../common/DecimalInput'; import { parseLocaleDecimal, parseDuration } from '../../utils/parsers'; import { customerAdminService } from '../../services/customerAdmin.service'; @@ -224,8 +224,7 @@ export const HoursSection: React.FC = ({ - setEntryDate(e.target.value)} className="input w-full" /> +
- {form.billingCadence && form.billingCadence !== 'per_event' && ( + {(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
- setValidUntil(e.target.value)} /> +
@@ -419,7 +419,7 @@ export const ContractEditorPage: React.FC = () => { - setEventDate(e.target.value)} /> +
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index bf8806de..22a68839 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Send } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { quotesService, type QuoteCreatePayload, @@ -490,8 +490,8 @@ export const QuoteEditorPage: React.FC = () => {
setForm((f) => ({ ...f, eventName: e.target.value }))} /> - setForm((f) => ({ ...f, eventDate: e.target.value }))} /> + setForm((f) => ({ ...f, eventDate: iso }))} /> setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} /> { setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} /> - setForm((f) => ({ ...f, validUntil: e.target.value }))} /> + setForm((f) => ({ ...f, validUntil: iso }))} />
diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts index 9ecdbded..08330679 100644 --- a/frontend/src/services/customerAdmin.service.ts +++ b/frontend/src/services/customerAdmin.service.ts @@ -58,7 +58,7 @@ export interface CustomerAccountDetail extends CustomerAccountSummary { * - 'monthly' / 'quarterly': snap every scheduled invoice to * `billingCycleDay` of the next period. */ - billingCadence?: 'per_event' | 'monthly' | 'quarterly'; + billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual'; billingCycleDay?: number; /** Per-customer Skonto opt-out (migration 112). When true, none of * this customer's invoices qualify for an early-payment discount, @@ -360,16 +360,18 @@ export const customerAdminService = { }, }; -/** Open monthly bill accumulator preview (migration 128). One row in +/** Open bill accumulator preview (migration 128). One row in * the invoices table with is_monthly_draft=true that gathers every * invoice line created for this customer during the current period; - * ships on the cadence day or via triggerMonthlyBill. */ + * ships on the cadence day or via triggerMonthlyBill. Manual-cadence + * drafts carry no period (periodStart/End null) and ship only on the + * admin trigger. */ export interface MonthlyDraftPreview { id: number; invoiceNumber: string; currency: string; - periodStart: string; - periodEnd: string; + periodStart: string | null; + periodEnd: string | null; netAmountMinor: number; vatRate: number | null; vatAmountMinor: number; From c2d3f663bc965287f4c050a7a069649d8f7c1d5d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:37:33 +0200 Subject: [PATCH 09/59] fix(settings): use CountrySelect for business-profile country (no more FL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The business-profile country field — the seed source for the customer-create country default — was still a free-text input placeholdered "FL", which could reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared CountrySelect so every surface stores ISO alpha-2. The free-text countryName verbatim-PDF override (migration 107) is unchanged. --- .../admin/settings/SettingsBusinessProfilePage.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index 9ec0d54d..ed10d7db 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -15,7 +15,7 @@ import { type BankAccount, type QrFormat, } from '../../../services/businessProfile.service'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, CountrySelect } from '../../../components/common'; import { toast } from 'react-toastify'; export const SettingsBusinessProfilePage: React.FC = () => { @@ -82,11 +82,9 @@ export const SettingsBusinessProfilePage: React.FC = () => { onChange={(e) => setProfile({ ...profile, city: e.target.value })} /> setProfile({ ...profile, state: e.target.value })} /> - setProfile({ ...profile, countryCode: e.target.value.toUpperCase() })} /> + setProfile({ ...profile, countryCode: code })} /> {/* Free-text country name override (migration 107). When left empty the renderer falls back to the COUNTRY_NAMES lookup on the abbreviation. */} From db7d11dc7ff7aff7f6da15698c9f964a1ab4d1a1 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:50:51 +0200 Subject: [PATCH 10/59] feat(bills): capture event name/date when importing historical invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The historical-invoice import form had no event field, so imported rows landed with event_name = NULL even when the admin knew the occasion. Add free-text Event name + Event date inputs to the import modal, thread them through billsService.importHistorical and the POST /admin/invoices/import validator, and store them in the event_name/event_date snapshot columns (migration 107). event_id stays NULL — no FK, since the event may predate picpeak. Autocomplete-to-event_id linking deferred as a future bonus. --- backend/src/routes/adminInvoices.js | 7 +++++++ frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/pages/admin/bills/BillsListPage.tsx | 15 +++++++++++++++ frontend/src/services/bills.service.ts | 4 ++++ 5 files changed, 28 insertions(+) diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index dd077a10..42375648 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -427,6 +427,8 @@ router.post( [ body('customerAccountId').isInt({ min: 1 }), body('invoiceNumber').isString().isLength({ min: 1, max: 64 }), + body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('eventDate').optional({ values: 'falsy' }).isISO8601(), body('issueDate').isISO8601(), body('dueDate').optional({ values: 'falsy' }).isISO8601(), body('totalAmountMinor').isInt({ min: 0 }), @@ -487,7 +489,12 @@ router.post( invoice_number: req.body.invoiceNumber, customer_account_id: customer.id, source_quote_id: null, + // No FK link on import — the event may predate picpeak. Store the + // free-text snapshot only, mirroring createInvoice's event_name / + // event_date columns (migration 107). event_id: null, + event_name: req.body.eventName || null, + event_date: req.body.eventDate || null, language, currency, issue_date: issueDate, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 7313a736..a8e43491 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3541,6 +3541,7 @@ "selectTiming": "— Zahlungsablauf wählen —", "eventName": "Anlass", "eventDate": "Anlassdatum", + "eventNamePlaceholder": "z.B. Hochzeit Schmidt 2024", "eventTimeStart": "Startzeit", "eventTimeEnd": "Endzeit", "customer": "Kunde", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9037f9f4..4bd19899 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3587,6 +3587,7 @@ "selectTiming": "— Select schedule —", "eventName": "Event", "eventDate": "Event date", + "eventNamePlaceholder": "e.g. Smith wedding 2024", "eventTimeStart": "Start time", "eventTimeEnd": "End time", "customer": "Customer", diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index 3a5100cf..f84c618e 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -221,6 +221,8 @@ const ImportHistoricalInvoiceModal: React.FC = ({ onClose }) = const [customerId, setCustomerId] = useState(null); const [customerLabel, setCustomerLabel] = useState(''); const [invoiceNumber, setInvoiceNumber] = useState(''); + const [eventName, setEventName] = useState(''); + const [eventDate, setEventDate] = useState(''); const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10)); const [dueDate, setDueDate] = useState(''); const [totalMajor, setTotalMajor] = useState(''); @@ -245,6 +247,8 @@ const ImportHistoricalInvoiceModal: React.FC = ({ onClose }) = await billsService.importHistorical({ customerAccountId: customerId, invoiceNumber, + eventName: eventName.trim() || undefined, + eventDate: eventDate || undefined, issueDate, dueDate: dueDate || undefined, totalAmountMinor: Math.round(Number(totalMajor) * 100), @@ -327,6 +331,17 @@ const ImportHistoricalInvoiceModal: React.FC = ({ onClose }) =
+
+ setEventName(e.target.value)} /> +
+ Date: Tue, 2 Jun 2026 10:04:23 +0200 Subject: [PATCH 11/59] fix(bills): localize the event_date on the invoice detail card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anlass field rendered inv.eventDate verbatim (raw ISO from pg date-as-Date serialization) while every other date on the card went through useLocalizedDate. Route it through fmtDate so it honors the general_date_format setting. (The event_id → /admin/events linkify was already in place.) --- frontend/src/pages/admin/bills/BillDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index cfdd0ece..37e960cf 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -391,7 +391,7 @@ export const BillDetailPage: React.FC = () => { {inv.eventId ? ( {inv.eventName} ) : inv.eventName} - {inv.eventDate ? ` · ${inv.eventDate}` : ''} + {inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''}
)} From d9251c085039d35769aba1489aaab6eb95adbb20 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:59:36 +0200 Subject: [PATCH 12/59] feat(crm): Finder-style sortable column headers, default sort by issue date Replace the sort setSort(e.target.value as InvoiceSort)} - > - - - - - - -
+
+ )} + + + ); +}; diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 5e287b00..bfade88d 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -8,6 +8,7 @@ export { ArchivesPage } from './ArchivesPage'; export { AnalyticsPage } from './AnalyticsPage'; export { BrandingPage } from './BrandingPage'; export { SettingsPage } from './SettingsPage'; +export { SystemHealthPage } from './SystemHealthPage'; export { CMSPage } from './CMSPage'; export { BackupManagement } from './BackupManagement'; export { EventFeedbackPage } from './EventFeedbackPage'; diff --git a/frontend/src/services/systemHealth.service.ts b/frontend/src/services/systemHealth.service.ts new file mode 100644 index 00000000..2dd84035 --- /dev/null +++ b/frontend/src/services/systemHealth.service.ts @@ -0,0 +1,35 @@ +/** + * Admin → System health. Surfaces background failures (v1: stuck/failed + * outbound emails) so they don't sit unnoticed, with retry/dismiss. + */ +import { api } from '../config/api'; + +export interface StuckEmail { + id: number; + recipientEmail: string; + emailType: string; + status: 'pending' | 'failed'; + retryCount: number; + errorMessage: string | null; + createdAt: string; +} + +export interface SystemHealthFailures { + stuckEmails: StuckEmail[]; + counts: { stuckEmails: number }; +} + +export const systemHealthService = { + async getFailures(): Promise { + const { data } = await api.get('/admin/system-health/failures'); + return data.data || data; + }, + + async retryEmail(id: number): Promise { + await api.post(`/admin/system-health/failures/email/${id}/retry`); + }, + + async dismissEmail(id: number): Promise { + await api.delete(`/admin/system-health/failures/email/${id}`); + }, +}; From 02742b3163dc70af4a799a27dbb99a9e6fa8094a Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:20:49 +0200 Subject: [PATCH 34/59] feat(hours): open the hours invoice in the editor to add items After 'Create draft invoice' mints the single scheduled invoice from a per-event customer's unbilled hours, navigate to the bill editor so the admin can add other line items before it ships (invoice is already status='scheduled' + editable). Updated the per-event hint copy. --- frontend/src/components/admin/HoursSection.tsx | 10 +++++++--- frontend/src/i18n/locales/de.json | 2 +- frontend/src/i18n/locales/en.json | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index da02c43f..6f4abd41 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -15,7 +15,7 @@ import React, { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Clock, AlertTriangle } from 'lucide-react'; import { Button, Card, LocalizedDateInput, TimeField } from '../common'; @@ -46,6 +46,7 @@ export const HoursSection: React.FC = ({ }) => { const { t } = useTranslation(); const qc = useQueryClient(); + const navigate = useNavigate(); const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate(); const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10)); const [startTime, setStartTime] = useState('09:00'); @@ -154,10 +155,13 @@ export const HoursSection: React.FC = ({ const billMutation = useMutation({ mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId), - onSuccess: () => { + onSuccess: ({ invoiceId }) => { qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); qc.invalidateQueries({ queryKey: ['admin-customer', customerId] }); toast.success(t('customers.hours.toast.billed', 'Hours billed')); + // Open the new scheduled invoice so the admin can add other line + // items in addition to the hours before it ships. + if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`); }, onError: (err: any) => { toast.error(err?.response?.data?.error || 'Failed to bill hours'); @@ -202,7 +206,7 @@ export const HoursSection: React.FC = ({ ? t('customers.hours.monthlyHint', 'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.') : t('customers.hours.perEventHint', - 'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')} + 'Logged entries stay unbilled until you click "Create draft invoice" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.')}

{/* Rate summary — hidden in compact mode (history-only on the diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 45df10eb..c1e1314e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3107,7 +3107,7 @@ "hours": { "section": "Stunden", "monthlyHint": "Einträge werden automatisch dem aktuellen monatlichen Entwurf angehängt. Bearbeiten/Löschen möglich, bis der Scheduler den Versand auslöst.", - "perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird ein eigenständiger Rechnungsentwurf mit einer Zeile pro Eintrag erzeugt, den Sie vor dem Versand prüfen können.", + "perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird eine geplante Rechnung mit einer Zeile pro Eintrag erzeugt und im Editor geöffnet, sodass Sie vor dem Versand weitere Positionen hinzufügen können.", "form": { "title": "Neuen Eintrag erfassen", "date": "Datum", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ca1ec218..a92a972a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3107,7 +3107,7 @@ "hours": { "section": "Hours", "monthlyHint": "Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.", - "perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.", + "perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.", "form": { "title": "Log new entry", "date": "Date", From 807ae3d4fa69d4058a5f886b922efb570fb27227 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:37:40 +0200 Subject: [PATCH 35/59] fix(security): block script execution in served SVGs via CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve uploaded SVGs (admin logos etc.) with a restrictive Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic. The browser still renders the vector, but any embedded