feat(crm): frontend code — pages + services + components

Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
This commit is contained in:
Luca
2026-05-26 18:19:32 +02:00
parent d543949188
commit a7e16e7bf6
96 changed files with 18370 additions and 572 deletions
+52
View File
@@ -0,0 +1,52 @@
/**
* calendarPrefs — localStorage-backed admin calendar preferences.
*
* Single key today: the last-used view (Month or Week). Stored per
* browser/admin pair via localStorage so the toggle persists across
* page reloads. No server round-trip.
*
* If more admin-tunable preferences land (e.g. default week start,
* legend visibility), extend this module with a JSON object keyed at
* `picpeak.calendar.prefs` instead of more individual keys.
*
* The getter swallows malformed values (e.g. someone hand-edits the
* stored value) and falls back to the documented default — never
* throws on read.
*/
const VIEW_KEY = 'picpeak.calendar.view';
export type CalendarView = 'dayGridMonth' | 'timeGridWeek';
const ALLOWED_VIEWS: ReadonlyArray<CalendarView> = ['dayGridMonth', 'timeGridWeek'];
/**
* Return the persisted view or the default ('dayGridMonth' — Month).
* Safe to call before localStorage exists (SSR / test envs).
*/
export function getCalendarView(): CalendarView {
if (typeof window === 'undefined' || !window.localStorage) return 'dayGridMonth';
try {
const raw = window.localStorage.getItem(VIEW_KEY);
if (raw && (ALLOWED_VIEWS as readonly string[]).includes(raw)) {
return raw as CalendarView;
}
} catch (_) {
// ignore — fall through to default
}
return 'dayGridMonth';
}
/**
* Persist the active view. Silently no-ops when localStorage is
* unavailable.
*/
export function setCalendarView(view: CalendarView): void {
if (typeof window === 'undefined' || !window.localStorage) return;
if (!(ALLOWED_VIEWS as readonly string[]).includes(view)) return;
try {
window.localStorage.setItem(VIEW_KEY, view);
} catch (_) {
// ignore — quota / disabled storage
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* formatShortDate — canonical DD.MM.YYYY formatter for public + customer
* surfaces that don't have access to admin Settings (general_date_format).
*
* Five page files (PaymentCheckPage, QuoteResponsePage, CustomerBills/
* Quotes/ContractsPage) previously carried identical copies. The
* customer portal + public token routes deliberately use a fixed format
* because they render on links the customer opens without any admin
* locale context.
*
* Admin-side surfaces should use `useLocalizedDate` instead so they
* honor the operator's general_date_format / general_time_format
* settings. See `feedback_respect_general_format_settings.md`.
*/
export function formatShortDate(value: string | null | undefined): string {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Unified money formatting for the picpeak UI.
*
* Replaces the six near-duplicate `function formatMoney` blocks that
* lived in LineItemsTable, CrmOverviewSection, PaymentCheckPage,
* QuoteResponsePage, CustomerBillsPage, and CustomerQuotesPage. Each
* copy had the SAME hardcoded `de-CH` locale fallback, meaning an
* EN-locale operator saw German thousand-separators on every page; and
* each copy varied on whether the `amount` was minor or major units —
* leading to occasional 100× factor bugs when call-sites used the
* wrong copy.
*
* Decisions baked in here:
* - Locale defaults to `i18next.language` (i.e. the active UI
* language), not de-CH. Call-sites can override with the `locale`
* option for special cases (e.g. always-DE PDF previews).
* - Two entry points: `formatMoney` for MAJOR units (preferred —
* matches what most templates and editor forms already pass), and
* `formatMoneyMinor` for MINOR units (use this when reading raw
* `*_amount_minor` columns).
* - Currency defaults to CHF when the call-site passes an empty
* string, mirroring the previous helpers' behaviour and matching
* the operator's default issuer currency.
*/
import i18next from 'i18next';
/**
* Map an i18next language code to a BCP 47 locale that
* `Intl.NumberFormat` accepts and that yields the conventional
* separator + grouping rules for that language.
*
* We intentionally lean toward CH-flavoured variants for DE because
* the operator's primary market is Switzerland (apostrophe-grouped
* thousands, period decimal). Other locales fall through to their
* canonical defaults.
*/
function languageToLocale(language?: string): string {
if (!language) return 'de-CH';
const base = language.toLowerCase().split('-')[0];
switch (base) {
case 'de': return 'de-CH';
case 'en': return 'en-US';
case 'fr': return 'fr-CH';
case 'nl': return 'nl-NL';
case 'pt': return 'pt-BR';
case 'ru': return 'ru-RU';
default: return language;
}
}
export interface FormatMoneyOptions {
/**
* BCP 47 locale override. When omitted, the active i18next language
* is mapped to a sensible locale via {@link languageToLocale}.
*/
locale?: string;
/**
* Force a specific fraction-digit count. Defaults to the
* currency's `Intl.NumberFormat` default (2 for most currencies,
* 0 for JPY etc.). Useful for whole-CHF totals in summary pills.
*/
fractionDigits?: number;
}
function buildFormatter(currency: string, opts?: FormatMoneyOptions): Intl.NumberFormat {
const locale = opts?.locale || languageToLocale(i18next.language);
const numFormatOpts: Intl.NumberFormatOptions = {
style: 'currency',
currency: (currency || 'CHF').toUpperCase(),
};
if (opts?.fractionDigits !== undefined) {
numFormatOpts.minimumFractionDigits = opts.fractionDigits;
numFormatOpts.maximumFractionDigits = opts.fractionDigits;
}
return new Intl.NumberFormat(locale, numFormatOpts);
}
/**
* Format a MAJOR-unit amount (e.g. 12.5 for €12.50) as a localised
* currency string. Pass `*_amount_minor` columns through
* {@link formatMoneyMinor} instead — passing minor units here yields
* a 100× over-display.
*/
export function formatMoney(
amount: number,
currency: string,
opts?: FormatMoneyOptions,
): string {
const safe = Number.isFinite(amount) ? amount : 0;
return buildFormatter(currency, opts).format(safe);
}
/**
* Format a MINOR-unit amount (the integer cents/Rappen stored in
* `*_amount_minor` columns) as a localised currency string. Divides
* by 100 before handing to Intl.NumberFormat.
*/
export function formatMoneyMinor(
minor: number,
currency: string,
opts?: FormatMoneyOptions,
): string {
const safe = Number.isFinite(minor) ? minor : 0;
return buildFormatter(currency, opts).format(safe / 100);
}
+101
View File
@@ -160,6 +160,107 @@ export const toArray = <T>(value: unknown, defaultValue: T[] = []): T[] => {
return defaultValue;
};
/**
* Locale-tolerant decimal parser. Accepts strings using either '.' or
* ',' as the decimal separator and either thousand-separator
* convention (German "1.234,50" or English "1,234.50"). Crucially, this
* is what we use to read CRM money/quantity fields whose <input> is
* typed by humans in either an EN or DE locale — `Number('12,50')`
* silently returns NaN, which the call-sites then coerce to 0, eating
* real money.
*
* Heuristic when both separators appear: the LAST one is the decimal
* separator; all earlier instances of either symbol are thousands and
* are stripped. When only one separator appears, it's treated as the
* decimal separator. Leading/trailing whitespace, currency glyphs
* (€, $, CHF, etc.), and stray spaces inside the number are tolerated.
*
* Returns `NaN` when the input can't be coerced — callers should check
* with `Number.isFinite` before persisting.
*
* Examples:
* parseLocaleDecimal('12,50') // → 12.5 (DE)
* parseLocaleDecimal('12.50') // → 12.5 (EN)
* parseLocaleDecimal('1.234,50') // → 1234.5 (DE with thousands)
* parseLocaleDecimal('1,234.50') // → 1234.5 (EN with thousands)
* parseLocaleDecimal('-7,5') // → -7.5
* parseLocaleDecimal('€ 12,50') // → 12.5
* parseLocaleDecimal('') // → NaN
* parseLocaleDecimal('foo') // → NaN
*/
export const parseLocaleDecimal = (value: unknown): number => {
if (typeof value === 'number') return value;
if (value === null || value === undefined) return NaN;
let s = String(value).trim();
if (!s) return NaN;
// Strip whitespace + common currency symbols; leave digits, signs,
// and the two separator characters.
s = s.replace(/[\s€$£¥]/g, '').replace(/CHF/gi, '');
const lastDot = s.lastIndexOf('.');
const lastComma = s.lastIndexOf(',');
if (lastDot === -1 && lastComma === -1) {
// No separator — pure integer or noise.
const n = Number(s);
return Number.isFinite(n) ? n : NaN;
}
// The later separator wins as the decimal point; earlier separators
// of either type are treated as thousands and stripped.
const decimalAt = Math.max(lastDot, lastComma);
const intPart = s.slice(0, decimalAt).replace(/[.,]/g, '');
const fracPart = s.slice(decimalAt + 1);
if (!fracPart && !intPart) return NaN;
const normalised = `${intPart}.${fracPart}`;
const n = Number(normalised);
return Number.isFinite(n) ? n : NaN;
};
/**
* Parse a duration shortcut into whole minutes.
*
* Accepts the three formats the maintainer types into the hours-log
* form so they don't have to compute end-time mentally for a known
* duration:
* - "1h", "2h", "0.5h", "1,5h" → hours, optional decimal
* - "1.5", "1,5", "0.75" → bare decimal hours (DE comma OK)
* - "1:30", "0:45" → H:MM
*
* Returns the duration in MINUTES (so call-sites adding to a start time
* don't have to round). Returns `null` for empty, unparseable, or
* negative input — the caller should ignore null instead of substituting
* a default, so a typo doesn't silently overwrite an end-time the admin
* already set.
*
* Examples:
* parseDuration('1h') → 60
* parseDuration('1.5') → 90
* parseDuration('1,5') → 90
* parseDuration('1:30') → 90
* parseDuration('0:45') → 45
* parseDuration('') → null
* parseDuration('1:99') → null
*/
export const parseDuration = (value: unknown): number | null => {
if (typeof value !== 'string') return null;
const s = value.trim().toLowerCase();
if (!s) return null;
// H:MM form.
const colonMatch = s.match(/^(\d+):([0-5]\d)$/);
if (colonMatch) {
const h = Number(colonMatch[1]);
const m = Number(colonMatch[2]);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
const total = h * 60 + m;
return total > 0 ? total : null;
}
// Strip a trailing 'h' (with optional whitespace).
const stripped = s.replace(/\s*h$/, '');
const hours = parseLocaleDecimal(stripped);
if (!Number.isFinite(hours) || hours <= 0) return null;
return Math.round(hours * 60);
};
// Re-export with alternative names for backwards compatibility
export const parseBooleanInput = toBoolean;
export const parseNumberInput = toNumber;