task(E6-frontend): guest panel with ribbon entry and api client
This commit is contained in:
@@ -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()}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user