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:
@@ -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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user