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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user