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
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import { parseLocaleDecimal } from '../../utils/parsers';
/**
* DecimalInput — controlled text input that accepts both '.' and ','
* as the decimal separator. Bubbles a parsed `number` up via
* `onChange`, but keeps the raw typed string in local state so the
* user can see their own keystrokes (German "12,5" stays "12,5"
* during editing instead of being instantly reformatted to "12.5").
*
* Why not `type="number"`? Chrome/Firefox `<input type=number>` reject
* comma input on non-DE document.lang and round-trip everything
* through `parseFloat`, which silently truncates "12,50" to 12. The
* audit found this eating real money on monthly bills typed by the
* operator in German. Switching to `type="text"` plus
* `inputMode="decimal"` keeps the mobile keypad and re-routes parsing
* through the locale-tolerant helper.
*
* Value-prop sync: the parent owns the canonical number. When the
* parent pushes a value that disagrees with what's in our local text
* (after parsing the local text back to a number), we reformat. This
* lets external updates (preset selection, autocompute) reflect in
* the field while still preserving the user's in-progress typing.
*/
export interface DecimalInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type' | 'inputMode'> {
/** Canonical numeric value. May be NaN when the field is empty. */
value: number;
/** Bubbled on every parseable keystroke. Receives NaN when the input is cleared. */
onChange: (next: number) => void;
/**
* Number of fraction digits to display when reformatting from a
* parent-pushed value. Defaults to undefined which uses the
* platform default (`Number.prototype.toString`).
*/
fractionDigits?: number;
/** Optional formatter override — receives the number, returns the display string. */
formatValue?: (value: number) => string;
}
function defaultFormat(value: number, fractionDigits?: number): string {
if (!Number.isFinite(value)) return '';
if (fractionDigits !== undefined) return value.toFixed(fractionDigits);
return String(value);
}
export function DecimalInput({
value,
onChange,
fractionDigits,
formatValue,
...rest
}: DecimalInputProps) {
const fmt = (v: number) => (formatValue ? formatValue(v) : defaultFormat(v, fractionDigits));
const [text, setText] = useState<string>(() => fmt(value));
const lastEmittedRef = useRef<number>(value);
// Sync from the parent when the externally-owned value disagrees
// with our locally-buffered text. We compare on the parsed number,
// not the string, so the user typing "12," doesn't snap back to
// "12" just because the parent saw 12 from the last keystroke.
useEffect(() => {
const parsed = parseLocaleDecimal(text);
const matchesParsed = Number.isFinite(parsed) && parsed === value;
const bothEmpty = text === '' && !Number.isFinite(value);
if (!matchesParsed && !bothEmpty && value !== lastEmittedRef.current) {
setText(fmt(value));
}
lastEmittedRef.current = value;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
const raw = e.target.value;
setText(raw);
// Empty field → propagate NaN so the parent can decide
// whether to coerce to 0 or treat as "unset".
if (raw.trim() === '') {
lastEmittedRef.current = NaN;
onChange(NaN);
return;
}
const parsed = parseLocaleDecimal(raw);
if (Number.isFinite(parsed)) {
lastEmittedRef.current = parsed;
onChange(parsed);
}
// Unparseable intermediate state (e.g. "12,") — keep the
// text but don't emit. The next keystroke usually completes
// the number and triggers a valid emit.
}}
onBlur={(e) => {
// Reformat on blur so the resting state shows the canonical
// form. Empty stays empty.
const parsed = parseLocaleDecimal(e.target.value);
if (Number.isFinite(parsed)) {
setText(fmt(parsed));
}
rest.onBlur?.(e);
}}
{...rest}
/>
);
}