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
+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>