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,51 @@
/**
* useInstallmentDefaults — fetch the three CRM-level installment
* defaults seeded by migration 141:
*
* - crm_invoices_installment_trigger_first ('quote_accepted')
* - crm_invoices_installment_days_before_event (14)
* - crm_invoices_installment_days_after_event (14)
*
* Used by InstallmentsPanel on the Quote and Invoice editors so a
* freshly-added installment row pre-populates with the admin's
* preferred trigger shape instead of an empty value.
*
* Cached via react-query with a 5-min staleTime — these settings
* change rarely and only via Settings → CRM. A `defaults` value is
* always returned (hard-coded fallbacks before the fetch completes)
* so callers don't have to guard a loading state.
*/
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../services/settings.service';
import type { PaymentTermInstallment } from '../services/quotes.service';
export interface InstallmentDefaults {
triggerFirst: PaymentTermInstallment['trigger'];
daysBeforeEvent: number;
daysAfterEvent: number;
}
const HARDCODED_FALLBACK: InstallmentDefaults = {
triggerFirst: 'quote_accepted',
daysBeforeEvent: 14,
daysAfterEvent: 14,
};
export function useInstallmentDefaults(): InstallmentDefaults {
const { data } = useQuery({
queryKey: ['installment-defaults'],
queryFn: () => settingsService.getAllSettings(),
staleTime: 5 * 60_000,
});
if (!data) return HARDCODED_FALLBACK;
return {
triggerFirst: (data.crm_invoices_installment_trigger_first as PaymentTermInstallment['trigger'])
|| HARDCODED_FALLBACK.triggerFirst,
daysBeforeEvent: Number.isFinite(Number(data.crm_invoices_installment_days_before_event))
? Number(data.crm_invoices_installment_days_before_event)
: HARDCODED_FALLBACK.daysBeforeEvent,
daysAfterEvent: Number.isFinite(Number(data.crm_invoices_installment_days_after_event))
? Number(data.crm_invoices_installment_days_after_event)
: HARDCODED_FALLBACK.daysAfterEvent,
};
}
+82 -5
View File
@@ -52,17 +52,94 @@ export const useLocalizedDate = () => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() });
};
// Time-format pattern: '24h' → 'HH:mm' (e.g. 14:32), '12h' →
// 'h:mm a' (e.g. 2:32 PM). Defaults to 24h when the setting is
// missing or unrecognised — matches the operator's CH/DE locale.
const timeFormatToken = settings?.general_time_format === '12h' ? 'h:mm a' : 'HH:mm';
/**
* Time only — respects the admin-configured `general_time_format`
* (24-hour HH:mm by default; 12-hour h:mm AM/PM when toggled).
*
* Accepts a Date, an ISO string, OR a bare "HH:MM" / "HH:MM:SS"
* clock string (e.g. "09:00" from a stored startTime/endTime).
* The bare-time path constructs an arbitrary epoch date with the
* given hours/minutes so date-fns can format the time half — the
* date half is discarded by the output pattern.
*/
const formatTime = (date: Date | string) => {
if (typeof date === 'string' && /^\d{2}:\d{2}(:\d{2})?$/.test(date)) {
const [h, m] = date.split(':');
const d = new Date(2000, 0, 1, parseInt(h, 10), parseInt(m, 10));
return dateFnsFormat(d, timeFormatToken, { locale: getLocale() });
}
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormat(dateObj, timeFormatToken, { locale: getLocale() });
};
/**
* Date + time, respecting both `general_date_format` for the date
* half and `general_time_format` for the time half. Examples with
* 24h time format:
* DD.MM.YYYY → "20.05.2026 14:32"
* YYYY-MM-DD → "2026-05-20 14:32"
* With 12h time format:
* DD.MM.YYYY → "20.05.2026 2:32 PM"
*
* Pass `formatStr` to override the date half (same shape as `format()`).
*/
const formatDateTime = (date: Date | string, formatStr?: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
let dateFormat = formatStr;
if (!dateFormat && settings?.general_date_format) {
dateFormat = typeof settings.general_date_format === 'string'
? settings.general_date_format
: settings.general_date_format.format || 'PPP';
}
dateFormat = dateFormat || 'PPP';
dateFormat = convertDateFormat(dateFormat);
return dateFnsFormat(dateObj, `${dateFormat} ${timeFormatToken}`, { locale: getLocale() });
};
return {
format,
formatTime,
formatDateTime,
formatDistanceToNow,
locale: getLocale(),
dateFormat: settings?.general_date_format
dateFormat: settings?.general_date_format
? convertDateFormat(
typeof settings.general_date_format === 'string'
? settings.general_date_format
typeof settings.general_date_format === 'string'
? settings.general_date_format
: settings.general_date_format.format || 'PPP'
)
: 'PPP'
: 'PPP',
/** '12h' or '24h' — exposed so components can decide between
* rendering a native <input type="time"> (always 24h value
* internally) vs a custom 12h-styled control if needed. */
timeFormat: (settings?.general_time_format === '12h' ? '12h' : '24h') as '12h' | '24h',
/** BCP-47 language tag to pin on `<input type="date">` so the
* browser-rendered placeholder + parsing match the admin's
* configured `general_date_format`. Chrome/Edge honour this;
* Safari/Firefox follow OS locale regardless (no way around it
* without a custom date-picker component).
*
* Mapping derives from the format string's first token:
* DD/dd → de-DE (renders TT.MM.JJJJ)
* YYYY/yyyy → en-CA (renders YYYY-MM-DD)
* MM → en-US (renders MM/DD/YYYY)
* fallback → i18n.language. */
dateInputLang: (() => {
const raw = settings?.general_date_format;
const fmt = typeof raw === 'string' ? raw : raw?.format;
if (fmt) {
const head = fmt.trim().slice(0, 2).toUpperCase();
if (head === 'DD') return 'de-DE';
if (head === 'YY') return 'en-CA';
if (head === 'MM') return 'en-US';
}
return i18n.language || 'en';
})(),
};
};
+43
View File
@@ -0,0 +1,43 @@
/**
* usePublicDarkMode — toggle the `.dark` class on <html> based on
* the admin's branding settings, for public pages that live outside
* the admin/customer layouts.
*
* Priority:
* 1. `branding_force_color_mode` → 'dark' / 'light' / null
* 2. fallback to the OS preference (and react when it flips)
*
* Necessary because Tailwind's `dark:` modifiers depend on the
* `.dark` class being present, and ThemeContext only writes CSS
* variables — it doesn't toggle the class.
*
* Shared by QuoteResponsePage + PaymentCheckPage. Adding a third
* public-page consumer? Reuse this hook.
*/
import { useEffect } from 'react';
import { usePublicSettings } from './usePublicSettings';
export function usePublicDarkMode() {
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
const root = document.documentElement;
const forced = publicSettings?.branding_force_color_mode;
const apply = (isDark: boolean) => {
if (isDark) root.classList.add('dark');
else root.classList.remove('dark');
};
if (forced === 'dark') {
apply(true);
return;
}
if (forced === 'light') {
apply(false);
return;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
apply(mql.matches);
const listener = (e: MediaQueryListEvent) => apply(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}, [publicSettings?.branding_force_color_mode]);
}