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