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
+92 -17
View File
@@ -35,10 +35,30 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
// brand row — suppress it here so it doesn't double up. left /
// center / right reposition the logo block within this header bar.
const logoPosition = brandingSettings?.branding_logo_position || 'left';
const logoInSidebar = logoPosition === 'sidepanel';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
// Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome.
const renderBrandBlock = () => (
<div className="flex items-center gap-2">
{!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
);
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -79,10 +99,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const unreadCount = notificationsData?.unreadCount || 0;
return (
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Left side - Menu button, Logo, and Date */}
// border-b is on the OUTER <header> so it spans the full header
// width — putting it on the inner row instead left a 32px gap on
// the left (the px-4/sm:px-6/lg:px-8 padding) before the divider
// started, visible as a missing segment between the sidebar's
// brand-row bottom border and the header's bottom border.
//
// The outer header is explicitly h-16 with the default border-box,
// so the 1px border is painted INSIDE the 64px height (y=63..64)
// — same model as the sidebar's brand row (also h-16 border-b
// border-box). Both bottom borders meet at the exact same
// y-coordinate.
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8 h-full">
<div className="relative flex items-center justify-between h-full gap-3">
{/* Left side - Menu button, optional left-positioned logo, Date.
Logo block appears here when logo_position = 'left' (the
default). For 'center' it's absolutely positioned across
the whole header; for 'right' it sits in the right-side
cluster just before the action widgets. */}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onMenuClick}
@@ -91,26 +126,66 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* Logo - sticky to the left on all sizes */}
<div className="flex items-center gap-2">
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
{!logoInSidebar && logoPosition === 'left' && renderBrandBlock()}
{/* Narrow-viewport fallback for center / right positions.
On screens where the centered (lg+) or right-anchored
(md+) brand block is hidden, render it on the left so
the admin chrome doesn't go logo-less on phones. */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="flex lg:hidden">{renderBrandBlock()}</div>
)}
{!logoInSidebar && logoPosition === 'right' && (
<div className="flex md:hidden">{renderBrandBlock()}</div>
)}
{/* Date display - hidden on smaller screens */}
<div className="hidden xl:block pl-3 border-l border-neutral-200 dark:border-neutral-700 ml-1">
<p className="text-base text-neutral-700 dark:text-neutral-300">
{/* Date display - hidden on smaller screens.
The vertical divider + left padding only render when
the logo sits on the left of the date (logo_position
= 'left'). For 'center' / 'right' / 'sidepanel' the
left cluster has only the mobile menu (which is
hidden on xl+), so a divider would be floating on
its own with nothing to separate. */}
{/* Explicit `flex items-center` (not just block) + the
self-stretch on the border-l variant so the divider
covers the full header row, AND the text baseline sits
exactly on the same y-axis as the brand-block / sidebar
logo to its left. The previous `hidden xl:block` left
the <p> inheriting its block-level vertical position,
which read as slightly off-centre next to the larger
logo image. */}
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
logoPosition === 'left' && !logoInSidebar
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
: ''
}`}>
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Right side actions */}
{/* Centered logo. Absolutely positioned so the existing
left/right clusters keep their natural sizing; hidden on
sub-lg widths to avoid colliding with the right-side
action cluster on narrow screens. pointer-events-none on
the wrapper passes hover/click through (the logo itself
has no interactive children today). */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
{renderBrandBlock()}
</div>
)}
{/* Right side actions (preceded by the brand block when
logo_position = 'right'). The right-anchored logo sits
before the language / dark-mode / notifications / user
cluster so the widgets stay where admins expect them. */}
<div className="flex items-center gap-3">
{!logoInSidebar && logoPosition === 'right' && (
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
{renderBrandBlock()}
</div>
)}
{/* Language Selector */}
<LanguageSelector />