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 BackgroundImport from './components/BackgroundImport';
|
||||||
import HistoryPanel from './components/HistoryPanel';
|
import HistoryPanel from './components/HistoryPanel';
|
||||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||||
|
import GuestPanel from './components/GuestPanel';
|
||||||
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
|
import { V2_TOOLS_ENABLED } from './kernel/featureFlags';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import InlineTextEditor from './components/InlineTextEditor';
|
import InlineTextEditor from './components/InlineTextEditor';
|
||||||
@@ -176,6 +177,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
|
|
||||||
// History panel
|
// History panel
|
||||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||||
|
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
|
||||||
|
|
||||||
// Export format selector (cross-browser replacement for nwsave)
|
// Export format selector (cross-browser replacement for nwsave)
|
||||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||||
@@ -1148,6 +1150,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
// ─── Tools actions ───
|
// ─── Tools actions ───
|
||||||
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
if (action === 'measure') { setActiveTool('dimension'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Messwerkzeug aktiv', type: 'info' }]); return; }
|
||||||
if (action === 'history') { setHistoryPanelOpen((prev) => !prev); 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
|
// ─── V2 tools (Task A4.13): 'v2tool:<id>' → aktives Tool umschalten. Der
|
||||||
// bestehende CanvasArea-Sync aus A4.1 routet es an den Dispatcher (Flag aktiv).
|
// 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)}
|
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 && (
|
{exportFormatOpen && (
|
||||||
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
<div className="export-format-overlay" onClick={() => setExportFormatOpen(false)}>
|
||||||
<div className="export-format-modal" onClick={(e) => e.stopPropagation()}>
|
<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>
|
<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>
|
<span>Historie</span>
|
||||||
</button>
|
</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>
|
||||||
<div className="ribbon-group-label">Extras</div>
|
<div className="ribbon-group-label">Extras</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -764,3 +764,51 @@ export async function aiChat(
|
|||||||
}
|
}
|
||||||
return res.json();
|
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