Files
picpeak/frontend/src/components/admin/CrmOverviewSection.tsx
T
Luca a7e16e7bf6 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.
2026-05-26 18:19:32 +02:00

273 lines
11 KiB
TypeScript

/**
* 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;