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
@@ -6,6 +6,7 @@ import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface AdminGuestDetailProps {
eventId: number;
@@ -17,6 +18,7 @@ type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
@@ -159,7 +161,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
{c.photo.filename} · {fmtDateTime(c.created_at)}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
@@ -8,6 +8,7 @@ import { AdminGuestDetail } from './AdminGuestDetail';
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface AdminGuestsListProps {
eventId: number;
@@ -18,6 +19,7 @@ type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
@@ -271,7 +273,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{new Date(guest.last_seen_at).toLocaleDateString()}
{fmtDate(guest.last_seen_at)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
+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 />
+44 -8
View File
@@ -9,10 +9,23 @@ import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
});
const setSidebarCollapsed = (v: boolean) => {
setSidebarCollapsedState(v);
if (typeof window !== 'undefined') {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '1' : '0');
}
};
// Handle session timeout
useSessionTimeout();
@@ -40,6 +53,8 @@ export const AdminLayout: React.FC = () => {
<AdminLayoutInner
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
mustChangePassword={mustChangePassword}
/>
</FeatureFlagsProvider>
@@ -49,10 +64,12 @@ export const AdminLayout: React.FC = () => {
interface AdminLayoutInnerProps {
sidebarOpen: boolean;
setSidebarOpen: (v: boolean) => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
mustChangePassword: boolean;
}
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => {
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
return (
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
@@ -68,21 +85,40 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
{/* Sidebar - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<AdminSidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Main content. `scrollbar-gutter: stable` on the column itself
(via the inline style) reserves the scrollbar gutter once at
the column level — so the header sits in the full column
width AND lines up with the sidebar's right edge, while
<main>'s scroll content honors the same gutter and never
shifts when content overflows. Without this, the header and
main each made their own decisions about the gutter, leaving
a visible ~15px notch on the right edge of the header's
border between the column's content area and the scrollbar. */}
<div
className="flex-1 flex flex-col min-w-0 h-screen overflow-y-auto"
style={{ scrollbarGutter: 'stable' }}
>
{/* Header - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
</div>
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content - disabled when password change required */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Page content - disabled when password change required.
overflow moved up to the column so the scrollbar gutter is
reserved once at the column level (see above). main now
just contributes its content + padding. */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
<Outlet />
</main>
</div>
+142 -18
View File
@@ -9,6 +9,8 @@ import {
X,
Users,
Briefcase,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -16,10 +18,16 @@ import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
/** Desktop-only: collapse to icon-rail when true. Persisted by parent. */
collapsed?: boolean;
/** Desktop-only: toggle for the collapse button rendered in the title bar. */
onToggleCollapse?: () => void;
}
interface NavItem {
@@ -74,18 +82,45 @@ const navigation: NavItem[] = [
permission: 'customers.view',
featureFlag: 'clients',
// Hide the entry when the parent is on but no sub-feature is —
// there's nothing inside ClientsLayout to link to. Add future
// sub-flags (calendar, quotes, bills, messaging) here as they
// ship; the entry reappears the moment any of them is enabled.
featureFlagsAny: ['customerPortal'],
// there's nothing inside ClientsLayout to link to. Mirror the same
// set used to derive the parent `clients` flag in
// FeatureFlagsContext (see clientsDependsOn) so the two checks
// can't disagree: any sub-feature on lights up the entry, all off
// hides it. Future siblings (e.g. `messaging`) get appended here
// AND in the context derivation.
featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
'taxReport', 'hoursLogging', 'contracts', 'calendar',
],
},
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
const location = useLocation();
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const { flags } = useFeatureFlags();
// Branding lookup for the "logo_position = sidepanel" mode — when
// chosen, the logo replaces the "PicPeak Admin" text in the brand
// row, and the favicon takes over in the collapsed icon rail.
const { data: publicSettings } = usePublicSettings();
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
const rawLogoUrl = publicSettings?.branding_logo_url?.trim();
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
const resolvedLogoUrl = rawLogoUrl
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
: null;
const resolvedFaviconUrl = rawFaviconUrl
? (rawFaviconUrl.startsWith('http') ? rawFaviconUrl : buildResourceUrl(rawFaviconUrl))
: null;
// In collapsed rail, prefer the favicon (it's already a square,
// tight crop). Fall back to the logo when no favicon is set, then
// to nothing — better an empty rail than a stretched logo.
const sidebarBrandImageUrl = collapsed
? (resolvedFaviconUrl || resolvedLogoUrl)
: (resolvedLogoUrl || resolvedFaviconUrl);
const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl;
const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title');
const filteredNavigation = navigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false;
@@ -101,38 +136,99 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
return true;
});
// Desktop width: full nav (w-64) vs icon rail (w-16). Mobile is always
// w-64 since the collapse affordance only applies on lg+ viewports.
const widthClasses = collapsed ? 'w-64 lg:w-16' : 'w-64';
const showLabels = !collapsed;
return (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-700 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
// Right edge drawn via box-shadow rather than `border-r` so the
// brand row's `border-b` can extend to the sidebar's full width
// and meet the header's `border-b` cleanly at the L-junction. A
// 1px border-r would shrink the brand row's content by 1px and
// leave a visible step in the horizontal divider where the
// sidebar meets the main column. Shadow uses the same neutral
// border colors so it looks identical to the previous border.
className={`fixed inset-y-0 left-0 z-50 ${widthClasses} bg-white dark:bg-neutral-900 shadow-[1px_0_0_0_theme(colors.neutral.200)] dark:shadow-[1px_0_0_0_theme(colors.neutral.700)] transform transition-all duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-screen lg:h-full">
{/* Brand */}
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<div className="flex items-center">
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
{/* Brand row: title on the left, mobile close (X) on the
right. The desktop collapse toggle used to live here but
was moved down next to the version / storage widgets so
it sits in admins' muscle-memory zone for chrome controls.
When collapsed on desktop the title hides and the row
becomes an empty spacer (no rail-width fight). */}
<div className={`flex items-center h-16 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0 ${
collapsed ? 'lg:justify-center lg:px-2 px-6 justify-between' : 'justify-between px-6'
}`}>
<div className="flex items-center gap-2 min-w-0">
{showLogoBrand ? (
<>
{/* Logo brand variant — fed by Branding > Logo
Position = "Sidebar". On the collapsed rail, only
the favicon (or logo as fallback) is shown — sized
to fit the 64px-wide rail. Expanded shows the full
logo at the same h-8 the admin header uses for
visual continuity. */}
<img
src={sidebarBrandImageUrl!}
alt={brandAlt}
className={collapsed ? 'h-8 w-8 object-contain lg:h-9 lg:w-9' : 'h-8 w-auto object-contain max-w-full'}
/>
{/* On mobile the rail-narrow style only applies at
lg+, so when collapsed=true the mobile view still
has the regular w-64 width — show the company name
next to the logo so the brand row doesn't feel
empty there. */}
{collapsed && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden truncate">
{brandAlt}
</span>
)}
</>
) : (
<>
{showLabels && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
)}
{/* When collapsed on desktop the title is hidden; on mobile we
always show it because the rail-narrow style only applies at lg+ */}
{collapsed && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden">{t('admin.title')}</span>
)}
</>
)}
</div>
<button
onClick={onClose}
className="lg:hidden text-neutral-400 hover:text-neutral-600"
aria-label="Close sidebar"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
<nav className={`flex-1 py-4 space-y-1 overflow-y-auto overflow-x-hidden min-h-0 ${
collapsed ? 'px-4 lg:px-2' : 'px-4'
}`}>
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href ||
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
const label = t(item.nameKey);
return (
<NavLink
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
title={collapsed ? label : undefined}
className={`flex items-center py-2 text-sm font-medium rounded-lg transition-colors ${
collapsed ? 'px-3 lg:px-0 lg:justify-center' : 'px-3'
} ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
@@ -143,18 +239,46 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
.tile-selected pattern used in the customizer. The accent
-dark token defaults to the legacy primary green so users
who haven't set CI colours yet see no migration regression. */}
<item.icon className={`w-5 h-5 mr-3 ${
<item.icon className={`w-5 h-5 flex-shrink-0 ${
collapsed ? 'mr-3 lg:mr-0' : 'mr-3'
} ${
isActive ? 'text-white' : 'text-neutral-400'
}`} />
{t(item.nameKey)}
<span className={collapsed ? 'lg:hidden' : ''}>{label}</span>
</NavLink>
);
})}
</nav>
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
{/* Desktop collapse / expand toggle.
Lives directly above the version + storage widgets — sits
in admins' muscle-memory zone for chrome controls and
stays visible even when the sidebar is collapsed so the
rail can always be re-expanded. Hidden on mobile (the X
in the brand row already closes the sheet there). */}
{onToggleCollapse && (
<div className={`hidden lg:flex flex-shrink-0 border-t border-neutral-200 dark:border-neutral-700 py-2 ${
collapsed ? 'justify-center px-2' : 'justify-end px-4'
}`}>
<button
type="button"
onClick={onToggleCollapse}
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
title={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
>
{collapsed
? <PanelLeftOpen className="w-5 h-5" />
: <PanelLeftClose className="w-5 h-5" />}
</button>
</div>
)}
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
mobile keeps them visible because mobile width is always w-64. */}
{hasPermission('settings.view') && (
<div className="flex-shrink-0">
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
{/* Version Info */}
<VersionInfo />
+60 -15
View File
@@ -14,7 +14,7 @@
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog } from 'lucide-react';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -46,12 +46,57 @@ export const ClientsLayout: React.FC = () => {
icon: UserCog,
featureFlag: 'customerPortal',
},
// Add future sub-features here as they ship:
// { key: 'calendar', to: '/admin/clients/calendar', ... featureFlag: 'calendar' }
// { key: 'quotes', to: '/admin/clients/quotes', ... featureFlag: 'quotes' }
// { key: 'bills', to: '/admin/clients/bills', ... featureFlag: 'bills' }
// etc. The empty-state below disappears automatically once any of
// these is enabled.
{
key: 'calendar',
to: '/admin/clients/calendar',
label: t('clients.subnav.calendar', 'Calendar'),
icon: Calendar,
featureFlag: 'calendar',
},
{
key: 'quotes',
to: '/admin/clients/quotes',
label: t('clients.subnav.quotes', 'Quotes'),
icon: FileText,
featureFlag: 'quotes',
},
{
key: 'contracts',
to: '/admin/clients/contracts',
label: t('clients.subnav.contracts', 'Contracts'),
icon: ScrollText,
featureFlag: 'contracts',
},
{
key: 'hours',
to: '/admin/clients/hours',
label: t('clients.subnav.hours', 'Hours'),
icon: Clock,
featureFlag: 'hoursLogging',
},
{
key: 'bills',
to: '/admin/clients/bills',
label: t('clients.subnav.bills', 'Invoices'),
icon: Receipt,
featureFlag: 'bills',
},
{
key: 'tax-report',
to: '/admin/clients/tax-report',
label: t('clients.subnav.taxReport', 'Tax'),
icon: Calculator,
featureFlag: 'taxReport',
},
// Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' }
{
key: 'development',
to: '/admin/clients/development',
label: t('clients.subnav.development', 'Development'),
icon: Wrench,
featureFlag: 'crmDevelopment',
},
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
@@ -64,22 +109,22 @@ export const ClientsLayout: React.FC = () => {
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
{t('clients.title', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</p>
</div>
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Briefcase className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('clients.empty.title', 'No client features enabled')}
{t('clients.empty.title', 'No CRM features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'clients.empty.body',
'Enable Accounts (or another Clients sub-feature) under Settings → Features to get started.',
'Enable Accounts (or another CRM sub-feature) under Settings → Features to get started.',
)}
</p>
</div>
@@ -91,10 +136,10 @@ export const ClientsLayout: React.FC = () => {
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
{t('clients.title', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</p>
</div>
@@ -103,7 +148,7 @@ export const ClientsLayout: React.FC = () => {
in one tap on touch devices, no horizontal scroll. */}
<div className="lg:hidden">
<label htmlFor="clients-section" className="sr-only">
{t('clients.navAriaLabel', 'Clients navigation')}
{t('clients.navAriaLabel', 'CRM navigation')}
</label>
<select
id="clients-section"
@@ -120,7 +165,7 @@ export const ClientsLayout: React.FC = () => {
{/* Desktop: sticky left rail */}
<aside className="hidden lg:block">
<nav
aria-label={t('clients.navAriaLabel', 'Clients navigation')}
aria-label={t('clients.navAriaLabel', 'CRM navigation')}
className="sticky top-6 space-y-1"
>
{enabledItems.map((item) => {
@@ -0,0 +1,272 @@
/**
* CrmOverviewSection — headline metrics embedded into the main
* AdminDashboard for admins who use the CRM features.
*
* Feature-flag gating (three layers):
* - `clients` parent flag OFF → renders nothing
* - only `quotes` enabled → only the quote cards render
* - only `bills` enabled → only the invoice + revenue
* + outstanding cards render
* - both enabled → full section
*
* Numbers come from /api/admin/dashboard/crm-stats. Each card deep-
* links into the matching filtered list so the admin can drill in
* with one click.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
FileText, Send, CheckCircle2, XCircle, Clock,
Receipt, AlertTriangle, TrendingUp, Wallet,
} from 'lucide-react';
import { Card } from '../common';
import { fetchCrmOverview, type CrmOverviewStats } from '../../services/bills.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { formatMoneyMinor } from '../../utils/money';
// Local alias preserved so call-sites in this file keep their
// minor-units semantics. The unified helper handles the /100 conversion.
const formatMoney = formatMoneyMinor;
export const CrmOverviewSection: React.FC = () => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { data: publicSettings } = usePublicSettings();
// Outer gate: hide the entire CRM block when the parent feature
// flag is off. The query is also skipped so we don't hit the
// endpoint at all on non-CRM installs.
const clientsOn = !!flags.clients;
const quotesOn = clientsOn && !!flags.quotes;
const billsOn = clientsOn && !!flags.bills;
const anyCrm = quotesOn || billsOn;
// Per-tile visibility (admin pref in Settings → CRM behaviour). All
// default true; only explicit false hides the matching tile. We
// resolve via `!== false` so the very first render — before
// publicSettings finishes loading — shows everything, then settles.
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
// Compute which sub-sections actually render so we can skip the
// outer block entirely when the admin hid everything.
const billsRevenueRow = billsOn && (showRevenue || showOutstanding);
const quotesBlock = quotesOn && showQuotes;
const invoicesBlock = billsOn && showInvoices;
const { data, isLoading, isError } = useQuery({
queryKey: ['crm-overview'],
queryFn: () => fetchCrmOverview(),
enabled: anyCrm,
// Dashboard tile aggregates — admin opens this on every dashboard
// load; refetching the full aggregate on every mount adds DB load
// without observable benefit. 60s window covers the typical
// "click into a contract / click back" pattern.
staleTime: 60_000,
});
if (!anyCrm) return null;
// Admin hid every CRM tile — render nothing, including the heading.
if (!billsRevenueRow && !quotesBlock && !invoicesBlock) return null;
if (isLoading) return null;
if (isError || !data) {
// Surface a tiny inline notice when the section is enabled by
// flags but the API failed — silent renders make this hard to
// debug (the user reported "everything turned on but nothing
// shows" which traced back to a permission check on the
// backend). Keep it small so it doesn't disrupt the page.
return (
<section className="mt-8">
<h2 className="text-xl font-bold text-theme mb-2">
{t('crmOverview.title', 'CRM overview')}
</h2>
<p className="text-sm text-red-600">
{t('crmOverview.loadError',
'Could not load CRM stats. Check that you have bills.view or quotes.view permission and that the backend is on the latest build.')}
</p>
</section>
);
}
const d: CrmOverviewStats = data;
const cur = d.currency || 'CHF';
return (
<section className="mt-8 space-y-5">
<h2 className="text-xl font-bold text-theme">
{t('crmOverview.title', 'CRM overview')}
</h2>
{/* Revenue + outstanding (bills feature only). Revenue trio and
outstanding tile are gated independently — admins who only
want the outstanding figure (or vice versa) can hide either
via Settings → CRM behaviour. */}
{billsRevenueRow && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{showRevenue && (
<>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.month', 'Revenue · last 30 days')}
value={formatMoney(d.revenue.monthMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.quarter', 'Revenue · last 90 days')}
value={formatMoney(d.revenue.quarterMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.year', 'Revenue · last 365 days')}
value={formatMoney(d.revenue.yearMinor, cur)}
/>
</>
)}
{showOutstanding && (
<StatCard
icon={<Wallet className="w-5 h-5 text-red-600" />}
label={t('crmOverview.outstanding', 'Outstanding payments')}
value={formatMoney(d.outstanding.totalMinor, cur)}
sub={t('crmOverview.outstandingSub', '{{count}} invoice(s) unpaid', {
count: d.outstanding.invoiceCount,
})}
to="/admin/clients/bills?unpaidOnly=true"
/>
)}
</div>
)}
{/* Quotes pipeline (quotes feature only) */}
{quotesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('crmOverview.quotes.title', 'Quotes')}
</h3>
<Link to="/admin/clients/quotes" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('quotes.status.draft', 'Drafts')}
value={d.quotes.draft}
to="/admin/clients/quotes?status=draft"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('quotes.status.sent', 'Sent / open')}
value={d.quotes.sent}
to="/admin/clients/quotes?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('quotes.status.accepted', 'Accepted')}
value={d.quotes.accepted}
to="/admin/clients/quotes?status=accepted"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-red-600" />}
label={t('quotes.status.declined', 'Declined')}
value={d.quotes.declined}
to="/admin/clients/quotes?status=declined"
/>
<StatCard
icon={<Clock className="w-5 h-5 text-neutral-500" />}
label={t('quotes.status.expired', 'Expired')}
value={d.quotes.expired}
to="/admin/clients/quotes?status=expired"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-emerald-700" />}
label={t('quotes.status.converted', 'Converted')}
value={d.quotes.converted}
to="/admin/clients/quotes?status=converted"
/>
</div>
</div>
)}
{/* Invoices pipeline (bills feature only) */}
{invoicesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-5 h-5" />
{t('crmOverview.invoices.title', 'Invoices')}
</h3>
<Link to="/admin/clients/bills" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('bills.status.scheduled', 'Scheduled')}
value={d.invoices.scheduled}
to="/admin/clients/bills?status=scheduled"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('bills.status.sent', 'Sent / open')}
value={d.invoices.sent}
to="/admin/clients/bills?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('bills.status.paid', 'Paid')}
value={d.invoices.paid}
to="/admin/clients/bills?status=paid"
/>
<StatCard
icon={<AlertTriangle className="w-5 h-5 text-red-600" />}
label={t('bills.status.overdue', 'Overdue')}
value={d.invoices.overdue}
to="/admin/clients/bills?status=overdue"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-neutral-500" />}
label={t('bills.status.cancelled', 'Cancelled')}
value={d.invoices.cancelled}
to="/admin/clients/bills?status=cancelled"
/>
</div>
</div>
)}
</section>
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
to?: string;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
const inner = (
<Card padding="md" className="h-full">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icon}</div>
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-theme">{label}</div>
<div className="text-2xl font-bold tabular-nums mt-1">{value}</div>
{sub && <div className="text-xs text-muted-theme mt-1">{sub}</div>}
</div>
</div>
</Card>
);
if (to) {
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
}
return inner;
};
export default CrmOverviewSection;
@@ -5,9 +5,11 @@ import { toast } from 'react-toastify';
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
@@ -228,7 +230,7 @@ export const CssTemplateEditor: React.FC = () => {
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
{t('cssTemplates.lastUpdated', 'Last updated')}: {fmtDateTime(activeTemplate.updated_at)}
</p>
)}
</div>
@@ -0,0 +1,221 @@
/**
* Customer CRM panels — quotes + invoices history shown on the customer
* detail page. Each panel:
* - is gated by its global feature flag (`quotes` / `bills`); when the
* flag is off the panel doesn't render at all (no empty space)
* - shows the 10 most recent rows for that customer, with status
* badge, total and a click-through to the full document
* - exposes a "New …" button + a "Show all" link to the global list
* pre-filtered by this customer
*
* Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { quotesService } from '../../services/quotes.service';
import { billsService } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service';
import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface Props {
customerAccountId: number;
}
export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
const { flags } = useFeatureFlags();
return (
<>
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
</>
);
};
const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-quotes', customerAccountId],
queryFn: () => quotesService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
// Customer detail page mounts these three panels together. Without
// staleTime they all refetch on every visit + every queryClient
// touch elsewhere. 30s lets a quick tab-out/tab-in not re-hit the
// API; admin mutations invalidate the cache explicitly when they
// need fresh data.
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/quotes?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* "New quote" pre-fills via state on QuoteEditorPage when a
customerAccountId search-param is present (cheap follow-up
if you want it). For now the editor's customer picker
starts empty. */}
{/* Pre-fill this customer on the editor via search-param
so the admin doesn't have to retype it. The editor picks
it up on mount. */}
<Link to={`/admin/clients/quotes/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('quotes.new', 'New quote')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.quotes.map((q) => (
<li key={q.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/quotes/${q.id}`} className="text-theme hover:underline font-mono text-sm">
{q.quoteNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800'
: q.status === 'declined' ? 'bg-red-100 text-red-800'
: q.status === 'sent' ? 'bg-blue-100 text-blue-800'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`quotes.status.${q.status}`, q.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-contracts', customerAccountId],
queryFn: () => contractsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/contracts?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
<Link to={`/admin/clients/contracts/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('contracts.list.new', 'New contract')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.contracts.map((c) => (
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/contracts/${c.id}`} className="text-theme hover:underline font-mono text-sm">
{c.contractNumber}
</Link>
<span className="text-xs text-muted-theme ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`contracts.status.${c.status}`, c.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-invoices', customerAccountId],
queryFn: () => billsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/bills?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* Same prefill trick as quotes — see comment in QuotesPanel. */}
<Link to={`/admin/clients/bills/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('bills.new', 'New invoice')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.invoices.map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/bills/${inv.id}`} className="text-theme hover:underline font-mono text-sm">
{inv.invoiceNumber}
</Link>
<span className="text-xs text-muted-theme ml-2">
{fmtDate(inv.dueDate)}
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
inv.status === 'paid' ? 'bg-green-100 text-green-800'
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
: 'bg-amber-100 text-amber-800'
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
@@ -0,0 +1,214 @@
/**
* CustomerPicker — shared search-or-create surface for the three CRM
* editors (Quote, Bill, Contract).
*
* **Why this exists**
*
* The audit flagged that QuoteEditorPage, BillEditorPage, and
* ContractEditorPage each carried near-identical ~80-line customer
* picker blocks: a "currently selected" row when an id is present,
* a search-debounced lookup with passive-badge chips, and an
* "InlineCustomerCreate" expansion when the admin clicks "+ Create
* new customer". The three copies had drifted: passive badge text
* positioning differed, the contract variant used a bare `<input>`
* instead of the shared `<Input>` component, and the contract change
* link used `text-accent-dark hover:underline` instead of the
* Button-variant "outline" style the other two used.
*
* Behavior unified here matches the Quote and Bill variants (which
* were already in sync with each other); the contract variant's
* styling differences are folded in.
*
* **API**
*
* <CustomerPicker
* value={customerAccountId} // number | null
* label={customerLabel} // pre-formatted display label
* isPassive={customerIsPassive} // boolean
* onSelect={(c) => { ... }} // CustomerSummary from search
* onCreate={(c) => { ... }} // CustomerAccountDetail from inline create
* onClear={() => { ... }} // user clicked "Change"
* readOnly={false} // contract editor uses true on edit
* />
*
* The component owns the `customerSearch` state + the debounced
* useQuery against customerAdminService.search and the "+ Create new
* customer" expansion toggle. Parents own the canonical
* `customerAccountId / label / isPassive` triple because each editor
* stores them differently (Quote nests them inside a form object,
* Bill + Contract use separate useStates). Keeping the triple owned
* by the parent avoids a forced shape migration.
*
* **Selection vs creation callbacks**
*
* `onSelect` receives the CustomerSummary shape from the search
* endpoint (id, email, displayName, companyName, firstName, lastName,
* isPassive). `onCreate` receives the full CustomerAccountDetail
* because some editors want to inherit additional fields from a
* freshly-created customer (e.g. Quote inherits the new customer's
* preferredLanguage so the doc renders in their locale by default).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Button, Input } from '../common';
import { InlineCustomerCreate } from './InlineCustomerCreate';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerAccountSummary,
} from '../../services/customerAdmin.service';
// Alias for clarity at the call-site: search returns the Summary shape.
export type CustomerSummary = CustomerAccountSummary;
export interface CustomerPickerProps {
value: number | null;
label: string;
isPassive: boolean;
onSelect: (customer: CustomerSummary) => void;
onCreate: (customer: CustomerAccountDetail) => void;
onClear: () => void;
/**
* Read-only mode: render only the selected-row chip, hide search +
* create + change. Used by the contract editor in edit mode where
* the customer is locked to whatever the contract was created with.
*/
readOnly?: boolean;
/**
* Placeholder override for the search box. Defaults to the i18n
* `crm.customerPicker.search` key with an EN fallback. Specific
* editors can pass a doc-type-flavoured label.
*/
searchPlaceholder?: string;
/**
* F.6 — surface a feature-gate badge so the admin sees up front that
* selecting a particular customer won't work for this surface (e.g.
* the calendar's hour-entry drag-create modal would 409 the backend
* on a customer whose `feature_hours_logging` is OFF).
* Currently only 'hoursLogging' is supported; pass undefined to
* skip the badge entirely (default for quote / bill / contract
* editors which don't care about hour-logging eligibility).
*/
requireFeature?: 'hoursLogging';
}
export const CustomerPicker: React.FC<CustomerPickerProps> = ({
value,
label,
isPassive,
onSelect,
onCreate,
onClear,
readOnly = false,
searchPlaceholder,
requireFeature,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
// Debounce the search term before it hits the API. The previous
// shape fired one search request per keystroke; on a passive-
// customer list of 500+ rows, that's hundreds of /api/admin/customers
// calls during a single look-up. 250ms is below the perceptual
// threshold for typing.
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(search), 250);
return () => window.clearTimeout(handle);
}, [search]);
const { data: options = [] } = useQuery({
queryKey: ['crm-customer-picker', debouncedSearch],
queryFn: () => customerAdminService.search(debouncedSearch),
enabled: !readOnly && !value && !creating && debouncedSearch.trim().length >= 2,
});
if (value) {
return (
<div className="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 rounded-md px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm">{label || `#${value}`}</span>
{isPassive && (
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
</div>
{!readOnly && (
<Button variant="outline" size="sm" onClick={onClear}>
{t('common.change', 'Change')}
</Button>
)}
</div>
);
}
if (creating) {
return (
<InlineCustomerCreate
onCancel={() => setCreating(false)}
onCreated={(c) => {
onCreate(c);
setCreating(false);
}}
/>
);
}
return (
<>
<Input
placeholder={searchPlaceholder
|| (t('crm.customerPicker.search', 'Search customer by email or company…') as string)}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{options.length > 0 && (
<ul className="mt-2 rounded-md border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
{options.map((c) => {
// F.6 — gate badge for the calendar's hour-entry create
// modal. Selecting a customer with feature_hours_logging
// OFF would 409 the backend; warn up front. We still allow
// the click so the admin can open the customer's detail
// page to flip the flag from a separate tab.
const hourLoggingOff =
requireFeature === 'hoursLogging' && c.featureHoursLogging === false;
return (
<li key={c.id}>
<button
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<span className="font-medium">
{c.companyName || c.displayName || c.email}
</span>
<span className="text-neutral-500 ml-2">{c.email}</span>
{c.isPassive && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
{hourLoggingOff && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">
{t('customers.hoursLoggingDisabled.badge', 'Hour logging disabled')}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
<button
type="button"
onClick={() => setCreating(true)}
className="mt-3 inline-flex items-center gap-1 text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
{t('customers.create.openLink', '+ Create new customer')}
</button>
</>
);
};
@@ -0,0 +1,313 @@
/**
* <DocumentLineageCard> — flat list of every document sharing one
* `deal_uuid` (migration 140), grouped by type. Renders on the three
* detail pages (Quote / Contract / Bill) so the admin sees the
* complete chain — quotes + contracts + invoices (incl. Storno /
* reissue / installment siblings) — without walking individual FKs.
*
* Data comes from `GET /api/admin/deals/:uuid/documents` (commit #3).
* The card is purely presentational; the parent passes the dealUuid
* and a `currentId` so the row representing "the document you're
* looking at" can be highlighted instead of linked.
*
* When no other documents share the deal (newly-created standalone
* doc), the card renders a compact "no related documents" line
* instead of three empty groups — keeps the detail page from looking
* busy on the common case.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { FileText, ScrollText, Receipt, AlertTriangle, Pencil } from 'lucide-react';
import { Button, Card } from '../common';
import { formatMoneyMinor } from '../../utils/money';
import { api } from '../../config/api';
import { EditInstallmentPlanModal } from './EditInstallmentPlanModal';
export interface DocumentLineageCardProps {
dealUuid: string | null | undefined;
/** Which document you're currently looking at. The matching row
* renders muted (not a link) so the admin doesn't navigate to the
* page they're already on. */
current: { kind: 'quote' | 'contract' | 'invoice'; id: number };
className?: string;
}
interface DealItemBase {
id: number;
number: string;
status: string;
currency?: string;
totalAmountMinor?: number;
issueDate?: string;
eventName?: string | null;
createdAt?: string;
}
interface DealQuoteItem extends DealItemBase { kind: 'quote'; validUntil?: string; }
interface DealContractItem extends DealItemBase { kind: 'contract'; title?: string | null; validUntil?: string; }
interface DealInvoiceItem extends DealItemBase {
kind: 'invoice';
invoiceKind: 'invoice' | 'storno';
paidAmountMinor?: number;
dueDate?: string;
eventDate?: string | null;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
isMonthlyDraft?: boolean;
}
interface DealLineageResponse {
dealUuid: string;
quotes: DealQuoteItem[];
contracts: DealContractItem[];
invoices: DealInvoiceItem[];
}
const EDITABLE_PLAN_STATUSES = new Set(['scheduled', 'pending_delivery']);
export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
dealUuid, current, className = '',
}) => {
const { t } = useTranslation();
const [showEditPlan, setShowEditPlan] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['deal-lineage', dealUuid],
queryFn: async () => {
const res = await api.get(`/admin/deals/${dealUuid}/documents`);
return (res.data.data || res.data) as DealLineageResponse;
},
enabled: !!dealUuid,
staleTime: 30_000,
});
if (!dealUuid) return null;
if (isLoading) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-muted-theme">
{t('dealLineage.loading', 'Loading related documents…')}
</p>
</Card>
);
}
if (error) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-red-700 dark:text-red-300 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dealLineage.error', 'Could not load related documents.')}
</p>
</Card>
);
}
if (!data) return null;
const { quotes, contracts, invoices } = data;
const totalCount = quotes.length + contracts.length + invoices.length;
// Reshape gesture is offered when this deal holds a multi-installment
// plan AND every invoice is still pre-customer. Server re-checks on
// save; if a sibling shipped between render and click, the 409 path
// in the modal handles the race.
const hasMultiInstallment = invoices.some((i) => (i.installmentTotal || 0) > 1);
const allInvoicesEditable = invoices.length > 0
&& invoices.every((i) => i.invoiceKind !== 'storno'
&& EDITABLE_PLAN_STATUSES.has(i.status));
const canEditPlan = hasMultiInstallment && allInvoicesEditable;
// Only ONE doc total = the current one. No siblings to surface.
if (totalCount <= 1) {
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-1 flex items-center gap-2">
{t('dealLineage.title', 'Related documents')}
</h2>
<p className="text-xs text-muted-theme">
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
</p>
</Card>
);
}
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-3">
{t('dealLineage.title', 'Related documents')}
</h2>
{quotes.length > 0 && (
<Group
icon={<FileText className="w-4 h-4" />}
label={t('dealLineage.quotes', 'Quotes')}
count={quotes.length}
>
{quotes.map((q) => (
<Row
key={`q-${q.id}`}
isCurrent={current.kind === 'quote' && current.id === q.id}
href={`/admin/clients/quotes/${q.id}`}
number={q.number}
statusKey={`quotes.status.${q.status}`}
statusFallback={q.status}
right={q.totalAmountMinor != null && q.currency
? formatMoneyMinor(q.totalAmountMinor, q.currency)
: null}
meta={q.eventName || undefined}
/>
))}
</Group>
)}
{contracts.length > 0 && (
<Group
icon={<ScrollText className="w-4 h-4" />}
label={t('dealLineage.contracts', 'Contracts')}
count={contracts.length}
>
{contracts.map((c) => (
<Row
key={`c-${c.id}`}
isCurrent={current.kind === 'contract' && current.id === c.id}
href={`/admin/clients/contracts/${c.id}`}
number={c.number}
statusKey={`contracts.status.${c.status}`}
statusFallback={c.status}
meta={c.title || c.eventName || undefined}
/>
))}
</Group>
)}
{invoices.length > 0 && (
<Group
icon={<Receipt className="w-4 h-4" />}
label={t('dealLineage.invoices', 'Invoices')}
count={invoices.length}
action={canEditPlan ? (
<Button
variant="outline"
size="sm"
onClick={() => setShowEditPlan(true)}
leftIcon={<Pencil className="w-3.5 h-3.5" />}
className="ml-auto"
>
{t('dealLineage.editPlan', 'Edit plan')}
</Button>
) : undefined}
>
{invoices.map((i) => {
const isStorno = i.invoiceKind === 'storno';
const installmentTag = i.installmentTotal && i.installmentTotal > 1
? ` · ${i.installmentLabel || `${i.installmentIndex! + 1}/${i.installmentTotal}`}`
: '';
return (
<Row
key={`i-${i.id}`}
isCurrent={current.kind === 'invoice' && current.id === i.id}
href={`/admin/clients/bills/${i.id}`}
number={i.number}
statusKey={`bills.status.${i.status}`}
statusFallback={i.status}
right={i.totalAmountMinor != null && i.currency
? formatMoneyMinor(i.totalAmountMinor, i.currency)
: null}
badge={isStorno ? t('bills.kind.storno', 'Storno') as string : undefined}
meta={installmentTag.replace(/^ · /, '') || undefined}
/>
);
})}
</Group>
)}
{canEditPlan && dealUuid && (
<EditInstallmentPlanModal
isOpen={showEditPlan}
onClose={() => setShowEditPlan(false)}
dealUuid={dealUuid}
siblings={invoices.map((i) => ({
id: i.id,
number: i.number,
status: i.status,
totalAmountMinor: i.totalAmountMinor,
installmentIndex: i.installmentIndex,
installmentTotal: i.installmentTotal,
installmentLabel: i.installmentLabel,
installmentTrigger: i.installmentTrigger,
installmentOffsetDays: i.installmentOffsetDays,
}))}
eventDate={invoices.find((i) => i.eventDate)?.eventDate || null}
/>
)}
</Card>
);
};
const Group: React.FC<{
icon: React.ReactNode;
label: string;
count: number;
children: React.ReactNode;
action?: React.ReactNode;
}> = ({ icon, label, count, children, action }) => (
<div className="mb-3 last:mb-0">
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-theme mb-1">
{icon}
<span>{label}</span>
<span>({count})</span>
{action}
</div>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{children}
</ul>
</div>
);
const Row: React.FC<{
isCurrent: boolean;
href: string;
number: string;
statusKey: string;
statusFallback: string;
right?: string | null;
meta?: string;
badge?: string;
}> = ({ isCurrent, href, number, statusKey, statusFallback, right, meta, badge }) => {
const { t } = useTranslation();
const inner = (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className={`font-mono text-sm ${isCurrent ? 'text-muted-theme' : ''}`}>{number}</span>
{badge && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{badge}
</span>
)}
{meta && (
<span className="text-xs text-muted-theme truncate">{meta}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{right && <span className="tabular-nums">{right}</span>}
<span className="text-muted-theme">{t(statusKey, statusFallback)}</span>
</div>
</div>
);
if (isCurrent) {
return <li className="opacity-60">{inner}</li>;
}
return (
<li>
<Link to={href} className="block hover:bg-neutral-50 dark:hover:bg-neutral-800/40 -mx-2 px-2 rounded">
{inner}
</Link>
</li>
);
};
export default DocumentLineageCard;
@@ -0,0 +1,209 @@
/**
* <EditInstallmentPlanModal> — atomic reshape of an installment plan
* after siblings have spawned. Wraps the same `<InstallmentsPanel>`
* used by Quote/Bill editors, pre-filled from the existing siblings
* (via the lineage payload — no extra fetch needed).
*
* Triggered from `<DocumentLineageCard>`, which only renders the
* "Edit plan" button when every invoice on the deal is still
* scheduled / pending_delivery (the same gate enforced server-side).
* The server still re-checks on save; a 409 INVOICE_LOCKED races back
* if a sibling shipped between modal open and save click.
*
* Plan total preservation: the backend uses sum(existing sibling totals)
* as the plan total, so the panel doesn't need to surface money — only
* the structure (percents / labels / triggers / offsets).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { X, AlertTriangle } from 'lucide-react';
import { Button, Card } from '../common';
import { InstallmentsPanel } from './InstallmentsPanel';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { dealsService } from '../../services/deals.service';
export interface EditInstallmentPlanModalProps {
isOpen: boolean;
onClose: () => void;
dealUuid: string;
/** Existing sibling invoices on the deal — used to seed the panel
* and to derive the current plan total for percent computation. */
siblings: Array<{
id: number;
number: string;
status: string;
totalAmountMinor?: number;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
}>;
/** Event date — passed to <InstallmentsPanel> so its date preview
* works for before_event / after_event rows. */
eventDate?: string | null;
onSaved?: () => void;
}
const VALID_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function isValidTrigger(t: unknown): t is PaymentTermInstallment['trigger'] {
return typeof t === 'string' && (VALID_TRIGGERS as string[]).includes(t);
}
export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> = ({
isOpen, onClose, dealUuid, siblings, eventDate, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Derive the initial panel rows from the existing siblings, sorted
// by installment_index. Percent = total_amount_minor / sum * 100.
const initialPlan = useMemo<PaymentTermInstallment[]>(() => {
const sorted = [...siblings].sort(
(a, b) => (a.installmentIndex ?? 0) - (b.installmentIndex ?? 0),
);
const sum = sorted.reduce((s, x) => s + (x.totalAmountMinor || 0), 0);
if (sum === 0) {
// Degenerate: every sibling totals zero. Fall back to equal split.
return sorted.map((s, i) => ({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: Math.round(10000 / sorted.length) / 100,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
}));
}
// Compute percents, last slice absorbs rounding so the panel reports
// sum=100 on open.
const rows: PaymentTermInstallment[] = [];
let accPct = 0;
sorted.forEach((s, i) => {
let pct = i === sorted.length - 1
? Math.max(0, 100 - accPct)
: Math.round(((s.totalAmountMinor || 0) / sum) * 10000) / 100;
pct = Math.round(pct * 100) / 100;
accPct += pct;
rows.push({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: pct,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
});
});
return rows;
}, [siblings]);
const [plan, setPlan] = useState<PaymentTermInstallment[] | null>(initialPlan);
const [valid, setValid] = useState(true);
// Reseed when the modal opens against fresh siblings.
useEffect(() => {
if (isOpen) setPlan(initialPlan);
}, [isOpen, initialPlan]);
const save = useMutation({
mutationFn: async () => {
if (!plan || plan.length === 0) {
throw new Error(t('dealLineage.editPlanEmpty', 'Plan cannot be empty.') as string);
}
return dealsService.updateInstallmentPlan(dealUuid, plan);
},
onSuccess: () => {
toast.success(t('dealLineage.editPlanSuccess', 'Installment plan updated.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
queryClient.invalidateQueries({ queryKey: ['adminBills'] });
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
onSaved?.();
onClose();
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { error?: string; code?: string } }; message?: string };
const code = e?.response?.data?.code;
if (code === 'INVOICE_LOCKED' || code === 'PLAN_HAS_STORNO') {
toast.error(t('dealLineage.editPlanLocked',
'Plan can no longer be edited — at least one invoice has shipped or been cancelled.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
onClose();
return;
}
if (code === 'PERCENT_SUM_INVALID') {
toast.error(t('dealLineage.editPlanPercentSumError', 'Percents must sum to 100.'));
return;
}
toast.error(
e?.response?.data?.error
|| e?.message
|| t('dealLineage.editPlanGeneralError', 'Could not update plan.'),
);
},
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div>
<h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2>
<p className="text-xs text-muted-theme mt-1">
{t('dealLineage.editPlanHelp',
'Atomically reshape this plan: change percents, labels, triggers, add or remove rows. The plan total stays fixed; existing invoice numbers are kept where possible. Refused once any invoice has shipped.')}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={save.isPending}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
aria-label={t('common.close', 'Close') as string}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-3 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('dealLineage.editPlanWarning',
'Trimming rows deletes their invoice numbers (the sequence cannot release them — a §14 UStG continuity rule). Adding rows claims fresh numbers.')}
</p>
</div>
<InstallmentsPanel
value={plan}
onChange={(next) => setPlan(next || [])}
onValidityChange={setValid}
eventDate={eventDate || null}
/>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={save.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending || !valid || !plan || plan.length === 0}
>
{t('dealLineage.editPlanSave', 'Save plan')}
</Button>
</div>
</Card>
</div>
);
};
export default EditInstallmentPlanModal;
@@ -0,0 +1,177 @@
/**
* <EventReminderOverrideCard>
*
* Per-event override for the pre-event customer reminder (migration
* 143). Mounted once on the EventDetailsPage; admin can:
* - Disable the reminder for THIS event only (no global flip)
* - Override the global "days before" offset (null = inherit)
* - Provide a custom body that overrides the resolved template's
* body for THIS event only (subject still comes from the template)
*
* Saves through the existing PUT /api/admin/events/:id endpoint with
* the three new whitelisted fields (added 2026-05-25):
* - event_reminder_disabled (bool)
* - event_reminder_offset_days (int | null)
* - event_reminder_body_override (string | null)
*
* Behaves correctly on pre-migration installs: if the event object
* doesn't carry the new fields, the form starts blank and "Reset to
* default" is a no-op until admin saves something.
*
* Strings: every label / hint / button goes through `t()` with a
* fallback. The maintainer flagged "no hard coded i18n" in commit #5
* — applying that convention strictly from here forward.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Bell, BellOff, Save } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { api } from '../../config/api';
export interface EventReminderOverrideCardProps {
eventId: number;
/** Initial values from the event row; null when unset. */
initial: {
event_reminder_disabled?: boolean;
event_reminder_offset_days?: number | null;
event_reminder_body_override?: string | null;
};
/** Optional callback so the parent can refresh its event query
* after a save. */
onSaved?: () => void;
}
export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps> = ({
eventId, initial, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
const [offsetDays, setOffsetDays] = useState<string>(
initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days),
);
const [bodyOverride, setBodyOverride] = useState<string>(initial.event_reminder_body_override || '');
// Reset local state if the parent's `initial` changes (e.g. after
// the event refetches following an unrelated save).
useEffect(() => {
setDisabled(!!initial.event_reminder_disabled);
setOffsetDays(initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days));
setBodyOverride(initial.event_reminder_body_override || '');
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, initial.event_reminder_body_override]);
const save = useMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {
event_reminder_disabled: disabled,
};
// Empty string → null clears the override and inherits global.
if (offsetDays.trim() === '') {
payload.event_reminder_offset_days = null;
} else {
const n = Number(offsetDays);
if (!Number.isFinite(n) || n < 0) {
throw new Error(t('eventReminderOverride.invalidOffset',
'Offset must be a non-negative integer or blank.') as string);
}
payload.event_reminder_offset_days = Math.floor(n);
}
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
await api.put(`/admin/events/${eventId}`, payload);
},
onSuccess: () => {
toast.success(t('eventReminderOverride.saved', 'Reminder override saved.'));
queryClient.invalidateQueries({ queryKey: ['admin-event', eventId] });
queryClient.invalidateQueries({ queryKey: ['adminEvent', eventId] });
onSaved?.();
},
onError: (err: unknown) => {
const e = err as { message?: string; response?: { data?: { error?: string } } };
toast.error(
e?.response?.data?.error
|| e?.message
|| t('eventReminderOverride.saveError', 'Could not save reminder override.'),
);
},
});
return (
<Card padding="lg" className="mt-4">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div className="flex items-center gap-2">
{disabled
? <BellOff className="w-5 h-5 text-muted-theme" aria-hidden />
: <Bell className="w-5 h-5" aria-hidden />}
<h2 className="text-lg font-semibold">
{t('eventReminderOverride.title', 'Pre-event reminder')}
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('eventReminderOverride.save', 'Save override')}
</Button>
</div>
<p className="text-xs text-muted-theme mb-3">
{t('eventReminderOverride.help',
'Per-event override for the customer reminder. Global on-off + default offset live under Settings → Reminder emails. Anything left blank here inherits the global setting / resolved template.')}
</p>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={disabled}
onChange={(e) => setDisabled(e.target.checked)}
/>
{t('eventReminderOverride.disabledLabel',
'Disable the reminder for this event (no email goes out)')}
</label>
<div>
<Input
type="number"
min={0}
max={365}
label={t('eventReminderOverride.offsetLabel',
'Days before the event (override) — leave blank to inherit') as string}
value={offsetDays}
onChange={(e) => setOffsetDays(e.target.value)}
placeholder={t('eventReminderOverride.offsetPlaceholder',
'Leave blank to use the global default') as string}
disabled={disabled}
className="md:w-80"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('eventReminderOverride.bodyOverrideLabel',
'Custom body for this event (overrides the resolved template body)')}
</label>
<textarea
rows={6}
className="input w-full text-sm"
placeholder={t('eventReminderOverride.bodyOverridePlaceholder',
'Leave blank to use the template body. Variables like {{customer_name}}, {{event_name}}, {{event_date}} still work here.') as string}
value={bodyOverride}
onChange={(e) => setBodyOverride(e.target.value)}
disabled={disabled}
/>
</div>
</div>
</Card>
);
};
export default EventReminderOverrideCard;
@@ -0,0 +1,407 @@
/**
* Hours section card (migration 129).
*
* Used in two places:
* 1. CustomerDetailPage — rendered when the per-customer
* `feature_hours_logging` flag is on AND the master `hoursLogging`
* flag is on. Sits between the features card and account actions.
* 2. The standalone /admin/clients/hours page — admin picks ANY
* customer with hours logging enabled, then sees this card.
*
* Wraps customerAdminService.{list,create,delete,billUnbilled}HourEntries.
* All writes go through react-query invalidation so the entry list
* refreshes after every action.
*/
import React, { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Clock } from 'lucide-react';
import { Button, Card } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export interface HoursSectionProps {
customerId: number;
customerHourlyRateMinor: number | null;
billingCadence: 'per_event' | 'monthly' | 'quarterly';
onHourlyRateChange?: (next: number | null) => void;
/**
* When true, render only the entry-history table + the per-event
* "Bill these hours" action. Hides the inline log-entry form and
* the default-rate input. Used on the customer detail page now
* that logging itself lives on the standalone /admin/clients/hours
* surface — the detail page becomes a read-only history view with
* the on-demand bill action for per-event customers.
*/
compact?: boolean;
}
export const HoursSection: React.FC<HoursSectionProps> = ({
customerId, customerHourlyRateMinor, billingCadence, onHourlyRateChange, compact,
}) => {
const { t } = useTranslation();
const qc = useQueryClient();
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
// Safari/Firefox follow OS locale and ignore this — that's a browser
// limitation, not something we can fix in the page. The underlying
// value stays HH:mm (24h) regardless of how the picker presents it.
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [duration, setDuration] = useState<string>('');
const [rateOverride, setRateOverride] = useState<string>('');
const [description, setDescription] = useState('');
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
// the end-time jumps to start + duration. Pure convenience; the End
// input still works for explicit times. Empty / unparseable input is
// a no-op so a typo doesn't overwrite a freshly-edited End.
const applyDuration = (raw: string) => {
const minutes = parseDuration(raw);
if (minutes == null) return;
const [hh, mm] = startTime.split(':').map(Number);
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return;
const totalEnd = Math.min(hh * 60 + mm + minutes, 24 * 60 - 1);
const eh = Math.floor(totalEnd / 60).toString().padStart(2, '0');
const em = (totalEnd % 60).toString().padStart(2, '0');
setEndTime(`${eh}:${em}`);
};
const { data: entries = [], isLoading } = useQuery({
queryKey: ['admin-customer-hour-entries', customerId],
queryFn: () => customerAdminService.listHourEntries(customerId),
enabled: Number.isFinite(customerId) && customerId > 0,
});
// Pull the configured default currency so the hint can show
// "{{currency}} 150" instead of the hardcoded "CHF 150". Same cache
// key as CustomerDetailPage so a single round-trip serves both
// mount points. 5-minute stale window — the value changes via
// Settings → Business profile, not during a hours-logging session.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
const createMutation = useMutation({
mutationFn: () => customerAdminService.createHourEntry(customerId, {
entryDate, startTime, endTime,
// Locale-tolerant: "12,50" and "12.50" both yield 1250.
hourlyRateMinorOverride: (() => {
if (!rateOverride) return null;
const n = parseLocaleDecimal(rateOverride);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
})(),
description: description || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
setStartTime('09:00');
setEndTime('10:00');
setDuration('');
setRateOverride('');
setDescription('');
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
toast.error(msg);
},
});
const deleteMutation = useMutation({
mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.deleted', 'Entry deleted'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to delete entry');
},
});
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
});
// Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the
// CustomerDetailPage form state changing) doesn't reshuffle the
// entries array in JS on every keystroke.
const { unbilledCount, unbilledTotalMajor } = useMemo(() => {
let count = 0;
let minor = 0;
for (const e of entries) {
if (e.status !== 'unbilled') continue;
count += 1;
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
minor += rateMinor * e.durationMinutes / 60;
}
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
}, [entries, customerHourlyRateMinor]);
const isMonthly = billingCadence === 'monthly';
// Local lockout check — mirrors customerHoursService.isEntryLocked
// so the delete button can be disabled before the request is sent.
const isLocked = (entry: typeof entries[number]) => {
if (!entry.invoiceId) return false;
if (entry.invoiceIsMonthlyDraft) return false;
if (entry.invoiceStatus !== 'scheduled') return true;
if (!entry.invoiceScheduledSendAt) return false;
return new Date(entry.invoiceScheduledSendAt).getTime() <= Date.now();
};
return (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('customers.hours.section', 'Hours')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{isMonthly
? t('customers.hours.monthlyHint',
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
: t('customers.hours.perEventHint',
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
</p>
{/* Default rate — hidden in compact mode (history-only on the
customer detail page; admin edits the rate elsewhere). */}
{!compact && (
<div className="mb-4">
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.field.hourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
if (!onHourlyRateChange) return;
if (!Number.isFinite(n)) {
onHourlyRateChange(null);
return;
}
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
}}
disabled={!onHourlyRateChange}
className="w-40 input"
placeholder="150.00"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
</div>
)}
{/* Inline log-new-entry form — hidden in compact mode. Logging
lives on the standalone /admin/clients/hours surface. */}
{!compact && (
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mb-4">
<h3 className="text-sm font-semibold mb-3">{t('customers.hours.form.title', 'Log new entry')}</h3>
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.date', 'Date')}
</label>
<input type="date" value={entryDate}
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.start', 'Start')}
</label>
<input type="time" lang={timeInputLang} value={startTime}
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.end', 'End')}
</label>
<input type="time" lang={timeInputLang} value={endTime}
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.duration', 'Duration')}
</label>
<input
type="text"
inputMode="decimal"
value={duration}
onChange={(e) => setDuration(e.target.value)}
onBlur={(e) => applyDuration(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyDuration((e.target as HTMLInputElement).value);
}
}}
placeholder={t('customers.hours.form.durationPlaceholder', '1h · 1.5 · 1:30') as string}
title={t('customers.hours.form.durationHint',
'Type a duration to auto-fill End: 1h, 1.5, 1,5 or 1:30') as string}
className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.rateOverride', 'Rate override')}
</label>
<input
type="text"
inputMode="decimal"
value={rateOverride}
onChange={(e) => setRateOverride(e.target.value)}
placeholder={customerHourlyRateMinor != null
? (customerHourlyRateMinor / 100).toFixed(2)
: '—'}
className="input w-full" />
</div>
</div>
<div className="mt-3">
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.note', 'Note / description')}
</label>
<textarea rows={2} value={description}
onChange={(e) => setDescription(e.target.value)}
className="input w-full text-sm"
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={createMutation.isPending}
isLoading={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('customers.hours.form.save', 'Add entry')}
</Button>
</div>
</div>
)}
{/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm">
{t('customers.hours.unbilledCount',
'{{count}} unbilled entries totaling {{total}}',
{
count: unbilledCount,
total: unbilledTotalMajor.toFixed(2),
})}
</span>
<Button
variant="primary"
disabled={billMutation.isPending}
isLoading={billMutation.isPending}
onClick={() => billMutation.mutate()}
>
{t('customers.hours.billButton', 'Create draft invoice')}
</Button>
</div>
)}
{/* Entry list table. */}
{isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.hours.empty', 'No entries logged yet.')}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-muted-theme">
<th className="py-2 pr-3">{t('customers.hours.col.date', 'Date')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.range', 'Time')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.hours', 'Hours')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.rate', 'Rate')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.total', 'Total')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.note', 'Note')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.status', 'Status')}</th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
const hours = e.durationMinutes / 60;
const total = (hours * rate) / 100;
const locked = isLocked(e);
return (
<tr key={e.id} className="border-t border-neutral-200 dark:border-neutral-700">
<td className="py-1.5 pr-3 tabular-nums">{fmtDate(e.entryDate)}</td>
<td className="py-1.5 pr-3 tabular-nums">{fmtTime(e.startTime)}{fmtTime(e.endTime)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{hours.toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{(rate / 100).toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums font-medium">{total.toFixed(2)}</td>
<td className="py-1.5 pr-3 max-w-xs truncate" title={e.description || ''}>
{e.description || '—'}
</td>
<td className="py-1.5 pr-3">
{e.status === 'billed' ? (
<span className="text-xs text-green-700 dark:text-green-300">
{e.invoiceNumber
? t('customers.hours.status.billedOn',
'Billed: {{number}}', { number: e.invoiceNumber })
: t('customers.hours.status.billed', 'Billed')}
</span>
) : (
<span className="text-xs text-amber-700 dark:text-amber-300">
{t('customers.hours.status.unbilled', 'Unbilled')}
</span>
)}
</td>
<td className="py-1.5 pr-3 text-right">
<button
type="button"
disabled={locked || deleteMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.hours.confirmDelete',
'Delete this entry? If it has been billed onto a draft, the matching invoice line will also be removed.') as string)) {
deleteMutation.mutate(e.id);
}
}}
className="text-xs text-red-600 hover:underline disabled:text-neutral-400 disabled:cursor-not-allowed"
title={locked ? t('customers.hours.locked',
'Locked: invoice already armed for send') as string : undefined}
>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
);
};
export default HoursSection;
@@ -0,0 +1,360 @@
/**
* Inline "+ Create new customer" form mounted inside the quote /
* invoice editor's customer card.
*
* Two save modes:
* - "Save as passive customer" POST /admin/customers. No email,
* no invitation. The customer becomes a usable record
* immediately for the current quote/invoice.
* - "Save & send portal invitation" POST /admin/customers
* followed by POST /admin/customers/:id/send-invite. Customer is
* created (passive in DB), then a standard onboarding email is
* queued so they can claim portal access. Orchestrated client-
* side so the backend endpoints stay simple and single-purpose.
*
* If the second call fails after the first succeeds, the customer
* stays saved (passive) and a warning toast asks the admin to retry
* from the customer detail page.
*
* Field set mirrors the customer detail page so admins see the same
* shape regardless of where they're editing.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
import { Button, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerInvitePrefill,
} from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useQuery } from '@tanstack/react-query';
interface Props {
/**
* Fires after a successful save. The customer payload is the same
* shape the customer-detail endpoint returns, so callers can use
* its id/email/company directly to populate the quote/invoice's
* customer pin.
*/
onCreated: (customer: CustomerAccountDetail) => void;
/** Revert the editor card back to the search-only state. */
onCancel: () => void;
/**
* Which save action(s) the form should expose.
* - 'both' (default): renders both buttons used by the quote /
* invoice editors where the admin picks the mode in-place.
* - 'passive': renders only "Save as passive customer".
* - 'invite': renders only "Save & send portal invitation".
* The "passive" / "invite" specialisations let CustomerManagementPage
* route both header buttons through the same modal the only
* difference between the two flows is which action button shows.
*/
mode?: 'both' | 'passive' | 'invite';
}
type FormState = {
email: string;
salutation: string;
firstName: string;
lastName: string;
displayName: string;
phone: string;
companyName: string;
vatId: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
state: string;
countryCode: string;
preferredLanguage: string;
};
const empty: FormState = {
email: '', salutation: '', firstName: '', lastName: '', displayName: '',
phone: '', companyName: '', vatId: '',
addressLine1: '', addressLine2: '', postalCode: '', city: '', state: '',
countryCode: '', preferredLanguage: '',
};
function buildPrefill(f: FormState): CustomerInvitePrefill {
// Backend's PREFILLABLE_FIELDS uses snake_case. Translate at the
// wire boundary and drop empty strings so the server doesn't store
// "" where null would be more honest.
const out: CustomerInvitePrefill = {};
if (f.salutation) out.salutation = f.salutation;
if (f.firstName) out.first_name = f.firstName;
if (f.lastName) out.last_name = f.lastName;
if (f.displayName) out.display_name = f.displayName;
if (f.phone) out.phone = f.phone;
if (f.companyName) out.company_name = f.companyName;
if (f.vatId) out.vat_id = f.vatId;
if (f.addressLine1) out.address_line1 = f.addressLine1;
if (f.addressLine2) out.address_line2 = f.addressLine2;
if (f.postalCode) out.postal_code = f.postalCode;
if (f.city) out.city = f.city;
if (f.state) out.state = f.state;
if (f.countryCode) out.country_code = f.countryCode.toUpperCase();
if (f.preferredLanguage) out.preferred_language = f.preferredLanguage;
return out;
}
export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mode = 'both' }) => {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(empty);
const [busy, setBusy] = useState<'passive' | 'invite' | null>(null);
// Resolve a title + subtitle that matches the selected mode. The
// 'both' branch keeps the legacy copy so inline (in-editor) callers
// see the same wording they had before this prop existed.
const heading = mode === 'invite'
? {
title: t('customers.invite.title', 'Invite a customer'),
subtitle: t('customers.invite.description',
'They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.'),
}
: mode === 'passive'
? {
title: t('customers.create.openButton', 'Create passive customer'),
subtitle: t('customers.create.passiveSubtitle',
'Adds an admin-only customer record. The customer is not notified and cannot log in until you send them an invitation later.'),
}
: {
title: t('customers.create.title', 'Create new customer'),
subtitle: t('customers.create.subtitle',
'Fill in the details below. Choose "Save as passive customer" to create an admin-only record, or "Save & send portal invitation" to also email the customer a sign-up link.'),
};
// Business-profile default locale powers the preferred-language
// hint AND seeds the field on mount.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
// Seed preferredLanguage with the profile default once the profile
// arrives (only if the field is still empty so we don't clobber
// explicit user input).
React.useEffect(() => {
if (profileDefaultLocale && !form.preferredLanguage) {
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profileDefaultLocale]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
const handleSave = async (mode: 'passive' | 'invite') => {
if (!isValid) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
if (mode === 'invite') {
// Customer is now saved as passive. Fire the second call to
// promote them. If THIS fails, keep the customer selected
// (it exists, just no email went out) and warn the admin.
try {
await customerAdminService.sendInvite(customer.id);
toast.success(t('customers.create.savedActiveToast',
'Customer created and portal invitation sent.'));
} catch (err: any) {
toast.warn(t('customers.create.inviteFailedToast',
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
// eslint-disable-next-line no-console
console.warn('sendInvite failed', err);
}
} else {
toast.success(t('customers.create.savedPassiveToast',
'Passive customer created.'));
}
onCreated(customer);
} catch (err: any) {
const msg = err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.');
toast.error(String(msg));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-start gap-3 mb-2">
<div>
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{heading.title}
</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{heading.subtitle}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="ml-auto p-1 rounded text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
aria-label={t('common.cancel', 'Cancel') as string}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
type="email"
label={`${t('customers.detail.email', 'Email')} *`}
value={form.email}
onChange={setField('email')}
placeholder="[email protected]"
required
/>
<Input
label={t('customers.detail.companyName', 'Company name') as string}
value={form.companyName}
onChange={setField('companyName')}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.salutation', 'Salutation')}
</label>
{/* Salutation values are stored verbatim ("Herr", "Frau",
"Mx", "Dr") canonical tokens across locales. Display
labels are translated; the option's value stays in the
German form so the same key works regardless of which
locale the admin is editing in. Matches CustomerDetailPage. */}
<select
value={form.salutation}
onChange={setField('salutation')}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<Input
label={t('customers.detail.phone', 'Phone') as string}
value={form.phone}
onChange={setField('phone')}
/>
<Input
label={t('customers.detail.firstName', 'First name') as string}
value={form.firstName}
onChange={setField('firstName')}
/>
<Input
label={t('customers.detail.lastName', 'Last name') as string}
value={form.lastName}
onChange={setField('lastName')}
/>
<Input
label={t('customers.detail.displayName', 'Display name') as string}
value={form.displayName}
onChange={setField('displayName')}
/>
<Input
label={t('customers.detail.vatId', 'VAT ID') as string}
value={form.vatId}
onChange={setField('vatId')}
/>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine1', 'Address line 1') as string}
value={form.addressLine1}
onChange={setField('addressLine1')}
/>
</div>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine2', 'Address line 2') as string}
value={form.addressLine2}
onChange={setField('addressLine2')}
/>
</div>
<Input
label={t('customers.detail.postalCode', 'Postal code') as string}
value={form.postalCode}
onChange={setField('postalCode')}
/>
<Input
label={t('customers.detail.city', 'City') as string}
value={form.city}
onChange={setField('city')}
/>
<Input
label={t('customers.detail.state', 'State / canton') as string}
value={form.state}
onChange={setField('state')}
/>
<Input
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
value={form.countryCode}
onChange={setField('countryCode')}
placeholder="CH"
maxLength={2}
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.preferredLanguage', 'Preferred language')}
</label>
<select
value={form.preferredLanguage || ''}
onChange={setField('preferredLanguage')}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-2 border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" onClick={onCancel} disabled={busy !== null}>
{t('common.cancel', 'Cancel')}
</Button>
{(mode === 'both' || mode === 'passive') && (
<Button
variant={mode === 'passive' ? 'primary' : 'outline'}
onClick={() => handleSave('passive')}
disabled={busy !== null || !isValid}
isLoading={busy === 'passive'}
leftIcon={<Save className="w-4 h-4" />}
>
{/* Mode 'passive' is the dedicated CTA: promote it to the
primary variant so the button hierarchy mirrors what
an admin who opened the modal from "Create passive
customer" expects. */}
{t('customers.create.saveAsPassive', 'Save as passive customer')}
</Button>
)}
{(mode === 'both' || mode === 'invite') && (
<Button
variant="primary"
onClick={() => handleSave('invite')}
disabled={busy !== null || !isValid}
isLoading={busy === 'invite'}
leftIcon={<Send className="w-4 h-4" />}
>
{t('customers.create.saveAndInvite', 'Save & send portal invitation')}
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,320 @@
/**
* <InstallmentsPanel> shared editor surface for the per-document
* installment plan. Used by both QuoteEditorPage and BillEditorPage.
*
* Two render modes:
* - Simple (default): per-row date picker mapped to trigger='fixed_date'
* - Advanced (toggle): per-row trigger dropdown + offset_days
*
* Rows store the canonical {label, percent, trigger, offset_days}
* shape (PaymentTermInstallment from quotes.service.ts). Date pickers
* are a UX convenience layered over `trigger='fixed_date' + offset_days`.
*
* When the panel is "off" (`value: null`), the document is treated as
* a single-invoice / single-payment plan and the parent editor skips
* the installment field on save. Switching the panel on inserts a
* default 100% row pre-populated from `useInstallmentDefaults()`.
*
* Validation: percents must sum to 100 (rendered inline below the
* footer; parent uses `onValidityChange` to disable Save when wrong).
*
* The parent owns the `value` state; this component is fully
* controlled. Render is React-Strict-Mode safe (no state in refs that
* outlives the props).
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Plus } from 'lucide-react';
import { Button, Input } from '../common';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export type InstallmentPlan = PaymentTermInstallment[];
export interface InstallmentsPanelProps {
/** null = single-document mode (no installments). Array = the plan. */
value: InstallmentPlan | null;
onChange: (next: InstallmentPlan | null) => void;
/** Reports whether percents sum to 100. Parent uses to gate Save. */
onValidityChange?: (valid: boolean) => void;
/** Event date used for trigger preview text in advanced mode. */
eventDate?: string | null;
/** Disable inputs (e.g. document is locked / sent). */
disabled?: boolean;
}
const ALL_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function addDays(base: string, days: number): string {
const d = new Date(base);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function daysBetween(from: string, to: string): number {
const f = new Date(from);
const t = new Date(to);
return Math.round((t.getTime() - f.getTime()) / 86_400_000);
}
export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
value, onChange, onValidityChange, eventDate, disabled,
}) => {
const { t } = useTranslation();
const { dateInputLang } = useLocalizedDate();
const defaults = useInstallmentDefaults();
const [advanced, setAdvanced] = React.useState(false);
const enabled = value !== null;
const rows = value || [];
const totalPercent = useMemo(
() => rows.reduce((s, r) => s + (Number(r.percent) || 0), 0),
[rows],
);
const isValid = !enabled || Math.abs(totalPercent - 100) < 0.001;
React.useEffect(() => {
onValidityChange?.(isValid);
}, [isValid, onValidityChange]);
const update = (idx: number, patch: Partial<PaymentTermInstallment>) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r));
onChange(next);
};
const remove = (idx: number) => {
const next = rows.filter((_, i) => i !== idx);
onChange(next.length === 0 ? null : next);
};
const addRow = () => {
// Default-shape new row: use the admin's defaults via a positional
// heuristic — first row inherits the "first installment" trigger,
// middle rows default to before_event, last row inherits after_event.
let trigger: PaymentTermInstallment['trigger'] = defaults.triggerFirst;
let offsetDays = 0;
if (rows.length === 0) {
trigger = defaults.triggerFirst;
offsetDays = 0;
} else {
// Second row onwards. If there's already a row tagged after_event,
// a new one slots in as before_event; else default to after_event.
const hasAfterEvent = rows.some((r) => r.trigger === 'after_event');
trigger = hasAfterEvent ? 'before_event' : 'after_event';
offsetDays = hasAfterEvent ? -defaults.daysBeforeEvent : defaults.daysAfterEvent;
}
// Suggest a percent that fills the gap (clamped to 0-100).
const gap = Math.max(0, 100 - totalPercent);
const next: PaymentTermInstallment = {
label: '',
percent: gap > 0 ? gap : 0,
trigger,
offset_days: offsetDays,
};
onChange([...rows, next]);
};
const toggleEnabled = () => {
if (enabled) {
onChange(null);
return;
}
// Switch on: seed with one 100% row using the "first installment"
// default trigger so the panel doesn't open with an empty list.
onChange([{
label: t('installments.firstRowDefaultLabel', 'Anzahlung') as string,
percent: 100,
trigger: defaults.triggerFirst,
offset_days: 0,
}]);
};
// Map a row to its "Send on" date in simple mode. Only meaningful
// when trigger is fixed_date; for dynamic triggers we display the
// resolved date if we have eventDate, else show a placeholder.
const previewDate = (row: PaymentTermInstallment): string | null => {
const baseline = todayIso();
if (row.trigger === 'fixed_date' || row.trigger === 'quote_accepted') {
return addDays(baseline, row.offset_days || 0);
}
if ((row.trigger === 'before_event' || row.trigger === 'after_event') && eventDate) {
return addDays(eventDate, row.offset_days || 0);
}
return null;
};
return (
<div>
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={toggleEnabled}
disabled={disabled}
/>
{t('installments.enableLabel', 'Split into installments')}
</label>
{enabled && (
<button
type="button"
className="text-xs text-primary-600 dark:text-primary-400 hover:underline"
onClick={() => setAdvanced((v) => !v)}
disabled={disabled}
>
{advanced
? t('installments.simpleToggle', 'Use simple date picker')
: t('installments.advancedToggle', 'Use dynamic triggers')}
</button>
)}
</div>
{enabled && (
<>
<p className="text-xs text-muted-theme mb-3">
{advanced
? t('installments.advancedHint',
'Pick a trigger (quote accepted, before/after event, on delivery, fixed date) plus offset in days. Triggers re-resolve if the event date later shifts.')
: t('installments.simpleHint',
'Each row fires on a specific date. Switch to dynamic triggers for plans tied to event date or delivery.')}
</p>
<div className="space-y-2">
{rows.map((row, idx) => (
<div
key={idx}
className="grid grid-cols-12 gap-2 items-end p-2 rounded-md bg-neutral-50 dark:bg-neutral-800/40"
>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.percent', '%')}
</label>
<Input
type="number"
min={0}
max={100}
step="0.01"
value={row.percent}
onChange={(e) => update(idx, { percent: Number(e.target.value) })}
disabled={disabled}
/>
</div>
<div className="col-span-4">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.label', 'Label')}
</label>
<Input
value={row.label}
onChange={(e) => update(idx, { label: e.target.value })}
disabled={disabled}
placeholder={t('installments.labelPlaceholder', 'Anzahlung / vor Event …') as string}
/>
</div>
{!advanced ? (
<div className="col-span-5">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.sendOn', 'Send on')}
</label>
{row.trigger === 'after_delivery' ? (
<div className="text-xs text-muted-theme py-2">
{t('installments.onDeliveryHint',
'On delivery — admin releases manually. Switch to advanced to change.')}
</div>
) : (
<Input
type="date"
lang={dateInputLang}
value={previewDate(row) || ''}
onChange={(e) => {
const next = e.target.value;
if (!next) return;
const offset = daysBetween(todayIso(), next);
update(idx, { trigger: 'fixed_date', offset_days: offset });
}}
disabled={disabled}
/>
)}
</div>
) : (
<>
<div className="col-span-3">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.trigger', 'Trigger')}
</label>
<select
value={row.trigger}
onChange={(e) => update(idx, {
trigger: e.target.value as PaymentTermInstallment['trigger'],
offset_days: e.target.value === 'after_delivery' ? 0 : row.offset_days,
})}
disabled={disabled}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
>
{ALL_TRIGGERS.map((tr) => (
<option key={tr} value={tr}>
{t(`installments.triggerOption.${tr}`, tr)}
</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs text-muted-theme mb-1">
{t('installments.offsetDays', 'Offset (days)')}
</label>
<Input
type="number"
value={row.offset_days}
onChange={(e) => update(idx, { offset_days: Number(e.target.value) })}
disabled={disabled || row.trigger === 'after_delivery'}
/>
</div>
</>
)}
<div className="col-span-1 flex justify-end">
<button
type="button"
onClick={() => remove(idx)}
disabled={disabled}
className="p-2 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-red-600"
aria-label={t('installments.removeRow', 'Remove row') as string}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={disabled || totalPercent >= 100}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('installments.addRow', 'Add installment')}
</Button>
<div className={`text-sm font-medium ${isValid ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}`}>
{t('installments.total', 'Total')}: {totalPercent.toFixed(2)}%
{!isValid && `${t('installments.mustSumTo100', 'must sum to 100%')}`}
</div>
</div>
</>
)}
</div>
);
};
export default InstallmentsPanel;
@@ -0,0 +1,481 @@
/**
* Reusable line-items editor for quotes + invoices.
*
* Two-level hierarchy (migration 119):
* - Top-level items roll into the document net/VAT/total.
* - Sub-items render indented under their parent. Their line total
* is shown in parentheses for transparency but is display-only;
* only the parent's price contributes to net.
* - Per-item `detailsText` is an optional free-form notes block
* rendered below the description on the PDF + customer view.
*
* Items in `items` are kept in DISPLAY ORDER (parent immediately
* followed by its sub-items, then the next parent, etc.). `position`
* is a stable unique identifier used to link sub-items to parents in
* the payload once assigned at row creation we never renumber it.
* Move up/down only swaps within the same level (top-level among
* top-level, sub-items among siblings of the same parent).
*
* Money values are stored in MAJOR units in the form state (e.g. 250.00)
* for editor ergonomics, then converted to minor (25000) when persisting.
* The conversion happens at the save boundary in the parent page.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, X, ArrowUp, ArrowDown, Save as SaveIcon, ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { Button } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { formatMoney } from '../../utils/money';
export interface EditableLineItem {
id?: number;
/** Stable unique identifier used to link sub-items to parents. */
position: number;
quantity: number;
description: string;
/** Stored in major units (CHF / EUR) for UX. */
unitPrice: number;
discountPercent: number;
/** NULL = top-level (rolls into net). Non-null = sub-item under that parent's position. */
parentPosition?: number | null;
/** Optional free-form notes rendered below the description. */
detailsText?: string;
}
export interface LineItemPresetMinimal {
id: number;
name: string;
description: string;
unitPriceMinor: number;
quantityDefault: number;
}
interface Props {
items: EditableLineItem[];
currency: string;
showDiscount?: boolean;
vatRate?: number;
shippingAmount?: number;
onChange: (items: EditableLineItem[]) => void;
presets?: LineItemPresetMinimal[];
onSaveAsPreset?: (item: EditableLineItem) => void;
}
function nextFreshPosition(items: EditableLineItem[]) {
return items.reduce((m, it) => Math.max(m, it.position), 0) + 1;
}
function isSub(li: EditableLineItem) {
return li.parentPosition != null;
}
export const LineItemsTable: React.FC<Props> = ({
items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0,
onChange, presets = [], onSaveAsPreset,
}) => {
const { t } = useTranslation();
// Track which rows have the details textarea expanded. Keyed by
// `position` since that's stable across renders.
const [detailsOpen, setDetailsOpen] = useState<Set<number>>(() => new Set(
items.filter((it) => it.detailsText && it.detailsText.trim().length > 0).map((it) => it.position)
));
const toggleDetails = (pos: number) => {
setDetailsOpen((prev) => {
const next = new Set(prev);
if (next.has(pos)) next.delete(pos); else next.add(pos);
return next;
});
};
const setItem = (idx: number, patch: Partial<EditableLineItem>) => {
const next = items.map((it, i) => (i === idx ? { ...it, ...patch } : it));
onChange(next);
};
const addRow = (preset?: LineItemPresetMinimal) => {
const pos = nextFreshPosition(items);
const next = [...items, {
position: pos,
quantity: preset ? Number(preset.quantityDefault) || 1 : 1,
description: preset ? `${preset.name}${preset.description ? `\n${preset.description}` : ''}` : '',
unitPrice: preset ? Number(preset.unitPriceMinor) / 100 : 0,
discountPercent: 0,
parentPosition: null,
detailsText: '',
}];
onChange(next);
};
/**
* Insert a fresh sub-item immediately AFTER the parent's last
* existing sub-item (or the parent itself if there are none yet).
* Keeps display order grouped: parent its sub-items next parent.
*/
const addSubItem = (parentIdx: number) => {
const parent = items[parentIdx];
if (!parent || isSub(parent)) return; // can't nest under a sub-item (1 level deep)
let insertAt = parentIdx + 1;
while (insertAt < items.length && items[insertAt].parentPosition === parent.position) {
insertAt += 1;
}
const newRow: EditableLineItem = {
position: nextFreshPosition(items),
quantity: 1,
description: '',
unitPrice: 0,
discountPercent: 0,
parentPosition: parent.position,
detailsText: '',
};
const next = [...items];
next.splice(insertAt, 0, newRow);
onChange(next);
};
/**
* Remove a row. When removing a top-level parent, also sweep its
* sub-items (CASCADE-equivalent in the editor, matches the DB FK
* cascade so the editor's behaviour matches what would persist).
*/
const removeRow = (idx: number) => {
const target = items[idx];
if (!target) return;
if (!isSub(target)) {
onChange(items.filter((it, i) => i !== idx && it.parentPosition !== target.position));
} else {
onChange(items.filter((_, i) => i !== idx));
}
};
/**
* Move up/down restricted to siblings of the same level. For
* top-level items, the entire "group" (parent + its sub-items) is
* moved as a unit. For sub-items, the swap is within the same
* parent's children only.
*/
const move = (idx: number, dir: -1 | 1) => {
const target = items[idx];
if (!target) return;
if (isSub(target)) {
// Find sibling sub-items with same parent.
const siblings: number[] = [];
for (let i = 0; i < items.length; i += 1) {
if (items[i].parentPosition === target.parentPosition) siblings.push(i);
}
const here = siblings.indexOf(idx);
const other = here + dir;
if (other < 0 || other >= siblings.length) return;
const next = [...items];
[next[siblings[here]], next[siblings[other]]] = [next[siblings[other]], next[siblings[here]]];
onChange(next);
} else {
// Move top-level group as a block. Find the range of this group
// and the adjacent group's range, then swap them.
const groupStart = idx;
let groupEnd = idx + 1;
while (groupEnd < items.length && items[groupEnd].parentPosition === target.position) {
groupEnd += 1;
}
if (dir === -1) {
if (groupStart === 0) return;
// Find the previous top-level item's group range.
let prevTopIdx = groupStart - 1;
while (prevTopIdx > 0 && isSub(items[prevTopIdx])) prevTopIdx -= 1;
const prevGroupStart = prevTopIdx;
const prevGroupEnd = groupStart; // exclusive
const before = items.slice(0, prevGroupStart);
const prevGroup = items.slice(prevGroupStart, prevGroupEnd);
const thisGroup = items.slice(groupStart, groupEnd);
const after = items.slice(groupEnd);
onChange([...before, ...thisGroup, ...prevGroup, ...after]);
} else {
if (groupEnd >= items.length) return;
const nextTopIdx = groupEnd; // is a top-level by construction
let nextGroupEnd = nextTopIdx + 1;
while (nextGroupEnd < items.length && isSub(items[nextGroupEnd])) nextGroupEnd += 1;
const before = items.slice(0, groupStart);
const thisGroup = items.slice(groupStart, groupEnd);
const nextGroup = items.slice(nextTopIdx, nextGroupEnd);
const after = items.slice(nextGroupEnd);
onChange([...before, ...nextGroup, ...thisGroup, ...after]);
}
}
};
const rawLineTotal = (li: EditableLineItem) =>
Math.round(li.quantity * li.unitPrice * (1 - li.discountPercent / 100) * 100) / 100;
/**
* A parent has "priced sub-items" when at least one of its
* children has unitPrice > 0. In that mode the parent's own
* unit_price / qty / discount inputs are disabled and its line
* total auto-resolves to the sum of those priced sub-items.
* Matches the backend resolveParentTotalsFromSubItems() rule
* (migration 119) so the editor mirrors what gets persisted.
*/
// D.4 — memoize the per-parent child-pricing aggregates. The previous
// shape rescanned `items` on every call, and the helpers were called
// inside the JSX loop AND from the subtotal reduce — so on a 20-item
// quote each keystroke ran ~O(n²) array scans. Build a Map once per
// render and read O(1) afterwards.
const childPricingByParent = useMemo(() => {
const map = new Map<number, { hasPriced: boolean; pricedSum: number }>();
for (const c of items) {
if (c.parentPosition == null) continue;
if (!(c.unitPrice > 0)) continue;
const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0 };
cur.hasPriced = true;
cur.pricedSum += rawLineTotal(c);
map.set(c.parentPosition, cur);
}
return map;
// rawLineTotal is a pure function of the closure's `items`; the
// dependency array gets the items snapshot directly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
const hasPricedChildren = (parentPos: number) =>
childPricingByParent.get(parentPos)?.hasPriced || false;
const pricedChildrenSum = (parentPos: number) =>
childPricingByParent.get(parentPos)?.pricedSum || 0;
/** Resolved line total: parent auto-sums when sub-items are priced. */
const lineTotal = (li: EditableLineItem) => {
if (!isSub(li) && hasPricedChildren(li.position)) {
return pricedChildrenSum(li.position);
}
return rawLineTotal(li);
};
// Subtotal: top-level items ONLY (their resolved totals). Sub-items
// never roll directly into net — they only feed their parent's
// auto-resolved line total.
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
const vatAmount = Math.round(subtotal * vatRate) / 100;
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
// Display numbering: top-level items get 1, 2, 3...; sub-items
// render as N.1, N.2 under the parent for clarity.
const displayNumbers = (() => {
const out: string[] = [];
let topCount = 0;
let subCount = 0;
for (const li of items) {
if (!isSub(li)) {
topCount += 1;
subCount = 0;
out.push(String(topCount));
} else {
subCount += 1;
out.push(`${topCount}.${subCount}`);
}
}
return out;
})();
return (
<div className="space-y-3">
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-left w-14">{t('crm.lineItems.position', 'Pos.')}</th>
<th className="px-2 py-2 text-left w-20">{t('crm.lineItems.quantity', 'Anzahl')}</th>
<th className="px-2 py-2 text-left">{t('crm.lineItems.description', 'Beschreibung')}</th>
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.unitPrice', 'Einzelpreis')}</th>
{showDiscount && (
<th className="px-2 py-2 text-right w-24">{t('crm.lineItems.discount', 'Rabatt %')}</th>
)}
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.total', 'Summe')}</th>
<th className="px-2 py-2 w-28"></th>
</tr>
</thead>
<tbody>
{items.map((li, idx) => {
const sub = isSub(li);
const open = detailsOpen.has(li.position);
// Parent is "auto-totaled" when at least one of its
// sub-items has a price. In that mode the qty / unit
// price / discount inputs are disabled — the parent's
// total is the sum of priced sub-items, computed by
// the backend on save.
const parentAutoTotaled = !sub && hasPricedChildren(li.position);
const disabledInputClass = parentAutoTotaled
? 'bg-neutral-100 dark:bg-neutral-700 text-neutral-400 cursor-not-allowed'
: 'bg-white dark:bg-neutral-800';
return (
<React.Fragment key={li.position}>
<tr className={`border-t border-neutral-200 dark:border-neutral-700 ${
sub ? 'bg-neutral-50/60 dark:bg-neutral-900/40' : ''
}`}>
<td className="px-2 py-2 text-neutral-600 dark:text-neutral-400 align-top">
<div className="flex items-center gap-1">
{sub && <CornerDownRight className="w-3.5 h-3.5 text-neutral-400" aria-hidden />}
<span>{displayNumbers[idx]}</span>
</div>
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm ${disabledInputClass}`}
value={li.quantity}
onChange={(n) => setItem(idx, { quantity: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
<td className={`px-2 py-2 align-top ${sub ? 'pl-6' : ''}`}>
<textarea
rows={2}
className="w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-sm"
value={li.description}
onChange={(e) => setItem(idx, { description: e.target.value })}
placeholder={t('crm.lineItems.descriptionPlaceholder', 'Description (multi-line OK)') as string}
/>
<button
type="button"
onClick={() => toggleDetails(li.position)}
className="mt-1 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
{open
? <ChevronDown className="w-3.5 h-3.5" aria-hidden />
: <ChevronRight className="w-3.5 h-3.5" aria-hidden />}
<span>
{(li.detailsText && li.detailsText.trim().length > 0)
? t('crm.lineItems.detailsFilled', 'Details')
: t('crm.lineItems.detailsAdd', '+ Add details / notes')}
</span>
</button>
{open && (
<textarea
rows={2}
maxLength={2000}
className="mt-2 w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-xs italic"
value={li.detailsText || ''}
onChange={(e) => setItem(idx, { detailsText: e.target.value })}
placeholder={t('crm.lineItems.detailsPlaceholder', 'Optional notes — fine print, package inclusions, conditions…') as string}
/>
)}
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-24 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.unitPrice}
fractionDigits={2}
onChange={(n) => setItem(idx, { unitPrice: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
{showDiscount && (
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.discountPercent}
onChange={(n) => {
// Clamp to 0..100 — match the original input's min/max.
const clamped = !Number.isFinite(n) ? 0 : Math.max(0, Math.min(100, n));
setItem(idx, { discountPercent: clamped });
}}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
)}
<td className={`px-2 py-2 text-right tabular-nums align-top ${
sub
? 'text-neutral-500 dark:text-neutral-400 italic'
: 'font-medium'
}`}>
{sub
? li.unitPrice > 0
? `(${formatMoney(lineTotal(li), currency)})`
: ''
: formatMoney(lineTotal(li), currency)}
{parentAutoTotaled && (
<div className="text-[10px] font-normal text-neutral-500 dark:text-neutral-400 italic mt-0.5">
{t('crm.lineItems.autoTotaledNote', '= Σ Unterpositionen') as string}
</div>
)}
</td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1 justify-end flex-wrap">
<button type="button" onClick={() => move(idx, -1)} aria-label="Move up"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowUp className="w-4 h-4" />
</button>
<button type="button" onClick={() => move(idx, 1)} aria-label="Move down"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowDown className="w-4 h-4" />
</button>
{!sub && (
<button type="button" onClick={() => addSubItem(idx)} aria-label="Add sub-item"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.addSubItem', 'Add sub-item') as string}>
<CornerDownRight className="w-4 h-4" />
</button>
)}
{onSaveAsPreset && !sub && (
<button type="button" onClick={() => onSaveAsPreset(li)} aria-label="Save as preset"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.saveAsPreset', 'Save as preset') as string}>
<SaveIcon className="w-4 h-4" />
</button>
)}
<button type="button" onClick={() => removeRow(idx)} aria-label="Remove"
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
</React.Fragment>
);
})}
{items.length === 0 && (
<tr><td colSpan={showDiscount ? 7 : 6} className="px-2 py-6 text-center text-neutral-500">
{t('crm.lineItems.empty', 'No line items yet — add one to get started.')}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => addRow()}>
<Plus className="w-4 h-4 mr-1" />{t('crm.lineItems.addRow', 'Add row')}
</Button>
{presets.length > 0 && (
<select
className="text-sm rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5"
onChange={(e) => {
const id = parseInt(e.target.value, 10);
const preset = presets.find((p) => p.id === id);
if (preset) addRow(preset);
e.target.value = '';
}}
defaultValue=""
>
<option value="" disabled>{t('crm.lineItems.addFromPreset', 'Add from preset…')}</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
<div className="flex flex-col items-end gap-1 text-sm pt-2 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.subtotal', 'Subtotal')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(subtotal, currency)}</span></div>
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.vat', 'VAT')} ({(vatRate * 100).toFixed(1)}%):</span><span className="tabular-nums w-28 text-right">{formatMoney(vatAmount, currency)}</span></div>
{!!shippingAmount && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.shipping', 'Shipping')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(shippingAmount, currency)}</span></div>
)}
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(total, currency)}</span></div>
</div>
</div>
);
};
// `formatMoney` is now the canonical helper from utils/money. Re-exported
// here so call-sites that historically imported from this file
// (CustomerCrmPanels, page-level summaries) keep working without churn.
export { formatMoney };
@@ -0,0 +1,102 @@
/**
* Settings Branding card: lets the admin pick the font used on every
* PDF (quotes, invoices, tax report) from the bundled families. Sits
* directly beneath the web Typography customizer card on
* `BrandingPage`, inside the left column matches the typography
* box width so the two visually pair.
*
* Controlled component. State + persistence live on the parent so the
* page's top-level "Save changes" button writes this together with
* the rest of the branding form (no card-local save button).
*
* Loads the same `/public/fonts` list the web font picker consumes,
* so any family bundled in `backend/assets/fonts/` shows up here too
* automatically. The selected value persists to
* `business_profile.pdf_font_family` and pdfService maps it to
* `<family>/400.ttf` (body) + `<family>/700.ttf` (bold) at render
* time.
*
* Hidden by the caller when no PDF-producing feature is enabled
* (quotes / bills / taxReport all off) when there's no PDF surface
* the setting is irrelevant.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Type } from 'lucide-react';
import { Card } from '../common';
import { fontsService } from '../../services/fonts.service';
/**
* The bundled-fonts API returns the DISPLAY name (e.g. "Playfair
* Display") but pdfService resolves a DIRECTORY name (e.g.
* "Playfair-Display"). They're always related by space hyphen.
* The helpers below convert between them so the dropdown can show
* a clean human label while persisting the on-disk identifier.
*/
const familyToDirectory = (family: string) => family.replace(/ /g, '-');
const directoryToFamily = (dir: string) => dir.replace(/-/g, ' ');
export interface PdfTypographyCardProps {
/** Directory name (e.g. "Inter", "Playfair-Display") or null/""
* for "Use Helvetica (default)". */
value: string | null;
onChange: (value: string | null) => void;
}
export const PdfTypographyCard: React.FC<PdfTypographyCardProps> = ({ value, onChange }) => {
const { t } = useTranslation();
// Same query the web typography picker consumes — single source of
// truth for "which bundled families exist on disk".
const { data: availableFonts } = useQuery({
queryKey: ['fonts'],
queryFn: () => fontsService.list(),
staleTime: 60 * 60 * 1000, // fonts don't change at runtime
});
const selection = value || '';
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.pdfTypography', 'PDF typography')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.pdfTypographyHelp',
'Used for invoice + quote letterheads. Pick one of the bundled fonts, or leave on default to use Helvetica.')}
</p>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.pdfFontFamily', 'Body font')}
</label>
<select
value={selection}
onChange={(e) => onChange(e.target.value ? e.target.value : null)}
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"
>
<option value="">
{t('branding.pdfFontFamilyDefault', 'Use Helvetica (default)')}
</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={familyToDirectory(f.family)}>
{/* Show the display name (with spaces) but persist
the directory name (with hyphens) so pdfService can
find the on-disk family without an extra lookup. */}
{f.family}
</option>
))}
{/* When the saved value points at a family that's no
longer on disk (e.g. uploaded by an earlier admin,
later removed), still show it so the admin sees what
they have rather than silently re-mapping to default. */}
{selection && !(availableFonts || []).some((f) => familyToDirectory(f.family) === selection) && (
<option value={selection}>
{directoryToFamily(selection)} ({t('branding.pdfFontFamilyMissing', 'missing')})
</option>
)}
</select>
</Card>
);
};
@@ -66,6 +66,12 @@ interface ThemeCustomizerEnhancedProps {
// only the colour tokens swap, layout/header/typography stay put so an
// admin who's already arranged the structure can pull just the palette.
onSyncFromBranding?: () => void;
// Optional render slot inserted between the Typography & Style /
// CSS Templates section and the Event-specific Custom CSS card.
// Used by BrandingPage to slot in unrelated cards (PDF typography)
// so they live with the other typography choices rather than after
// the always-bulky Custom CSS editor.
slotBeforeCustomCss?: React.ReactNode;
}
/**
@@ -174,7 +180,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onCssTemplateChange,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding
onSyncFromBranding,
slotBeforeCustomCss
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -1364,6 +1371,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</Card>
)}
{/* Caller-provided slot used by BrandingPage to keep the
PDF typography card adjacent to the web typography section
instead of trailing the (often-collapsed) Custom CSS block. */}
{slotBeforeCustomCss}
{/* Event-specific Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">