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
@@ -25,6 +25,11 @@ export interface GeneralSettings {
use_original_filenames_for_downloads: boolean;
default_language: string;
date_format: { format: string; locale: string };
/** '12h' or '24h' — controls how times are RENDERED across admin
* + customer-facing views (formatDateTime / formatTime). The
* underlying storage is always HH:mm (24h); only the visual
* presentation toggles. Default '24h' (operator's locale). */
time_format: '12h' | '24h';
}
export interface SecuritySettings {
@@ -101,7 +106,8 @@ export function useSettingsState() {
short_gallery_urls: false,
use_original_filenames_for_downloads: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' },
time_format: '24h'
});
// Security settings state
@@ -192,7 +198,8 @@ export function useSettingsState() {
? (typeof settings.general_date_format === 'string'
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
: settings.general_date_format)
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
: { format: 'dd/MM/yyyy', locale: 'en-GB' },
time_format: settings.general_time_format === '12h' ? '12h' : '24h'
});
setSecuritySettings({
@@ -5,6 +5,7 @@ import { toast } from 'react-toastify';
import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
interface ApiTokenRow {
id: number;
@@ -28,6 +29,7 @@ const ALL_SCOPES: Array<'read' | 'write' | 'admin'> = ['read', 'write', 'admin']
*/
export const ApiTokensTab: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime, format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [scopes, setScopes] = useState<Array<'read' | 'write' | 'admin'>>(['read']);
@@ -205,10 +207,10 @@ export const ApiTokensTab: React.FC = () => {
pp_live_{token.preview || '••••'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '—'}
{token.last_used_at ? fmtDateTime(token.last_used_at) : '—'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{new Date(token.created_at).toLocaleDateString()}
{fmtDate(token.created_at)}
</td>
<td className="py-3 pr-3">
<span className={`text-xs px-2 py-0.5 rounded ${
@@ -8,11 +8,14 @@ import {
MessageSquare,
CalendarDays,
FileSignature,
ScrollText,
Receipt,
BarChart3,
Users,
UserCog,
Briefcase,
Wrench,
Calculator,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -115,10 +118,10 @@ export const FeaturesTab: React.FC = () => {
child below is on), so there's no explicit parent toggle —
admins just enable the specific feature they want and the
section appears automatically. */}
<Section title={t('settings.features.sections.clients', 'Clients')}>
<Section title={t('settings.features.sections.clients', 'CRM')}>
<FeatureCard
icon={UserCog}
title={t('settings.features.customerPortal.title', 'Clients')}
title={t('settings.features.customerPortal.title', 'Customer Accounts')}
description={t(
'settings.features.customerPortal.description',
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts.',
@@ -129,7 +132,7 @@ export const FeaturesTab: React.FC = () => {
// admin clicks first, not the deeper "Accounts" sub-nav.
// Keeps the wording consistent with what's actually visible
// in the menu bar.
sidebarLabel={t('navigation.clients', 'Clients')}
sidebarLabel={t('navigation.clients', 'CRM')}
enabled={staged.customerPortal}
onToggle={(next) => setFlag('customerPortal', next)}
/>
@@ -146,10 +149,10 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.reminderEmails.title', 'Reminder Emails')}
description={t(
'settings.features.reminderEmails.description',
'Automatic nudges to guests before their gallery expires and to admins about pending uploads.',
'Automatic nudges to guests before their gallery expires and to admins about pending uploads. Coming soon.',
)}
status="stable"
statusLabel={statusLabel('stable')}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarHidden
sidebarHiddenLabel={sidebarHiddenLabel}
enabled={staged.reminderEmails}
@@ -163,10 +166,10 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.messaging.title', 'Messaging')}
description={t(
'settings.features.messaging.description',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place.',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
)}
status="experimental"
statusLabel={statusLabel('experimental')}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
enabled={staged.messaging}
onToggle={() => { /* locked */ }}
@@ -182,12 +185,28 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.calendar.title', 'Calendar')}
description={t(
'settings.features.calendar.description',
'See all upcoming and past events on a month/week view. Optionally accept new bookings from clients.',
'Admin-only calendar showing events, logged hours, and pending quotes/contracts in one view. Drag-create hours directly on the calendar.',
)}
status="beta"
statusLabel={statusLabel('beta')}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.calendar.sidebar', 'Calendar')}
enabled={staged.calendar}
onToggle={(next) => setFlag('calendar', next)}
/>
{/* Customer-facing booking — placeholder. UI-disabled per
spec; backend dependency rule already gates it on
`calendar`. Re-enable when the booking flow ships. */}
<FeatureCard
icon={CalendarDays}
title={t('settings.features.calendarBooking.title', 'Customer booking')}
description={t(
'settings.features.calendarBooking.description',
'Let customers see free slots on a public calendar and book directly. Coming soon.',
)}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.calendarBooking.sidebar', 'Booking')}
enabled={staged.calendarBooking}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
@@ -196,6 +215,10 @@ export const FeaturesTab: React.FC = () => {
{/* Sales */}
<Section title={t('settings.features.sections.sales', 'Sales')}>
{/* Quotes + Bills shipped in the CRM PR (#TBD). The old
placeholder lockedReason / disabled props are removed so
the toggles actually save. The sub-page surfaces under
/admin/clients/{quotes,bills} are gated independently. */}
<FeatureCard
icon={FileSignature}
title={t('settings.features.quotes.title', 'Quotes')}
@@ -207,25 +230,68 @@ export const FeaturesTab: React.FC = () => {
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.quotes.sidebar', 'Quotes')}
enabled={staged.quotes}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
onToggle={(next) => setFlag('quotes', next)}
/>
<FeatureCard
icon={ScrollText}
title={t('settings.features.contracts.title', 'Contracts')}
description={t(
'settings.features.contracts.description',
'Compose contracts from a library of reusable blocks (image rights, NDA, model release, cancellation, jurisdiction…) and have customers sign in-browser or upload a wet-signed PDF. Seeded block bodies are EXAMPLES ONLY — review with your lawyer before sending.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.contracts.sidebar', 'Contracts')}
enabled={staged.contracts}
onToggle={(next) => setFlag('contracts', next)}
/>
<FeatureCard
icon={Receipt}
title={t('settings.features.bills.title', 'Bills')}
title={t('settings.features.bills.title', 'Invoices')}
description={t(
'settings.features.bills.description',
'Generate a bill from any accepted quote. Mark paid manually — no payment processor integration.',
'Generate an invoice from any accepted quote. Mark paid manually — no payment processor integration.',
)}
status="roadmap"
statusLabel={statusLabel('roadmap')}
sidebarLabel={t('settings.features.bills.sidebar', 'Bills')}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.bills.sidebar', 'Invoices')}
enabled={staged.bills}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
onToggle={(next) => setFlag('bills', next)}
/>
<FeatureCard
icon={Calculator}
title={t('settings.features.taxReport.title', 'Tax report')}
description={t(
'settings.features.taxReport.description',
'Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Export as PDF (landscape, company letterhead) or CSV for your accountant. Cancelled invoices stay visible for a gap-free audit trail but are excluded from totals.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.taxReport.sidebar', 'Tax')}
enabled={staged.taxReport}
onToggle={(next) => setFlag('taxReport', next)}
disabled={!staged.bills}
lockedReason={!staged.bills ? t(
'settings.features.taxReport.requiresBills',
'Enable Bills first — the tax report reads from your invoices.',
) : undefined}
/>
<FeatureCard
icon={Briefcase}
title={t('settings.features.hoursLogging.title', 'Hours logging')}
description={t(
'settings.features.hoursLogging.description',
'Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a "Create draft invoice" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.hoursLogging.sidebar', 'Hours')}
enabled={staged.hoursLogging}
onToggle={(next) => setFlag('hoursLogging', next)}
/>
</Section>
@@ -262,6 +328,20 @@ export const FeaturesTab: React.FC = () => {
'Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this.',
)}
/>
<FeatureCard
icon={Wrench}
title={t('settings.features.crmDevelopment.title', 'CRM developer tools')}
description={t(
'settings.features.crmDevelopment.description',
'Internal helpers for verifying CRM flows (e.g. fire the admin payment-check email instantly, bypass throttles). Surfaces as a "Development" sub-tab under Clients. Strictly opt-in — fires real side effects, use against test data only.',
)}
status="experimental"
statusLabel={statusLabel('experimental')}
sidebarLabel={t('settings.features.crmDevelopment.sidebar', 'Development')}
enabled={staged.crmDevelopment}
onToggle={(next) => setFlag('crmDevelopment', next)}
/>
</Section>
</Card>
@@ -322,6 +322,27 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.dateFormatHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.general.timeFormat', 'Time format')}
</label>
<select
value={generalSettings.time_format || '24h'}
onChange={(e) => {
const time_format = e.target.value === '12h' ? '12h' : '24h';
setGeneralSettings(prev => ({ ...prev, time_format }));
}}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="24h">{t('settings.general.timeFormat24h', '24-hour (14:30)')}</option>
<option value="12h">{t('settings.general.timeFormat12h', '12-hour (2:30 PM)')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.timeFormatHelp',
'Controls how times render across admin views, customer-facing pages, and PDFs. Storage stays 24-hour (HH:mm); only the display switches.')}
</p>
</div>
</div>
<div className="mt-6">
@@ -16,6 +16,7 @@ import { api } from '../../../config/api';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const BYTES_PER_GB = 1024 * 1024 * 1024;
@@ -55,6 +56,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
setOverrideDirty,
}) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { storageInfo, systemStatus } = useStatusTab(isActive);
const queryClient = useQueryClient();
@@ -614,7 +616,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
{systemStatus && (
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
{t('settings.systemStatus.lastUpdate')}: {fmtDateTime(systemStatus.timestamp)}
</div>
)}
</div>
@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const WEBHOOK_EVENT_TYPES = [
'event.created',
@@ -39,6 +40,7 @@ interface WebhookRow {
*/
export const WebhooksTab: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
@@ -304,13 +306,13 @@ export const WebhooksTab: React.FC = () => {
{lastEither === 'success' && lastSuccess && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{lastSuccess.toLocaleString()}
{fmtDateTime(lastSuccess)}
</span>
)}
{lastEither === 'failure' && lastFailure && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-3.5 h-3.5" />
{lastFailure.toLocaleString()}
{fmtDateTime(lastFailure)}
</span>
)}
{lastEither === 'none' && <span className="text-neutral-400"></span>}