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
@@ -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;