feat(projects): attach-event control in the cockpit

Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
This commit is contained in:
Luca
2026-06-06 13:27:43 +02:00
parent 81553aa0e3
commit dffcf6269f
3 changed files with 79 additions and 1 deletions
+7
View File
@@ -3410,6 +3410,13 @@
"label": "Projekt",
"none": "Kein Projekt"
},
"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.",
+7
View File
@@ -3410,6 +3410,13 @@
"label": "Project",
"none": "No project"
},
"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.",
@@ -13,7 +13,7 @@ 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,
X, Send, RotateCw, Ban, Eye, Save, ArrowLeft, Plus, Search,
} from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import {
@@ -21,6 +21,7 @@ import {
type ProjectOverview,
type EmailPreview,
} from '../../../services/projects.service';
import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
@@ -63,6 +64,7 @@ export const ProjectCockpitPage: React.FC = () => {
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],
@@ -95,6 +97,25 @@ export const ProjectCockpitPage: React.FC = () => {
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) => toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string)),
});
const openPreview = async (emailId: number) => {
setPreviewLoading(true);
try {
@@ -201,6 +222,49 @@ export const ProjectCockpitPage: React.FC = () => {
</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">