feat(projects): Project Overview cockpit UI + CRM nav entry
- 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.
This commit is contained in:
@@ -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() {
|
||||
<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 />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<FeedKind, React.ComponentType<{ className?: string }>> = {
|
||||
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<string | null>(null);
|
||||
const [preview, setPreview] = useState<EmailPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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 <Loading />;
|
||||
if (!data) return <div className="p-6 text-neutral-500">{t('projects.notFound', 'Project not found')}</div>;
|
||||
|
||||
const { project, milestones, hours } = data;
|
||||
|
||||
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>
|
||||
{editName === null && (
|
||||
<Button variant="outline" onClick={() => setEditName(project.name)}>{t('projects.rename', 'Rename')}</Button>
|
||||
)}
|
||||
</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;
|
||||
return (
|
||||
<div key={`${m.kind}-${i}`} className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3 py-2">
|
||||
<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];
|
||||
return (
|
||||
<li key={item.key} className="flex items-start gap-3 rounded-lg border border-neutral-100 dark:border-neutral-800 px-3 py-2">
|
||||
<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">
|
||||
<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.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 ? (
|
||||
<iframe title="email-preview" srcDoc={preview.html} className="w-full h-[60vh] border border-neutral-200 dark:border-neutral-700 rounded bg-white" />
|
||||
) : (
|
||||
<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,141 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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">{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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user