From 81553aa0e3476e7fdd2d6fcda7ebfa1f1d7c9b57 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:25:38 +0200 Subject: [PATCH] feat(projects): Project Overview cockpit UI + CRM nav entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectsListPage: searchable list + inline create, under CRM → Overview. - ProjectCockpitPage: editable header, milestone timeline, and one dated feed merging emails (with sent-HTML preview modal + resend/cancel/retry/ send-now actions), quotes, contracts, invoices, galleries and hours. - Routes /admin/clients/projects(/:id) gated by RequireFeature flag=projects. - ClientsLayout 'Overview' nav entry (top), gated on flags.projects. - en + de i18n for the projects namespace + book-to-project label. --- frontend/src/App.tsx | 8 + .../src/components/admin/ClientsLayout.tsx | 9 +- frontend/src/i18n/locales/de.json | 59 ++++ frontend/src/i18n/locales/en.json | 59 ++++ .../admin/projects/ProjectCockpitPage.tsx | 310 ++++++++++++++++++ .../pages/admin/projects/ProjectsListPage.tsx | 141 ++++++++ 6 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/admin/projects/ProjectCockpitPage.tsx create mode 100644 frontend/src/pages/admin/projects/ProjectsListPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4d72da6..14e85596 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -202,6 +204,12 @@ function App() { } /> } /> + {/* Project Overview (CRM) — admin-only grouping + layer above events, gated by `projects`. */} + }> + } /> + } /> + {/* Bills / invoices (CRM) — gated by `bills`. */} }> } /> diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index d3de2e1a..811c350d 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -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, Calculator, 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', diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c955df08..6b41eb92 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3123,6 +3123,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." }, @@ -3370,6 +3371,7 @@ "body": "Aktiviere „Konten\" (oder eine andere CRM-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen." }, "subnav": { + "overview": "Übersicht", "accounts": "Konten", "quotes": "Angebote", "contracts": "Verträge", @@ -3380,6 +3382,63 @@ "development": "Entwicklung" } }, + "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", + "status": "Status", + "updated": "Aktualisiert" + }, + "picker": { + "label": "Projekt", + "none": "Kein Projekt" + }, + "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." + }, + "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" + } + }, "calendar": { "pageTitle": "Kalender", "subtitle": "Termine, erfasste Stunden und offene Angebote/Verträge in einer Ansicht.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7e8c5a03..dccf833f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3123,6 +3123,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." }, @@ -3370,6 +3371,7 @@ "body": "Enable Accounts (or another CRM sub-feature) under Settings → Features to get started." }, "subnav": { + "overview": "Overview", "accounts": "Accounts", "quotes": "Quotes", "contracts": "Contracts", @@ -3380,6 +3382,63 @@ "development": "Development" } }, + "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", + "status": "Status", + "updated": "Updated" + }, + "picker": { + "label": "Project", + "none": "No project" + }, + "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." + }, + "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" + } + }, "calendar": { "pageTitle": "Calendar", "subtitle": "Events, logged hours, and pending quotes/contracts in one view.", diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx new file mode 100644 index 00000000..3efcbf03 --- /dev/null +++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx @@ -0,0 +1,310 @@ +/** + * 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 } 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, +} from 'lucide-react'; +import { Button, Card, Input, Loading } from '../../../components/common'; +import { + projectsService, + type ProjectOverview, + type EmailPreview, +} from '../../../services/projects.service'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { formatMoneyMinor } from '../../../utils/money'; + +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; + emailId?: number; + emailStatus?: string; +} + +const KIND_ICON: Record> = { + email: Mail, + quote: FileText, + contract: ScrollText, + invoice: Receipt, + gallery: ImageIcon, + hours: Clock, +}; + +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 qc = useQueryClient(); + const { format, formatTime } = useLocalizedDate(); + + const [editName, setEditName] = useState(null); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + const { data, isLoading } = useQuery({ + 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)), + }); + + 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(() => { + 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, + }); + } + 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(q.total_amount_minor, q.currency), + }); + } + 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, + }); + } + 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(inv.total_amount_minor, inv.currency), + }); + } + 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, + }); + } + 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]); + + if (isLoading) return ; + if (!data) return
{t('projects.notFound', 'Project not found')}
; + + const { project, milestones, hours } = data; + + return ( +
+ + {t('projects.backToList', 'All projects')} + + + {/* Header */} + +
+
+ {editName === null ? ( +

{project.name}

+ ) : ( +
+ setEditName(e.target.value)} className="max-w-sm" /> + + +
+ )} +

+ {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) })} +

+
+ {editName === null && ( + + )} +
+
+ + {/* Milestone timeline */} + {milestones && milestones.length > 0 && ( + +

{t('projects.timeline', 'Milestones')}

+
+ {milestones.map((m, i) => { + const Icon = KIND_ICON[m.kind] || FileText; + return ( +
+ +
+
{m.label}
+
{m.date ? format(m.date) : '—'}
+
+
+ ); + })} +
+
+ )} + + {/* Dated feed */} + +

{t('projects.feed.title', 'Activity')}

+ {feed.length === 0 ? ( +
{t('projects.feed.empty', 'Nothing rolled up to this project yet.')}
+ ) : ( +
    + {feed.map((item) => { + const Icon = KIND_ICON[item.kind]; + return ( +
  • + +
    +
    + {item.title} + + {item.date ? `${format(item.date)} ${item.kind === 'email' ? formatTime(item.date) : ''}` : '—'} + +
    + {item.subtitle &&
    {item.subtitle}
    } +
    + {item.status && ( + {item.status} + )} + {item.amount && {item.amount}} + {item.kind === 'email' && item.emailId != null && ( +
    + + {item.emailStatus === 'sent' && ( + + )} + {item.emailStatus === 'pending' && ( + <> + + + + )} + {item.emailStatus === 'failed' && ( + + )} +
    + )} +
    +
    +
  • + ); + })} +
+ )} +
+ + {/* Email preview modal */} + {(preview || previewLoading) && ( +
setPreview(null)}> +
e.stopPropagation()}> +
+

{t('projects.email.previewTitle', 'Email preview')}

+ +
+
+ {previewLoading ? ( + + ) : preview && preview.available && preview.html ? ( +