Merge origin/beta into feat/accounting-inbound-invoices

Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
This commit is contained in:
Luca
2026-06-15 16:37:23 +02:00
46 changed files with 2556 additions and 96 deletions
+8
View File
@@ -43,6 +43,8 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
@@ -207,6 +209,12 @@ function App() {
<Route path="quotes/:id" element={<QuoteDetailPage />} />
<Route path="quotes/:id/edit" element={<QuoteEditorPage />} />
</Route>
{/* Project Overview (CRM) — admin-only grouping
layer above events, gated by `projects`. */}
<Route element={<RequireFeature flag="projects" />}>
<Route path="projects" element={<ProjectsListPage />} />
<Route path="projects/:id" element={<ProjectCockpitPage />} />
</Route>
{/* Bills / invoices (CRM) — gated by `bills`. */}
<Route element={<RequireFeature flag="bills" />}>
<Route path="bills" element={<BillsListPage />} />
+13 -34
View File
@@ -1,60 +1,39 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api } from '../config/api';
import { setMaintenanceModeCallback } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
}
// Maintenance detection now lives in two places:
// Maintenance detection lives in two places:
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
// maintenance_mode field (via the shared usePublicSettings hook).
// This wrapper only needs to gate the rendered tree on the resulting state.
//
// The maintenance screen ONLY blocks customer/gallery/public routes. Admin
// routes (/admin/*) are never blocked: an admin must always be able to reach
// the panel to turn maintenance back off, and the admin auth layer already
// handles access (AdminLayout redirects a logged-out admin to /admin/login).
// Gating /admin/* here on an "is the admin logged in?" check is what caused the
// lockout — it hid the login page itself, and after login the check went stale
// (login → dashboard is a client-side nav within /admin, so it never re-ran),
// leaving a logged-in admin stuck on the maintenance screen.
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
const isAdminRoute = location.pathname.startsWith('/admin');
useEffect(() => {
let isMounted = true;
const checkAdminSession = async () => {
if (!isAdminRoute) {
setHasAdminSession(false);
return;
}
try {
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
if (isMounted) {
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
}
} catch {
if (isMounted) {
setHasAdminSession(false);
}
}
};
checkAdminSession();
return () => {
isMounted = false;
};
}, [isAdminRoute]);
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
if (isMaintenanceMode && !isAdminRoute) {
return <MaintenanceMode />;
}
@@ -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, FileText, Receipt, Wrench, Clock, ScrollText, Calendar } from 'lucide-react';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -39,6 +39,13 @@ export const ClientsLayout: React.FC = () => {
const { flags } = useFeatureFlags();
const navItems: NavItem[] = [
{
key: 'overview',
to: '/admin/clients/projects',
label: t('clients.subnav.overview', 'Overview'),
icon: FolderKanban,
featureFlag: 'projects',
},
{
key: 'accounts',
to: '/admin/clients/accounts',
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { ProjectSelect } from './ProjectSelect';
export interface HoursSectionProps {
customerId: number;
@@ -54,6 +55,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
const [duration, setDuration] = useState<string>('');
const [rateOverride, setRateOverride] = useState<string>('');
const [description, setDescription] = useState('');
// Migration 118 — optional "book to project" link (gated component).
const [projectId, setProjectId] = useState<number | null>(null);
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
// the end-time jumps to start + duration. Pure convenience; the End
@@ -114,6 +117,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
})(),
description: description || null,
projectId: projectId ?? null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
@@ -123,6 +127,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
setDuration('');
setRateOverride('');
setDescription('');
setProjectId(null);
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
@@ -135,6 +140,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
return;
}
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch',
"That project belongs to a different customer than this entry."));
return;
}
const msg = err?.response?.data?.error || err?.message
|| t('customers.hours.error.createFailed', 'Failed to log entry');
toast.error(msg);
@@ -348,6 +358,14 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
{/* Book to project — renders only when the projects feature is on. */}
<ProjectSelect
className="mt-3"
label={t('customers.hours.form.bookToProject', 'Book to project') as string}
value={projectId}
customerAccountId={customerId}
onChange={setProjectId}
/>
<div className="mt-3 flex items-center justify-end gap-3">
{noRateConfigured && !overrideTyped && (
<span className="text-xs text-amber-700 dark:text-amber-300">
@@ -26,10 +26,9 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Plus } from 'lucide-react';
import { Button, Input } from '../common';
import { Button, Input, LocalizedDateInput } from '../common';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export type InstallmentPlan = PaymentTermInstallment[];
@@ -69,7 +68,6 @@ 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);
@@ -230,12 +228,9 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
'On delivery — admin releases manually. Switch to advanced to change.')}
</div>
) : (
<Input
type="date"
lang={dateInputLang}
<LocalizedDateInput
value={previewDate(row) || ''}
onChange={(e) => {
const next = e.target.value;
onChange={(next) => {
if (!next) return;
const offset = daysBetween(todayIso(), next);
update(idx, { trigger: 'fixed_date', offset_days: offset });
@@ -0,0 +1,74 @@
/**
* ProjectSelect — a gated project picker reused by the quote / contract /
* hours / event editors to link a document to a Project Overview project.
*
* Renders nothing when the `projects` feature flag is off, so every call
* site stays a one-liner that simply vanishes when the feature is disabled
* (the maintainer's "book to project must not show unless projects is
* enabled" requirement). Customers never see this — admin surfaces only.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { projectsService } from '../../services/projects.service';
interface ProjectSelectProps {
value: number | null;
onChange: (projectId: number | null) => void;
/** Optional label above the select. When omitted the select renders bare. */
label?: string;
/** Restrict the list to a single customer's projects when set. */
customerAccountId?: number | null;
disabled?: boolean;
className?: string;
}
export const ProjectSelect: React.FC<ProjectSelectProps> = ({
value,
onChange,
label,
customerAccountId,
disabled,
className,
}) => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { data: projects, isLoading } = useQuery({
queryKey: ['projects', 'select'],
queryFn: () => projectsService.list(),
enabled: !!flags.projects,
staleTime: 60_000,
});
// Hard gate: hidden entirely when the feature is off.
if (!flags.projects) return null;
const options = (projects || []).filter(
(p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId,
);
return (
<div className={className}>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
)}
<select
value={value ?? ''}
disabled={disabled || isLoading}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
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 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
>
<option value="">{t('projects.picker.none', 'No project')}</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
);
};
@@ -56,6 +56,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
// Separate Accounting sub-feature; requires `accounting`.
expenses: false,
// Projects (migration 120). Admin-only grouping layer above events +
// the Project Overview cockpit. Off by default — admin opts in under
// Settings → Features once they want the CRM → Overview area.
projects: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -20,6 +20,7 @@ import {
Landmark,
ScanLine,
Wallet,
FolderKanban,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -291,6 +292,20 @@ export const FeaturesTab: React.FC = () => {
enabled={staged.hoursLogging}
onToggle={(next) => setFlag('hoursLogging', next)}
/>
<FeatureCard
icon={FolderKanban}
title={t('settings.features.projects.title', 'Projects')}
description={t(
'settings.features.projects.description',
'Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a "book to project" control when logging hours. Customers never see projects.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.projects.sidebar', 'Overview')}
enabled={staged.projects}
onToggle={(next) => setFlag('projects', next)}
/>
</Section>
{/* Accounting — top-level master + sub-toggles. The Tax export
+81
View File
@@ -1691,6 +1691,11 @@
"title": "Stundenerfassung",
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
"sidebar": "Stunden"
},
"projects": {
"title": "Projekte",
"description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.",
"sidebar": "Übersicht"
}
},
"customerSurface": {
@@ -3203,6 +3208,7 @@
"rateOverride": "Satz-Override",
"note": "Notiz / Beschreibung",
"notePlaceholder": "Was wurde gearbeitet?",
"bookToProject": "Auf Projekt buchen",
"save": "Eintrag hinzufügen",
"needRate": "Satz festlegen oder Override eingeben, um Zeit zu erfassen."
},
@@ -3450,6 +3456,7 @@
"body": "Aktiviere „Konten\" (oder eine andere CRM-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
},
"subnav": {
"overview": "Übersicht",
"accounts": "Konten",
"quotes": "Angebote",
"contracts": "Verträge",
@@ -3634,6 +3641,80 @@
"createdToast": "Aufwand hinzugefügt."
}
},
"projects": {
"title": "Projektübersicht",
"subtitle": "Fasse Events zu Projekten zusammen und sieh jede E-Mail, jedes Dokument, jede Galerie und jede Stunde in einem Cockpit.",
"search": "Nach Name oder Kunde suchen…",
"empty": "Noch keine Projekte. Erstelle oben eines, oder vorhandene Events wurden automatisch gruppiert.",
"notFound": "Projekt nicht gefunden",
"backToList": "Alle Projekte",
"noCustomer": "Kein einzelner Kunde",
"rename": "Umbenennen",
"timeline": "Meilensteine",
"eventCount": "{{count}} Events",
"totalHours": "{{hours}} erfasst",
"create": {
"label": "Name des neuen Projekts",
"placeholder": "z. B. Hochzeit Müller 2026",
"button": "Projekt erstellen"
},
"col": {
"name": "Projekt",
"customer": "Kunde",
"events": "Events",
"value": "Wert",
"status": "Status",
"updated": "Aktualisiert"
},
"picker": {
"label": "Projekt",
"none": "Kein Projekt"
},
"value": {
"label": "Projektwert",
"paid": "bezahlt"
},
"events": {
"title": "Events",
"none": "Diesem Projekt sind noch keine Events zugeordnet.",
"searchPlaceholder": "Event zuordnen — nach Name suchen…",
"attached": "Event zugeordnet",
"attachFailed": "Event konnte nicht zugeordnet werden"
},
"feed": {
"title": "Aktivität",
"empty": "Diesem Projekt ist noch nichts zugeordnet.",
"email": "E-Mail",
"quote": "Angebot",
"contract": "Vertrag",
"invoice": "Rechnung",
"gallery": "Galerie",
"hours": "Stunden"
},
"email": {
"preview": "Vorschau",
"resend": "Erneut senden",
"sendNow": "Jetzt senden",
"cancel": "Abbrechen",
"retry": "Wiederholen",
"previewTitle": "E-Mail-Vorschau",
"noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.",
"reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen.",
"reRenderedTag": "≈ neu gerendert"
},
"toast": {
"created": "Projekt erstellt",
"createFailed": "Projekt konnte nicht erstellt werden",
"saved": "Projekt gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"emailAction": "Erledigt",
"emailActionFailed": "Aktion fehlgeschlagen",
"previewFailed": "Vorschau konnte nicht geladen werden"
},
"error": {
"customerMismatch": "Das gehört zu einem anderen Kunden als dieses Projekt."
}
},
"calendar": {
"pageTitle": "Kalender",
"subtitle": "Termine, erfasste Stunden und offene Angebote/Verträge in einer Ansicht.",
+81
View File
@@ -1249,6 +1249,11 @@
"title": "Hours logging",
"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.",
"sidebar": "Hours"
},
"projects": {
"title": "Projects",
"description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.",
"sidebar": "Overview"
}
},
"customerSurface": {
@@ -3203,6 +3208,7 @@
"rateOverride": "Rate override",
"note": "Note / description",
"notePlaceholder": "What was worked on?",
"bookToProject": "Book to project",
"save": "Add entry",
"needRate": "Set a rate or enter an override to log time."
},
@@ -3450,6 +3456,7 @@
"body": "Enable Accounts (or another CRM sub-feature) under Settings → Features to get started."
},
"subnav": {
"overview": "Overview",
"accounts": "Accounts",
"quotes": "Quotes",
"contracts": "Contracts",
@@ -3634,6 +3641,80 @@
"createdToast": "Expense added."
}
},
"projects": {
"title": "Project Overview",
"subtitle": "Group events into projects and see every email, document, gallery and hour in one cockpit.",
"search": "Search by name or customer…",
"empty": "No projects yet. Create one above, or events you already have were grouped automatically.",
"notFound": "Project not found",
"backToList": "All projects",
"noCustomer": "No single customer",
"rename": "Rename",
"timeline": "Milestones",
"eventCount": "{{count}} events",
"totalHours": "{{hours}} logged",
"create": {
"label": "New project name",
"placeholder": "e.g. Müller wedding 2026",
"button": "Create project"
},
"col": {
"name": "Project",
"customer": "Customer",
"events": "Events",
"value": "Value",
"status": "Status",
"updated": "Updated"
},
"picker": {
"label": "Project",
"none": "No project"
},
"value": {
"label": "Project value",
"paid": "paid"
},
"events": {
"title": "Events",
"none": "No events grouped under this project yet.",
"searchPlaceholder": "Attach an event — search by name…",
"attached": "Event attached",
"attachFailed": "Could not attach event"
},
"feed": {
"title": "Activity",
"empty": "Nothing rolled up to this project yet.",
"email": "Email",
"quote": "Quote",
"contract": "Contract",
"invoice": "Invoice",
"gallery": "Gallery",
"hours": "Hours"
},
"email": {
"preview": "Preview",
"resend": "Resend",
"sendNow": "Send now",
"cancel": "Cancel",
"retry": "Retry",
"previewTitle": "Email preview",
"noPreview": "No stored preview for this email — it was sent before previews were captured.",
"reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.",
"reRenderedTag": "≈ re-rendered"
},
"toast": {
"created": "Project created",
"createFailed": "Could not create project",
"saved": "Project saved",
"saveFailed": "Save failed",
"emailAction": "Done",
"emailActionFailed": "Action failed",
"previewFailed": "Could not load preview"
},
"error": {
"customerMismatch": "That belongs to a different customer than this project."
}
},
"calendar": {
"pageTitle": "Calendar",
"subtitle": "Events, logged hours, and pending quotes/contracts in one view.",
@@ -276,6 +276,11 @@ export const AdminDashboard: React.FC = () => {
const getActivityMessage = (): string => {
const params: Record<string, any> = {
eventName: activity.eventName || t('common.unknown'),
// Customer/account activity keys (customer_login,
// customer_invitation_*, customer_updated, …) interpolate
// {{email}}; without it the literal placeholder rendered.
// Sourced the same way formatActivityMessage does.
email: activity.metadata?.email || activity.actorName || '',
count: activity.metadata?.count || 0,
template: activity.metadata?.template_key || '',
categoryName: activity.metadata?.category_name || ''
@@ -566,8 +566,31 @@ export const BillEditorPage: React.FC = () => {
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
</label>
</div>
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.scheduledSendAt', 'Scheduled send (optional)')}</label>
{/* Localized date + time (honours general_date_format +
general_time_format) instead of a native datetime-local, which
renders in the browser locale (US date + 12h). Recombined into
the "YYYY-MM-DDTHH:MM" the payload + scheduler expect. */}
<div className="grid grid-cols-2 gap-3">
<LocalizedDateInput
value={scheduledSendAt ? scheduledSendAt.slice(0, 10) : ''}
onChange={(iso) => {
if (!iso) { setScheduledSendAt(''); return; }
const time = scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : '09:00';
setScheduledSendAt(`${iso}T${time}`);
}}
/>
<TimeField
value={scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : ''}
onChange={(hhmm) => {
const date = scheduledSendAt ? scheduledSendAt.slice(0, 10) : '';
if (!date) return;
setScheduledSendAt(`${date}T${hhmm || '09:00'}`);
}}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.qrFormat', 'Payment QR format')}</label>
<select
@@ -24,6 +24,7 @@ import {
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
interface BlockRow {
blockId: number;
@@ -63,6 +64,7 @@ export const ContractEditorPage: React.FC = () => {
const [language, setLanguage] = useState('de');
const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10));
const [validUntil, setValidUntil] = useState('');
const [projectId, setProjectId] = useState<number | null>(null);
const [blocks, setBlocks] = useState<BlockRow[]>([]);
// Load existing contract on edit.
@@ -110,6 +112,7 @@ export const ContractEditorPage: React.FC = () => {
setLanguage(c.language || 'de');
setIssueDate(c.issueDate);
setValidUntil(c.validUntil || '');
setProjectId(c.projectId ?? null);
setBlocks((c.inclusions || []).map((inc) => ({
blockId: inc.blockId,
section: inc.section,
@@ -188,6 +191,7 @@ export const ContractEditorPage: React.FC = () => {
outroText: outroText || null,
issueDate,
validUntil: validUntil || undefined,
projectId: projectId ?? null,
});
// Apply block toggles + ordering as an update right after create.
await contractsService.update(created.contract.id, {
@@ -202,6 +206,10 @@ export const ContractEditorPage: React.FC = () => {
navigate(`/admin/clients/contracts/${created.id}`);
},
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
return;
}
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
@@ -220,6 +228,7 @@ export const ContractEditorPage: React.FC = () => {
language,
issueDate,
validUntil: validUntil || undefined,
projectId: projectId ?? null,
blocks: blocks.map((b) => ({
blockId: b.blockId, included: b.included, position: b.position,
})),
@@ -230,6 +239,10 @@ export const ContractEditorPage: React.FC = () => {
navigate(`/admin/clients/contracts/${numericId}`);
},
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
return;
}
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
@@ -348,6 +361,16 @@ export const ContractEditorPage: React.FC = () => {
</div>
)}
{/* Project link (renders only when the projects feature is on). */}
<div className="mb-4">
<ProjectSelect
label={t('projects.picker.label', 'Project') as string}
value={projectId}
customerAccountId={customerAccountId}
onChange={setProjectId}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
@@ -0,0 +1,491 @@
/**
* Admin → Project Cockpit (the 360° Project Overview).
*
* One project, everything in it: an editable header, a milestone timeline,
* and a single dated feed merging every email (with the actual sent HTML
* preview + resend/cancel/retry/send-now actions), quote, contract, invoice,
* gallery and logged hour that rolls up to the project. Admin-only.
*/
import React, { useMemo, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
Mail, FileText, ScrollText, Receipt, Image as ImageIcon, Clock,
X, Send, RotateCw, Ban, Eye, Save, ArrowLeft, Plus, Search,
} from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import {
projectsService,
type ProjectOverview,
type EmailPreview,
type ProjectMilestone,
} from '../../../services/projects.service';
import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
type FeedKind = 'email' | 'quote' | 'contract' | 'invoice' | 'gallery' | 'hours';
interface FeedItem {
key: string;
kind: FeedKind;
date: string | null;
title: string;
subtitle?: string;
amount?: string;
status?: string;
href?: string | null;
emailId?: number;
emailStatus?: string;
reRendered?: boolean;
}
/** The feature flag that gates each document's detail ROUTE (RequireFeature
* in App.tsx). The cockpit surfaces docs by permission, but their detail
* pages live behind these flags — so a link is only live when the flag is
* on, else clicking would bounce to /admin/dashboard. Galleries/events have
* no such gate. */
const FLAG_FOR_KIND: Partial<Record<FeedKind, FeatureKey>> = {
quote: 'quotes',
contract: 'contracts',
invoice: 'bills',
};
/** Detail-page route for a clickable document, or null when there isn't one
* (hours have no page; emails open the preview) OR the destination's feature
* flag is off (so we don't render a link that just redirects away). */
function hrefFor(
kind: FeedKind | ProjectMilestone['kind'],
id: number | undefined,
flags: Record<string, boolean>,
): string | null {
if (id == null) return null;
const flag = FLAG_FOR_KIND[kind as FeedKind];
if (flag && !flags[flag]) return null;
switch (kind) {
case 'quote': return `/admin/clients/quotes/${id}`;
case 'contract': return `/admin/clients/contracts/${id}`;
case 'invoice': return `/admin/clients/bills/${id}`;
case 'gallery': return `/admin/events/${id}`;
default: return null;
}
}
const KIND_ICON: Record<FeedKind, React.ComponentType<{ className?: string }>> = {
email: Mail,
quote: FileText,
contract: ScrollText,
invoice: Receipt,
gallery: ImageIcon,
hours: Clock,
};
/** Prepare the rendered email for a read-only preview:
* - `<base target="_blank">` so the sandboxed iframe (no allow-popups) blocks
* every link, preventing accidental clicks on the live Accept/Decline URLs.
* - `overflow-wrap:break-word` so a long unbreakable token (e.g. the gallery
* link) wraps inside the container instead of forcing horizontal scroll.
* break-word only kicks in on overflow, so it won't disturb table layout. */
function preparePreviewHtml(html: string): string {
const inject = '<base target="_blank"><style>*{overflow-wrap:break-word}</style>';
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + inject);
return inject + html;
}
function minutesToHours(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
export const ProjectCockpitPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const projectId = id ? parseInt(id, 10) : null;
const navigate = useNavigate();
const qc = useQueryClient();
const { flags } = useFeatureFlags();
const { format, formatTime } = useLocalizedDate();
const [editName, setEditName] = useState<string | null>(null);
const [preview, setPreview] = useState<EmailPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [eventSearch, setEventSearch] = useState('');
const { data, isLoading } = useQuery<ProjectOverview>({
queryKey: ['project-overview', projectId],
queryFn: () => projectsService.overview(projectId as number),
enabled: projectId !== null,
});
const renameMutation = useMutation({
mutationFn: (name: string) => projectsService.update(projectId as number, { name }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
qc.invalidateQueries({ queryKey: ['projects'] });
setEditName(null);
toast.success(t('projects.toast.saved', 'Project saved') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.saveFailed', 'Save failed') as string)),
});
const emailActionMutation = useMutation({
mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => {
if (action === 'resend') return projectsService.resendEmail(emailId);
if (action === 'cancel') return projectsService.cancelEmail(emailId);
if (action === 'retry') return projectsService.retryEmail(emailId);
return projectsService.sendEmailNow(emailId);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
toast.success(t('projects.toast.emailAction', 'Done') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
});
// Event search for the "attach event" control (results exclude events
// already on this project).
const { data: eventResults } = useQuery({
queryKey: ['project-event-search', eventSearch],
queryFn: () => eventsService.getEvents(1, 10, undefined, eventSearch),
enabled: eventSearch.trim().length >= 2,
});
const attachEventMutation = useMutation({
mutationFn: (eventId: number) => projectsService.assignEvent(projectId as number, eventId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
qc.invalidateQueries({ queryKey: ['projects'] });
setEventSearch('');
toast.success(t('projects.events.attached', 'Event attached') as string);
},
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That belongs to a different customer than this project.') as string);
return;
}
toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string));
},
});
const openPreview = async (emailId: number) => {
setPreviewLoading(true);
try {
const p = await projectsService.emailPreview(emailId);
setPreview(p);
} catch (err: any) {
toast.error(err?.response?.data?.error || (t('projects.toast.previewFailed', 'Could not load preview') as string));
} finally {
setPreviewLoading(false);
}
};
// Merge every rolled-up document into one dated feed (newest first).
const feed = useMemo<FeedItem[]>(() => {
if (!data) return [];
const items: FeedItem[] = [];
for (const e of data.emails) {
items.push({
key: `email-${e.id}`, kind: 'email', date: e.sentAt || e.queuedAt,
title: t(`projects.feed.email`, 'Email') + ` · ${e.type}`,
subtitle: e.recipient + (e.error ? `${e.error}` : ''),
status: e.status, emailId: e.id, emailStatus: e.status, reRendered: !e.stored,
});
}
for (const q of data.quotes) {
items.push({
key: `quote-${q.id}`, kind: 'quote', date: q.issue_date,
title: t('projects.feed.quote', 'Quote') + ` ${q.quote_number}`,
status: q.status, amount: formatMoneyMinor(Number(q.total_amount_minor), q.currency),
href: hrefFor('quote', q.id, flags),
});
}
for (const c of data.contracts) {
items.push({
key: `contract-${c.id}`, kind: 'contract', date: c.issue_date,
title: t('projects.feed.contract', 'Contract') + ` ${c.contract_number}`,
status: c.status, href: hrefFor('contract', c.id, flags),
});
}
for (const inv of data.invoices) {
items.push({
key: `invoice-${inv.id}`, kind: 'invoice', date: inv.issue_date,
title: t('projects.feed.invoice', 'Invoice') + ` ${inv.invoice_number}`,
status: inv.status, amount: formatMoneyMinor(Number(inv.total_amount_minor), inv.currency),
href: hrefFor('invoice', inv.id, flags),
});
}
for (const ev of data.events) {
items.push({
key: `gallery-${ev.id}`, kind: 'gallery', date: ev.event_date,
title: t('projects.feed.gallery', 'Gallery') + ` · ${ev.event_name}`,
subtitle: ev.slug, href: hrefFor('gallery', ev.id, flags),
});
}
for (const h of data.hours.entries) {
items.push({
key: `hours-${h.id}`, kind: 'hours', date: h.entry_date,
title: t('projects.feed.hours', 'Hours') + ` · ${minutesToHours(h.duration_minutes)}`,
subtitle: h.description || undefined, status: h.status || undefined,
});
}
return items.sort((a, b) => {
const da = a.date ? new Date(a.date).getTime() : 0;
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
}, [data, t, flags]);
if (isLoading) return <Loading />;
if (!data) return <div className="p-6 text-neutral-500">{t('projects.notFound', 'Project not found')}</div>;
const { project, milestones, hours, valuation } = data;
const valueBuckets = valuation?.byCurrency?.filter((b) => b.totalMinor !== 0 || b.paidMinor !== 0) || [];
return (
<div>
<Link to="/admin/clients/projects" className="inline-flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 mb-3">
<ArrowLeft className="w-4 h-4" />{t('projects.backToList', 'All projects')}
</Link>
{/* Header */}
<Card className="mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1">
{editName === null ? (
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{project.name}</h1>
) : (
<div className="flex items-center gap-2">
<Input value={editName} onChange={(e) => setEditName(e.target.value)} className="max-w-sm" />
<Button variant="primary" disabled={!editName.trim() || renameMutation.isPending} onClick={() => renameMutation.mutate(editName.trim())}>
<Save className="w-4 h-4" />
</Button>
<Button variant="outline" onClick={() => setEditName(null)}><X className="w-4 h-4" /></Button>
</div>
)}
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{project.customerEmail || t('projects.noCustomer', 'No single customer')}
{' · '}
{t('projects.eventCount', '{{count}} events', { count: data.events.length })}
{' · '}
{t('projects.totalHours', '{{hours}} logged', { hours: minutesToHours(hours.totalMinutes) })}
</p>
</div>
<div className="flex items-start gap-4">
{valueBuckets.length > 0 && (
<div className="text-right">
<div className="text-xs text-neutral-500 dark:text-neutral-400">{t('projects.value.label', 'Project value')}</div>
{valueBuckets.map((b) => (
<div key={b.currency} className="text-lg font-bold text-neutral-900 dark:text-neutral-100 tabular-nums">
{formatMoneyMinor(b.totalMinor, b.currency)}
</div>
))}
{valueBuckets.some((b) => b.paidMinor !== 0) && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('projects.value.paid', 'paid')}: {valueBuckets.map((b) => formatMoneyMinor(b.paidMinor, b.currency)).join(' · ')}
</div>
)}
</div>
)}
{editName === null && (
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
)}
</div>
</div>
</Card>
{/* Events in this project + attach control */}
<Card className="mb-4">
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.events.title', 'Events')}</h2>
{data.events.length === 0 ? (
<p className="text-sm text-neutral-500 mb-3">{t('projects.events.none', 'No events grouped under this project yet.')}</p>
) : (
<ul className="space-y-1 mb-3">
{data.events.map((ev) => (
<li key={ev.id} className="flex items-center justify-between text-sm rounded-md border border-neutral-100 dark:border-neutral-800 px-3 py-1.5">
<span className="font-medium text-neutral-900 dark:text-neutral-100">{ev.event_name}</span>
<span className="text-xs text-neutral-500">{ev.event_date ? format(ev.event_date) : '—'}</span>
</li>
))}
</ul>
)}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
<Input
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
placeholder={t('projects.events.searchPlaceholder', 'Attach an event — search by name…') as string}
className="pl-9"
/>
{eventSearch.trim().length >= 2 && eventResults?.events && eventResults.events.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg max-h-56 overflow-auto">
{eventResults.events
.filter((ev: any) => !data.events.some((existing) => existing.id === ev.id))
.map((ev: any) => (
<button
key={ev.id}
onClick={() => attachEventMutation.mutate(ev.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700"
>
<Plus className="w-3 h-3 text-neutral-400" />
<span className="flex-1 truncate text-neutral-900 dark:text-neutral-100">{ev.event_name}</span>
<span className="text-xs text-neutral-500">{ev.event_date ? format(ev.event_date) : ''}</span>
</button>
))}
</div>
)}
</div>
</Card>
{/* Milestone timeline */}
{milestones && milestones.length > 0 && (
<Card className="mb-4">
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.timeline', 'Milestones')}</h2>
<div className="flex flex-wrap gap-3">
{milestones.map((m, i) => {
const Icon = KIND_ICON[m.kind] || FileText;
const href = hrefFor(m.kind, m.id, flags);
return (
<div
key={`${m.kind}-${i}`}
onClick={href ? () => navigate(href) : undefined}
className={`flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2 ${href ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
>
<Icon className="w-4 h-4 text-neutral-500" />
<div>
<div className="text-xs font-medium text-neutral-900 dark:text-neutral-100">{m.label}</div>
<div className="text-xs text-neutral-500">{m.date ? format(m.date) : '—'}</div>
</div>
</div>
);
})}
</div>
</Card>
)}
{/* Dated feed */}
<Card>
<h2 className="text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300">{t('projects.feed.title', 'Activity')}</h2>
{feed.length === 0 ? (
<div className="text-center py-8 text-neutral-500">{t('projects.feed.empty', 'Nothing rolled up to this project yet.')}</div>
) : (
<ul className="space-y-2">
{feed.map((item) => {
const Icon = KIND_ICON[item.kind];
// Whole-row click: documents navigate to their detail page,
// emails open the preview (so the row behaves like its buttons,
// not a dead strip next to them). Hours have neither → static.
const onRowClick = item.href
? () => navigate(item.href as string)
: (item.kind === 'email' && item.emailId != null ? () => openPreview(item.emailId as number) : undefined);
return (
<li
key={item.key}
onClick={onRowClick}
className={`flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2 ${onRowClick ? 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800/60' : ''}`}
>
<Icon className="w-4 h-4 mt-0.5 text-neutral-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{item.title}</span>
<span className="text-xs text-neutral-500 flex-shrink-0">
{item.date ? `${format(item.date)} ${item.kind === 'email' ? formatTime(item.date) : ''}` : '—'}
</span>
</div>
{item.subtitle && <div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{item.subtitle}</div>}
<div className="flex items-center gap-2 mt-1">
{item.status && (
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300">{item.status}</span>
)}
{item.amount && <span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">{item.amount}</span>}
{item.kind === 'email' && item.emailId != null && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => openPreview(item.emailId as number)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline">
<Eye className="w-3 h-3" />{t('projects.email.preview', 'Preview')}
</button>
{item.reRendered && (
<span
title={t('projects.email.reRendered', 'Re-rendered from the current template — may differ slightly from what was sent.') as string}
className="inline-block rounded-full px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"
>
{t('projects.email.reRenderedTag', '≈ re-rendered')}
</span>
)}
{item.emailStatus === 'sent' && (
<button onClick={() => emailActionMutation.mutate({ action: 'resend', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.resend', 'Resend')}
</button>
)}
{item.emailStatus === 'pending' && (
<>
<button onClick={() => emailActionMutation.mutate({ action: 'sendNow', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-neutral-600 dark:text-neutral-300 hover:underline">
<Send className="w-3 h-3" />{t('projects.email.sendNow', 'Send now')}
</button>
<button onClick={() => emailActionMutation.mutate({ action: 'cancel', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-red-600 hover:underline">
<Ban className="w-3 h-3" />{t('projects.email.cancel', 'Cancel')}
</button>
</>
)}
{item.emailStatus === 'failed' && (
<button onClick={() => emailActionMutation.mutate({ action: 'retry', emailId: item.emailId as number })} className="inline-flex items-center gap-1 text-xs text-amber-600 hover:underline">
<RotateCw className="w-3 h-3" />{t('projects.email.retry', 'Retry')}
</button>
)}
</div>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</Card>
{/* Email preview modal */}
{(preview || previewLoading) && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setPreview(null)}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-4 py-3">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('projects.email.previewTitle', 'Email preview')}</h3>
<button onClick={() => setPreview(null)} className="text-neutral-500 hover:text-neutral-700"><X className="w-5 h-5" /></button>
</div>
<div className="flex-1 overflow-auto p-4">
{previewLoading ? (
<Loading />
) : preview && preview.available && preview.html ? (
<>
{!preview.exact && (
<div className="mb-3 rounded-md bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
{t('projects.email.reRendered', 'Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.')}
</div>
)}
{/* Read-only preview: renders the email with its own brand
colors (color-scheme:normal stops the dark app theme from
tinting it), but `sandbox` (no allow-popups/scripts/forms)
+ neutralizeLinks make every link inert — so the admin
can't accidentally trigger the live Accept/Decline URLs by
clicking inside the preview. Scrolling still works. */}
<iframe
title="email-preview"
srcDoc={preparePreviewHtml(preview.html)}
sandbox=""
style={{ colorScheme: 'normal' }}
className="w-full h-[60vh] border border-neutral-200 dark:border-neutral-700 rounded"
/>
</>
) : (
<div className="text-center py-10 text-neutral-500">
{t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,155 @@
/**
* Admin → Project Overview list page.
*
* Lists every project (the admin-only grouping layer above events) with a
* search box, an inline "new project" creator, and a click-through to each
* project's cockpit. Visual shape mirrors the other /admin/clients lists so
* the CRM area feels like one product. Admin-only — customers never see it.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Plus, Search, FolderKanban } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { projectsService, type ProjectSummary } from '../../../services/projects.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
/** Render a project's rolled-up value (newest stage per deal, cumulative),
* one entry per currency. Convention (deliberately differs from the Events
* column): a zero *count* is a real number → "0"; a zero *value* means "nothing
* billed/quoted yet" → em dash, since "CHF 0.00" would wrongly imply a real
* zero-value deal. */
function formatValuation(p: ProjectSummary): string {
const buckets = p.valuation?.byCurrency?.filter((b) => b.totalMinor !== 0) || [];
if (buckets.length === 0) return '—';
return buckets.map((b) => formatMoneyMinor(b.totalMinor, b.currency)).join(' · ');
}
export const ProjectsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { format } = useLocalizedDate();
const [search, setSearch] = useState('');
const [newName, setNewName] = useState('');
const { data: projects, isLoading } = useQuery({
queryKey: ['projects', { search }],
queryFn: () => projectsService.list({ q: search || undefined }),
});
const createMutation = useMutation({
mutationFn: () => projectsService.create({ name: newName.trim() }),
onSuccess: (project) => {
qc.invalidateQueries({ queryKey: ['projects'] });
setNewName('');
toast.success(t('projects.toast.created', 'Project created') as string);
navigate(`/admin/clients/projects/${project.id}`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || err?.message || (t('projects.toast.createFailed', 'Could not create project') as string));
},
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<FolderKanban className="w-6 h-6 text-neutral-500" />
{t('projects.title', 'Project Overview')}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('projects.subtitle', 'Group events into projects and see every email, document, gallery and hour in one cockpit.')}
</p>
</div>
</div>
{/* Inline create */}
<Card className="mb-4">
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('projects.create.label', 'New project name')}
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && newName.trim()) createMutation.mutate(); }}
placeholder={t('projects.create.placeholder', 'e.g. Müller wedding 2026') as string}
/>
</div>
<Button
variant="primary"
disabled={!newName.trim() || createMutation.isPending}
isLoading={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
<Plus className="w-4 h-4 mr-1" />{t('projects.create.button', 'Create project')}
</Button>
</div>
</Card>
{/* Search */}
<div className="relative mb-3 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('projects.search', 'Search by name or customer…') as string}
className="pl-9"
/>
</div>
{isLoading ? (
<Loading />
) : !projects || projects.length === 0 ? (
<Card>
<div className="text-center py-10 text-neutral-500 dark:text-neutral-400">
{t('projects.empty', 'No projects yet. Create one above, or events you already have were grouped automatically.')}
</div>
</Card>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-left">
<tr>
<th className="px-4 py-2 font-medium">{t('projects.col.name', 'Project')}</th>
<th className="px-4 py-2 font-medium">{t('projects.col.customer', 'Customer')}</th>
<th className="px-4 py-2 font-medium text-right">{t('projects.col.events', 'Events')}</th>
<th className="px-4 py-2 font-medium text-right">{t('projects.col.value', 'Value')}</th>
<th className="px-4 py-2 font-medium">{t('projects.col.status', 'Status')}</th>
<th className="px-4 py-2 font-medium">{t('projects.col.updated', 'Updated')}</th>
</tr>
</thead>
<tbody>
{projects.map((p: ProjectSummary) => (
<tr
key={p.id}
onClick={() => navigate(`/admin/clients/projects/${p.id}`)}
className="border-t border-neutral-100 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/60 cursor-pointer"
>
<td className="px-4 py-2 font-medium text-neutral-900 dark:text-neutral-100">{p.name}</td>
<td className="px-4 py-2 text-neutral-600 dark:text-neutral-400">{p.customerEmail || '—'}</td>
<td className="px-4 py-2 text-right tabular-nums">{p.eventCount ?? 0}</td>
<td className="px-4 py-2 text-right tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{formatValuation(p)}</td>
<td className="px-4 py-2">
<span className="inline-block rounded-full px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200">
{p.status}
</span>
</td>
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400">{p.updatedAt ? format(p.updatedAt) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
@@ -24,6 +24,7 @@ import {
} from '../../../services/quotes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -59,6 +60,8 @@ interface FormState {
internalNotes: string;
ccPdfEmail: string;
businessBankAccountId: number | null;
/** Migration 121 — optional Project Overview link. */
projectId: number | null;
lineItems: EditableLineItem[];
// Ad-hoc installments (commit #6). null = use the payment-timing
// template's installments; array = explicit per-quote override.
@@ -88,6 +91,7 @@ const empty: FormState = {
internalNotes: '',
ccPdfEmail: '',
businessBankAccountId: null,
projectId: null,
lineItems: [],
installments: null,
};
@@ -123,6 +127,8 @@ function buildPayload(f: FormState): QuoteCreatePayload {
internalNotes: f.internalNotes || undefined,
ccPdfEmail: f.ccPdfEmail || undefined,
businessBankAccountId: f.businessBankAccountId || undefined,
// Migration 121 — Project Overview link. Send null to clear.
projectId: f.projectId ?? null,
lineItems: f.lineItems.map((li) => ({
position: li.position,
quantity: li.quantity,
@@ -214,6 +220,7 @@ export const QuoteEditorPage: React.FC = () => {
internalNotes: q.internalNotes || '',
ccPdfEmail: q.ccPdfEmail || '',
businessBankAccountId: q.businessBankAccountId,
projectId: q.projectId ?? null,
lineItems: existing.lineItems.map((li) => ({
id: li.id,
position: li.position,
@@ -389,6 +396,9 @@ export const QuoteEditorPage: React.FC = () => {
if (err?.response?.data?.code === 'CUSTOMER_FEATURE_DISABLED') {
toast.error(t('quotes.errors.customerFeatureDisabled',
'This customer has Quotes disabled. Enable "Quotes" on the customer detail page first.'));
} else if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch',
'That project belongs to a different customer than this entry.'));
} else if (err?.response?.data?.code === 'VALIDATION_ERROR' && Array.isArray(err?.response?.data?.details)) {
// Show the first field that failed validation so the admin
// knows what to fix instead of just seeing "Validation failed".
@@ -479,6 +489,15 @@ export const QuoteEditorPage: React.FC = () => {
}))}
searchPlaceholder={t('quotes.customerSearch', 'Search customer by email or company…') as string}
/>
{/* Project link (renders only when the projects feature is on). */}
<div className="mt-3">
<ProjectSelect
label={t('projects.picker.label', 'Project') as string}
value={form.projectId}
customerAccountId={form.customerAccountId}
onChange={(projectId) => setForm((f) => ({ ...f, projectId }))}
/>
</div>
</Card>
{/* Section: Event */}
@@ -21,6 +21,18 @@ import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../..
import { DecimalInput } from '../../../components/common/DecimalInput';
import { toast } from 'react-toastify';
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
// engine that lacks it.
const IANA_TIMEZONES: string[] = (() => {
try {
// @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
return Intl.supportedValuesOf('timeZone') as string[];
} catch {
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
}
})();
export const SettingsBusinessProfilePage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -117,17 +129,26 @@ export const SettingsBusinessProfilePage: React.FC = () => {
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
{/* Migration 137 — IANA timezone string for the admin calendar.
Free-text; backend caps at 64 chars. When blank the calendar
UI falls back to the browser's `Intl.DateTimeFormat()
.resolvedOptions().timeZone`. */}
<Input
label={t('businessProfile.field.timezone', 'Timezone (IANA)') as string}
value={profile.timezone || ''}
maxLength={64}
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
/>
{/* Migration 137 — IANA timezone for the admin calendar + the
scheduled-email business-hours snapping. Dropdown of the full
IANA list; blank = fall back to the server/browser tz. */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.field.timezone', 'Timezone (IANA)')}
</label>
<select
value={profile.timezone || ''}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
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 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">
{t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
</option>
{IANA_TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
@@ -25,7 +25,7 @@ import { useNavigate } from 'react-router-dom';
import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { format, parseISO } from 'date-fns';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Button, Loading } from '../../components/common';
@@ -51,6 +51,10 @@ const DEFAULT_SORT: SortKey = 'newest';
export const CustomerDashboardPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// Localized date formatting — respects general_date_format + the active UI
// language. Previously used raw date-fns `format(parseISO(iso),'PP')` with
// no locale, so dates rendered en-US ("May", "Jun") under a German UI.
const { format: fmtLocalized } = useLocalizedDate();
const { data: events, isLoading, error } = useQuery({
queryKey: ['customer-events'],
@@ -125,7 +129,7 @@ export const CustomerDashboardPage: React.FC = () => {
const formatDate = (iso: string | null) => {
if (!iso) return null;
try { return format(parseISO(iso), 'PP'); } catch { return null; }
try { return fmtLocalized(iso); } catch { return null; }
};
return (
@@ -109,6 +109,8 @@ export interface ContractSummary {
/** Cross-document lineage UUID (migration 140). See QuoteSummary. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -194,6 +196,8 @@ export interface ContractCreatePayload {
outroText?: string | null;
issueDate?: string;
validUntil?: string;
/** Migration 121 — optional link to a Project Overview project. */
projectId?: number | null;
}
export interface ContractUpdatePayload {
@@ -211,6 +215,8 @@ export interface ContractUpdatePayload {
* rows from this payload — caller controls inclusion + per-section
* order via the position field. Omit to leave inclusions untouched. */
blocks?: Array<{ blockId: number; included?: boolean; position?: number }>;
/** Migration 121 — optional Project Overview link. null clears it. */
projectId?: number | null;
}
export interface ContractBlockCreatePayload {
@@ -451,6 +451,8 @@ export interface HourEntryCreatePayload {
endTime: string; // HH:MM
hourlyRateMinorOverride?: number | null;
description?: string | null;
/** Migration 118 — optional "book to project" link. */
projectId?: number | null;
}
export interface HourEntryUpdatePayload {
@@ -54,7 +54,11 @@ export type FeatureKey =
| 'incomingInvoices'
// Expenses (migration 127) — internal expenses (mileage / per-diem / cash).
// Separate Accounting sub-feature; requires `accounting`.
| 'expenses';
| 'expenses'
// Projects (migration 120). Admin-only grouping layer above events with the
// 360° Project Overview cockpit + the "book to project" hours control. Off
// by default; gates the CRM → Overview area entirely.
| 'projects';
export type FeatureFlags = Record<FeatureKey, boolean>;
+194
View File
@@ -0,0 +1,194 @@
/**
* Admin → Projects API client. Hits /api/admin/projects/*.
*
* Projects are the admin-only grouping layer above events (Model A). The
* cockpit "overview" rolls up the per-event/per-customer documents. Mirrors
* the contracts/bills service shape: `data.data || data` unwrap.
*/
import { api } from '../config/api';
export type ProjectStatus = 'active' | 'archived' | string;
/** Rolled-up project value: newest stage wins per deal (invoice > quote;
* contracts carry no total), cumulative across events, split by currency. */
export interface ProjectValuation {
byCurrency: Array<{ currency: string; totalMinor: number; paidMinor: number }>;
}
export interface ProjectSummary {
id: number;
name: string;
customerAccountId: number | null;
customerEmail: string | null;
status: ProjectStatus;
eventCount?: number;
valuation?: ProjectValuation;
createdAt: string | null;
updatedAt: string | null;
}
export interface ProjectEvent {
id: number;
event_name: string;
event_date: string | null;
slug: string;
is_active: boolean | number;
is_draft: boolean | number;
expires_at: string | null;
is_archived: boolean | number;
}
export interface ProjectEmail {
id: number;
recipient: string;
type: string;
status: string;
queuedAt: string | null;
sentAt: string | null;
error: string | null;
eventId: number | null;
/** true = exact HTML stored at send time; false = preview re-rendered. */
stored: boolean;
}
export interface ProjectInvoice {
id: number;
invoice_number: string;
status: string;
kind: string | null;
issue_date: string | null;
due_date: string | null;
total_amount_minor: number;
paid_amount_minor: number | null;
paid_at: string | null;
currency: string;
event_id: number | null;
deal_uuid: string | null;
}
export interface ProjectQuote {
id: number;
quote_number: string;
status: string;
issue_date: string | null;
valid_until: string | null;
total_amount_minor: number;
currency: string;
deal_uuid: string | null;
}
export interface ProjectContract {
id: number;
contract_number: string;
status: string;
issue_date: string | null;
signed_by_customer_at: string | null;
deal_uuid: string | null;
}
export interface ProjectHourEntry {
id: number;
entry_date: string | null;
duration_minutes: number;
description: string | null;
status: string | null;
invoice_id: number | null;
}
export interface ProjectMilestone {
kind: 'quote' | 'contract' | 'gallery' | 'invoice';
id?: number;
label: string;
date: string | null;
}
export interface ProjectOverview {
project: ProjectSummary;
events: ProjectEvent[];
emails: ProjectEmail[];
quotes: ProjectQuote[];
contracts: ProjectContract[];
invoices: ProjectInvoice[];
hours: { entries: ProjectHourEntry[]; totalMinutes: number };
milestones: ProjectMilestone[];
valuation: ProjectValuation;
}
export interface EmailPreview {
id: number;
recipient: string;
type: string;
status: string;
available: boolean;
/** true = exact bytes stored at send time; false = re-rendered from the
* current template (approximation for emails sent before capture). */
exact: boolean;
html: string | null;
}
export const projectsService = {
async list(params: { q?: string; status?: string } = {}): Promise<ProjectSummary[]> {
const { data } = await api.get('/admin/projects', { params });
const body = data.data || data;
return body.projects || [];
},
async get(id: number): Promise<ProjectSummary> {
const { data } = await api.get(`/admin/projects/${id}`);
const body = data.data || data;
return body.project;
},
async create(payload: { name: string; customerAccountId?: number | null }): Promise<ProjectSummary> {
const { data } = await api.post('/admin/projects', payload);
const body = data.data || data;
return body.project;
},
async update(
id: number,
payload: { name?: string; customerAccountId?: number | null; status?: string },
): Promise<ProjectSummary> {
const { data } = await api.put(`/admin/projects/${id}`, payload);
const body = data.data || data;
return body.project;
},
async overview(id: number): Promise<ProjectOverview> {
const { data } = await api.get(`/admin/projects/${id}/overview`);
return (data.data || data) as ProjectOverview;
},
async assignEvent(projectId: number, eventId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/events`, { eventId });
},
async assignQuote(projectId: number, quoteId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/quotes`, { quoteId });
},
async assignContract(projectId: number, contractId: number): Promise<void> {
await api.post(`/admin/projects/${projectId}/contracts`, { contractId });
},
async emailPreview(emailId: number): Promise<EmailPreview> {
const { data } = await api.get(`/admin/projects/email/${emailId}/preview`);
return (data.data || data) as EmailPreview;
},
async resendEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/resend`);
},
async cancelEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/cancel`);
},
async retryEmail(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/retry`);
},
async sendEmailNow(emailId: number): Promise<void> {
await api.post(`/admin/projects/email/${emailId}/send-now`);
},
};
+5
View File
@@ -43,6 +43,8 @@ export interface QuoteSummary {
* doc — contract, invoices, Storni — that shares this deal. */
dealUuid: string | null;
customerAccountId: number;
/** Migration 121 — Project Overview link (null when unlinked). */
projectId: number | null;
customer: {
email: string | null;
displayName: string | null;
@@ -195,6 +197,9 @@ export interface QuoteCreatePayload {
internalNotes?: string;
ccPdfEmail?: string;
businessBankAccountId?: number;
/** Migration 121 — optional link to a Project Overview project.
* null clears the link; undefined leaves it unchanged. */
projectId?: number | null;
lineItems: QuoteLineItem[];
}