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
+97 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastContainer } from 'react-toastify';
@@ -24,8 +24,29 @@ import {
UserManagementPage,
CustomerManagementPage,
CustomerDetailPage,
WebhookDeliveriesPage
WebhookDeliveriesPage,
// CRM routes (#TBD) — feature-flagged at the route layer via RequireFeature.
QuotesListPage,
QuoteEditorPage,
QuoteDetailPage,
BillsListPage,
BillEditorPage,
BillDetailPage,
} from './pages/admin';
import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage';
import { TaxReportPage } from './pages/admin/clients/TaxReportPage';
import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle
// (carved into its own chunk in vite.config.ts) doesn't ship with the
// main app. Only pages that visit /admin/clients/calendar fetch it.
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage';
import { PaymentCheckPage } from './pages/public/PaymentCheckPage';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import {
CustomerLoginPage,
@@ -36,13 +57,14 @@ import {
CustomerCalendarPage,
CustomerQuotesPage,
CustomerBillsPage,
CustomerContractsPage,
CustomerResetPasswordPage,
} from './pages/customer';
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { ClientsLayout } from './components/admin/ClientsLayout';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { usePublicSettings } from './hooks/usePublicSettings';
@@ -172,12 +194,67 @@ function App() {
<Route path="accounts" element={<CustomerManagementPage />} />
<Route path="accounts/:id" element={<CustomerDetailPage />} />
</Route>
{/* Quotes (CRM) — gated by `quotes`. */}
<Route element={<RequireFeature flag="quotes" />}>
<Route path="quotes" element={<QuotesListPage />} />
<Route path="quotes/new" element={<QuoteEditorPage />} />
<Route path="quotes/:id" element={<QuoteDetailPage />} />
<Route path="quotes/:id/edit" element={<QuoteEditorPage />} />
</Route>
{/* Bills / invoices (CRM) — gated by `bills`. */}
<Route element={<RequireFeature flag="bills" />}>
<Route path="bills" element={<BillsListPage />} />
<Route path="bills/new" element={<BillEditorPage />} />
<Route path="bills/:id" element={<BillDetailPage />} />
<Route path="bills/:id/edit" element={<BillEditorPage />} />
</Route>
{/* Contracts (CRM) — gated by `contracts`. Independent
of quotes/bills; a free-standing legal document
type composed from a library of reusable blocks. */}
<Route element={<RequireFeature flag="contracts" />}>
<Route path="contracts" element={<ContractsListPage />} />
<Route path="contracts/new" element={<ContractEditorPage />} />
<Route path="contracts/blocks" element={<BlockLibraryPage />} />
<Route path="contracts/:id" element={<ContractDetailPage />} />
<Route path="contracts/:id/edit" element={<ContractEditorPage />} />
</Route>
{/* Hour logging (standalone surface) — gated by
`hoursLogging`. Independent of `bills` so admin
can log hours before the full billing surface
is enabled. */}
<Route element={<RequireFeature flag="hoursLogging" />}>
<Route path="hours" element={<HoursLoggingPage />} />
</Route>
{/* Admin calendar (migration 137) — gated by
`calendar`. Lazy-loaded so the FullCalendar
bundle stays out of the main chunk. */}
<Route element={<RequireFeature flag="calendar" />}>
<Route
path="calendar"
element={
<Suspense fallback={<Loading />}>
<CalendarPage />
</Suspense>
}
/>
</Route>
{/* Tax / Steuer report — gated by `taxReport`. */}
<Route element={<RequireFeature flag="taxReport" />}>
<Route path="tax-report" element={<TaxReportPage />} />
</Route>
{/* Developer tools — gated by `crmDevelopment`. */}
<Route element={<RequireFeature flag="crmDevelopment" />}>
<Route path="development" element={<CrmDevelopmentPage />} />
</Route>
{/* Default: send /admin/clients (no sub-path) to
the first available sub-feature. Today that's
always accounts; when calendar/quotes ship they
get their own routes here and the empty-state
in ClientsLayout handles the rare "parent on,
all children off" case. */}
the first enabled sub-feature. accounts comes
first because it predates the others. The empty
state inside ClientsLayout handles "parent on,
all children off". */}
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
</Route>
</Route>
@@ -209,6 +286,17 @@ function App() {
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Public quote accept/decline page (CRM). Token-only,
no auth required. */}
<Route path="/quote/:token" element={<QuoteResponsePage />} />
<Route path="/contract/:token" element={<ContractResponsePage />} />
{/* Admin payment-check page (CRM) — token only,
no auth. Reached from the "Paid in full /
Partial / Not paid" buttons in the payment-
check email. */}
<Route path="/payment-check/:token" element={<PaymentCheckPage />} />
{/* Customer surface (#354). Strictly separate provider /
cookie / API surface from /admin/*. The customerPortal
feature flag hides the *admin-side* surfaces (sidebar
@@ -237,6 +325,7 @@ function App() {
<Route path="dashboard" element={<CustomerDashboardPage />} />
<Route path="calendar" element={<CustomerCalendarPage />} />
<Route path="quotes" element={<CustomerQuotesPage />} />
<Route path="contracts" element={<CustomerContractsPage />} />
<Route path="bills" element={<CustomerBillsPage />} />
<Route path="profile" element={<CustomerProfilePage />} />
</Route>