task(E6-frontend): guest panel with ribbon entry and api client

This commit is contained in:
Agent Zero
2026-08-28 07:30:43 +02:00
parent e99cf597f8
commit 5eb741d2a7
4 changed files with 204 additions and 0 deletions
+12
View File
@@ -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<CADEditorProps> = ({ 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<CADEditorProps> = ({ 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:<id>' → 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<CADEditorProps> = ({ projectId, token, onNavigateBack
onClose={() => setHistoryPanelOpen(false)}
/>
))}
{guestPanelOpen && drawingId && (
<div className="guest-panel-overlay" style={{ position: 'fixed', top: 60, right: 12, width: 300, maxHeight: '70vh', background: 'var(--bg-secondary, #1e1e2e)', color: 'var(--fg-primary, #e0e0e0)', border: '1px solid rgba(128,128,128,0.3)', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.4)', zIndex: 1000, display: 'flex', flexDirection: 'column' }}>
<GuestPanel
token={token}
drawingId={drawingId}
onClose={() => setGuestPanelOpen(false)}
/>
</div>
)}
{exportFormatOpen && (
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
+140
View File
@@ -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<GuestPanelProps> = ({ token, drawingId, onClose }) => {
const [guests, setGuests] = useState<Guest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="guest-panel" style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>Gäste ({guests.length})</strong>
<span style={{ fontSize: 12, opacity: 0.7 }}>{assigned} zugewiesen</span>
{onClose && <button onClick={onClose} aria-label="Schließen">×</button>}
</div>
<input
type="search"
placeholder="Suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ padding: '4px 8px' }}
/>
<div style={{ display: 'flex', gap: 4 }}>
<input
placeholder="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAdd(); }}
style={{ flex: 1, padding: '4px 8px' }}
/>
<input
placeholder="E-Mail (optional)"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
style={{ flex: 1, padding: '4px 8px' }}
/>
<select value={newCategory} onChange={(e) => setNewCategory(e.target.value)} style={{ width: 90 }}>
<option value="standard">Standard</option>
<option value="vip">VIP</option>
<option value="staff">Staff</option>
</select>
<button onClick={() => void handleAdd()} disabled={newName.trim() === ''}>+</button>
</div>
{error && <div style={{ color: '#e5484d', fontSize: 12 }}>{error}</div>}
{loading && <div style={{ opacity: 0.6 }}>Lädt</div>}
<ul style={{ listStyle: 'none', margin: 0, padding: 0, overflowY: 'auto', flex: 1 }}>
{filtered.map((g) => (
<li
key={g.id}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.2)',
}}
>
<div>
<div style={{ fontWeight: g.category === 'vip' ? 700 : 400 }}>
{g.category === 'vip' ? '★ ' : ''}{g.name}
</div>
{g.email && <div style={{ fontSize: 11, opacity: 0.6 }}>{g.email}</div>}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{g.seat_element_id && (
<span style={{ fontSize: 11, opacity: 0.7 }} title="Sitzplatz zugewiesen">💺</span>
)}
<button onClick={() => void handleDelete(g.id)} aria-label={`Lösche ${g.name}`}>🗑</button>
</div>
</li>
))}
{!loading && filtered.length === 0 && (
<li style={{ opacity: 0.5, padding: 8 }}>Keine Gäste gefunden.</li>
)}
</ul>
</div>
);
};
export default GuestPanel;
+4
View File
@@ -324,6 +324,10 @@ const RibbonBar: React.FC<RibbonBarProps> = ({
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
<span>Historie</span>
</button>
<button className="ribbon-btn" title="Gäste-Verwaltung" onClick={() => onAction('guests')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Gäste</span>
</button>
</div>
<div className="ribbon-group-label">Extras</div>
</div>
+48
View File
@@ -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<Guest[]> {
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<Guest> {
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<Guest> {
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<void> {
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}`);
}