From 5eb741d2a735b79506766797ae434612178ce8a8 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 28 Aug 2026 07:30:43 +0200 Subject: [PATCH] task(E6-frontend): guest panel with ribbon entry and api client --- frontend/src/App.tsx | 12 +++ frontend/src/components/GuestPanel.tsx | 140 +++++++++++++++++++++++++ frontend/src/components/RibbonBar.tsx | 4 + frontend/src/services/api.ts | 48 +++++++++ 4 files changed, 204 insertions(+) create mode 100644 frontend/src/components/GuestPanel.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e8fca4..0aa493a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import MobileDrawers from './components/MobileDrawers'; import BackgroundImport from './components/BackgroundImport'; import HistoryPanel from './components/HistoryPanel'; import V2HistoryPanel from './components/V2HistoryPanel'; +import GuestPanel from './components/GuestPanel'; import { V2_TOOLS_ENABLED } from './kernel/featureFlags'; import SettingsModal from './components/SettingsModal'; import InlineTextEditor from './components/InlineTextEditor'; @@ -176,6 +177,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // History panel const [historyPanelOpen, setHistoryPanelOpen] = useState(false); + const [guestPanelOpen, setGuestPanelOpen] = useState(false); // Export format selector (cross-browser replacement for nwsave) const [exportFormatOpen, setExportFormatOpen] = useState(false); @@ -1148,6 +1150,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // ─── Tools actions ─── if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; } if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } + if (action === 'guests') { setGuestPanelOpen((prev) => !prev); return; } // ─── V2 tools (Task A4.13): 'v2tool:' → aktives Tool umschalten. Der // bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv). @@ -1762,6 +1765,15 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onClose={() => setHistoryPanelOpen(false)} /> ))} + {guestPanelOpen && drawingId && ( +
+ setGuestPanelOpen(false)} + /> +
+ )} {exportFormatOpen && (
setExportFormatOpen(false)}>
e.stopPropagation()}> diff --git a/frontend/src/components/GuestPanel.tsx b/frontend/src/components/GuestPanel.tsx new file mode 100644 index 0000000..e99204a --- /dev/null +++ b/frontend/src/components/GuestPanel.tsx @@ -0,0 +1,140 @@ +/** + * GuestPanel (Task E6) — Gäste-Verwaltung für Event-Bestuhlung. + * Liste + Suche + Hinzufügen + Löschen. Zuweisung: seat_element_id + * wird per API gesetzt (Klick auf Stuhl im Canvas folgt in E6b). + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { listGuests, createGuest, deleteGuest, type Guest } from '../services/api'; + +interface GuestPanelProps { + token: string; + drawingId: string; + onClose?: () => void; +} + +const GuestPanel: React.FC = ({ token, drawingId, onClose }) => { + const [guests, setGuests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [newName, setNewName] = useState(''); + const [newEmail, setNewEmail] = useState(''); + const [newCategory, setNewCategory] = useState('standard'); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setGuests(await listGuests(token, drawingId)); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden'); + } finally { + setLoading(false); + } + }, [token, drawingId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const handleAdd = async () => { + if (newName.trim() === '') return; + try { + await createGuest(token, drawingId, { name: newName.trim(), email: newEmail.trim() || undefined, category: newCategory }); + setNewName(''); + setNewEmail(''); + setNewCategory('standard'); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Hinzufügen'); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteGuest(token, id); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Löschen'); + } + }; + + const filtered = guests.filter((g) => + g.name.toLowerCase().includes(search.toLowerCase()) || + (g.email ?? '').toLowerCase().includes(search.toLowerCase()) + ); + const assigned = guests.filter((g) => g.seat_element_id).length; + + return ( +
+
+ Gäste ({guests.length}) + {assigned} zugewiesen + {onClose && } +
+ + setSearch(e.target.value)} + style={{ padding: '4px 8px' }} + /> + +
+ setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void handleAdd(); }} + style={{ flex: 1, padding: '4px 8px' }} + /> + setNewEmail(e.target.value)} + style={{ flex: 1, padding: '4px 8px' }} + /> + + +
+ + {error &&
{error}
} + {loading &&
Lädt…
} + +
    + {filtered.map((g) => ( +
  • +
    +
    + {g.category === 'vip' ? '★ ' : ''}{g.name} +
    + {g.email &&
    {g.email}
    } +
    +
    + {g.seat_element_id && ( + 💺 + )} + +
    +
  • + ))} + {!loading && filtered.length === 0 && ( +
  • Keine Gäste gefunden.
  • + )} +
+
+ ); +}; + +export default GuestPanel; diff --git a/frontend/src/components/RibbonBar.tsx b/frontend/src/components/RibbonBar.tsx index 543f272..ec47c06 100644 --- a/frontend/src/components/RibbonBar.tsx +++ b/frontend/src/components/RibbonBar.tsx @@ -324,6 +324,10 @@ const RibbonBar: React.FC = ({ Historie +
Extras
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 327cb7e..36758c1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -764,3 +764,51 @@ export async function aiChat( } return res.json(); } + +// ─── Guests (Task E6) ────────────────────────────────── +export interface Guest { + id: string; + drawing_id: string; + name: string; + email?: string | null; + category: string; + seat_element_id?: string | null; + notes?: string | null; + created_at: string; +} + +export async function listGuests(token: string, drawingId: string): Promise { + const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/guests`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to list guests: ${res.status}`); + return res.json(); +} + +export async function createGuest(token: string, drawingId: string, data: { name: string; email?: string; category?: string; notes?: string }): Promise { + const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/guests`, { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(`Failed to create guest: ${res.status}`); + return res.json(); +} + +export async function updateGuest(token: string, id: string, data: Partial<{ name: string; email: string; category: string; seat_element_id: string; notes: string }>): Promise { + const res = await fetch(`${API_BASE}/api/guests/${id}`, { + method: 'PATCH', + headers: authHeaders(token), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(`Failed to update guest: ${res.status}`); + return res.json(); +} + +export async function deleteGuest(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/guests/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to delete guest: ${res.status}`); +}