+ {data.map(d => {
+ const h = max > 0 ? (d.revenue / max) * 100 : 0;
+ const isCurrent = d.hour === currentHour;
+ const isFuture = parseInt(d.hour) > parseInt(currentHour);
+ return (
+
+
+
0 ? 4 : 0,
+ background: isCurrent
+ ? 'var(--brand-500)'
+ : isFuture
+ ? 'var(--ink-100)'
+ : 'var(--brand-200)',
+ }} />
+
+
{d.hour}
+
+ );
+ })}
+
+
+ );
+}
+
+// ---------------------------------------------------------------- Reservations
+function ReservationsCard() {
+ return (
+
+ Add}>
+
+ {OPS_DATA.reservations.map(r => (
+
+
{r.time}
+
+
{r.name}
+
+ {r.guests} guests · Table {r.table}
+ {r.notes && <> · {r.notes} >}
+
+
+
+ ))}
+
+
+ );
+}
+
+window.OpsCards = { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard };
diff --git a/CLAUDE_DESIGN/ops-data.jsx b/CLAUDE_DESIGN/ops-data.jsx
new file mode 100644
index 0000000..a5d3867
--- /dev/null
+++ b/CLAUDE_DESIGN/ops-data.jsx
@@ -0,0 +1,73 @@
+// Mock data for the daily ops dashboard
+
+window.OPS_DATA = {
+ business: {
+ name: 'Trattoria del Sole',
+ date: 'Saturday, April 25',
+ dayStartedAt: '11:30',
+ dayDurationMins: 4 * 60 + 18, // 4h 18m so far
+ },
+
+ kpis: {
+ revenue: 2847.50,
+ revenueGoal: 4500,
+ revenueLastWeek: 2640.00,
+ covers: 64,
+ coversGoal: 110,
+ coversLastWeek: 71,
+ tablesOpen: 7,
+ tablesTotal: 18,
+ avgTicket: 44.49,
+ },
+
+ hourly: [
+ // Lunch service mostly done, dinner ramping up
+ { hour: '11', revenue: 0 },
+ { hour: '12', revenue: 480 },
+ { hour: '13', revenue: 720 },
+ { hour: '14', revenue: 410 },
+ { hour: '15', revenue: 180 },
+ { hour: '16', revenue: 90 },
+ { hour: '17', revenue: 240 },
+ { hour: '18', revenue: 580 },
+ { hour: '19', revenue: 147 },
+ { hour: '20', revenue: 0 },
+ { hour: '21', revenue: 0 },
+ { hour: '22', revenue: 0 },
+ ],
+
+ shifts: [
+ { id: 1, name: 'Marco Riva', section: 'Terrace', clockIn: '11:00', hoursWorked: 4.8, tables: ['A1', 'A2'], status: 'active' },
+ { id: 2, name: 'Sofia Greco', section: 'Main hall', clockIn: '11:00', hoursWorked: 4.8, tables: ['B2'], status: 'active' },
+ { id: 3, name: 'Luca Bianchi', section: 'Main hall', clockIn: '11:30', hoursWorked: 4.3, tables: ['A4', 'B4'], status: 'break' },
+ { id: 4, name: 'Elena Costa', section: 'Bar', clockIn: '12:00', hoursWorked: 3.8, tables: ['B1', 'B2'], status: 'active' },
+ { id: 5, name: 'Alessandro Conti', section: 'Terrace', clockIn: '17:00', hoursWorked: 0.3, tables: [], status: 'active' },
+ ],
+
+ scheduledShifts: [
+ { id: 6, name: 'Giulia Ferri', section: 'Main hall', scheduledAt: '18:00' },
+ { id: 7, name: 'Paolo Mancini', section: 'Bar', scheduledAt: '18:30' },
+ ],
+
+ reservations: [
+ { id: 1, time: '19:00', name: 'Bianchi', guests: 4, table: 'A1', notes: 'Anniversary' },
+ { id: 2, time: '19:30', name: 'Romano', guests: 2, table: 'B3', notes: '' },
+ { id: 3, time: '20:00', name: 'De Luca', guests: 6, table: 'C1', notes: 'High chair' },
+ { id: 4, time: '20:00', name: 'Ferrari', guests: 2, table: '—', notes: 'VIP' },
+ { id: 5, time: '20:30', name: 'Russo', guests: 8, table: 'C2', notes: 'Birthday' },
+ { id: 6, time: '21:00', name: 'Marino', guests: 3, table: 'A3', notes: '' },
+ ],
+
+ recentMessages: [
+ { id: 1, to: 'Marco Riva', text: 'Come see me', sentAt: '15:42', read: true },
+ { id: 2, to: 'Everyone', text: 'Specials updated — see kitchen', sentAt: '15:10', read: true },
+ { id: 3, to: 'Luca Bianchi', text: 'Table A4 needs cleaning', sentAt: '14:55', read: false },
+ ],
+
+ presets: [
+ 'Come see me',
+ 'Table __ needs you',
+ 'Table __ needs cleaning',
+ 'Take a break',
+ ],
+};
diff --git a/CLAUDE_DESIGN/ops-layouts.jsx b/CLAUDE_DESIGN/ops-layouts.jsx
new file mode 100644
index 0000000..dbfa064
--- /dev/null
+++ b/CLAUDE_DESIGN/ops-layouts.jsx
@@ -0,0 +1,217 @@
+// Main ops dashboard layout
+
+const { Avatar, Card, Btn, Icon } = window.OpsUI;
+const { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard } = window.OpsCards;
+const { ShiftsCard, MessagesCard, ComposeModal, EndDayModal } = window.OpsCards2;
+const { OPS_DATA } = window;
+
+function TopBar({ onEndDay }) {
+ const b = OPS_DATA.business;
+ return (
+
+
TS
+
+
+
{b.name}
+
+
+ Day open
+
+
+
+ {b.date} · started at {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m running
+
+
+
+
+ Reports
+ End day
+
+
+ );
+}
+
+// Compact tablet top bar
+function TabletTopBar({ onEndDay }) {
+ const b = OPS_DATA.business;
+ return (
+
+
+
+
{b.name}
+
+
+ Open
+
+
+
+ {b.date} · {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m
+
+
+
+
End day
+
+ );
+}
+
+// KPI strip — 3 cards
+function KpiStrip({ compact }) {
+ const k = OPS_DATA.kpis;
+ const revPct = (k.revenue / k.revenueGoal) * 100;
+ const covPct = (k.covers / k.coversGoal) * 100;
+ const tablesOpenPct = (k.tablesOpen / k.tablesTotal) * 100;
+ const revDelta = ((k.revenue - k.revenueLastWeek) / k.revenueLastWeek) * 100;
+ const covDelta = ((k.covers - k.coversLastWeek) / k.coversLastWeek) * 100;
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------- DESKTOP LAYOUT
+function DesktopDashboard() {
+ const [composeOpen, setComposeOpen] = React.useState(false);
+ const [composeText, setComposeText] = React.useState('');
+ const [composeTo, setComposeTo] = React.useState('Everyone');
+ const [endDayOpen, setEndDayOpen] = React.useState(false);
+
+ const openCompose = (preset, to) => {
+ setComposeText(preset || '');
+ setComposeTo(to || 'Everyone');
+ setComposeOpen(true);
+ };
+
+ return (
+
+
setEndDayOpen(true)} />
+
+
+
Today at a glance
+
Updated 19:08 · auto-refresh
+
+
+
+
+
+
+
+
openCompose('', s.name)} />
+
+
+
+
+
+
+
+
+
+ setComposeOpen(false)} />
+ setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
+
+ );
+}
+
+// ---------------------------------------------------------------- TABLET LAYOUT
+function TabletDashboard() {
+ const [composeOpen, setComposeOpen] = React.useState(false);
+ const [composeText, setComposeText] = React.useState('');
+ const [composeTo, setComposeTo] = React.useState('Everyone');
+ const [endDayOpen, setEndDayOpen] = React.useState(false);
+
+ const openCompose = (preset, to) => {
+ setComposeText(preset || '');
+ setComposeTo(to || 'Everyone');
+ setComposeOpen(true);
+ };
+
+ return (
+
+
setEndDayOpen(true)} />
+
+
+
+
+
+
openCompose('', s.name)} />
+
+
+
+
+ setComposeOpen(false)} />
+ setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
+
+ );
+}
+
+window.OpsLayouts = { DesktopDashboard, TabletDashboard };
diff --git a/CLAUDE_DESIGN/ops-shifts.jsx b/CLAUDE_DESIGN/ops-shifts.jsx
new file mode 100644
index 0000000..ce80400
--- /dev/null
+++ b/CLAUDE_DESIGN/ops-shifts.jsx
@@ -0,0 +1,283 @@
+// Shifts card + Messages card
+
+const { Avatar, Card, Btn, Icon } = window.OpsUI;
+const { OPS_DATA } = window;
+
+// ---------------------------------------------------------------- Shifts
+function ShiftsCard({ onMessage }) {
+ return (
+
+ Start shift}
+ >
+
+ {OPS_DATA.shifts.map(s => (
+
+
+
+
+
{s.name}
+ {s.status === 'break' && (
+
On break
+ )}
+
+
+ {s.section} · in at {s.clockIn} · {s.hoursWorked.toFixed(1)}h worked
+ {s.tables.length > 0 && <> · tables {s.tables.join(', ')}>}
+
+
+
+ onMessage(s)} title="Message" style={{
+ width: 36, height: 36, borderRadius: 18,
+ background: 'white', border: '1px solid var(--ink-200)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer', color: 'var(--ink-700)',
+ }}>
+ ⋯
+
+
+ ))}
+
+ {/* scheduled / not yet started */}
+
Scheduled later
+ {OPS_DATA.scheduledShifts.map(s => (
+
+
+
+
{s.name}
+
+ {s.section} · starts at {s.scheduledAt}
+
+
+
Start now
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------- Messages
+function MessagesCard({ openCompose }) {
+ return (
+
openCompose()}> New}
+ >
+ {/* Quick presets */}
+ Quick send
+
+ {OPS_DATA.presets.map(p => (
+ openCompose(p)} style={{
+ height: 34, padding: '0 12px',
+ borderRadius: 17,
+ background: 'white',
+ border: '1px solid var(--ink-200)',
+ color: 'var(--ink-700)',
+ fontSize: 13, fontWeight: 500,
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ }}>+ {p}
+ ))}
+
+
+ Recent
+
+ {OPS_DATA.recentMessages.map(m => (
+
+
+
+
+ {m.to}
+ · {m.text}
+
+
+
{m.sentAt}
+ {!m.read &&
}
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------- Compose modal
+function ComposeModal({ open, prefilled, prefilledTo, onClose }) {
+ const [text, setText] = React.useState('');
+ const [recipient, setRecipient] = React.useState('Everyone');
+
+ React.useEffect(() => {
+ if (open) {
+ setText(prefilled || '');
+ setRecipient(prefilledTo || 'Everyone');
+ }
+ }, [open, prefilled, prefilledTo]);
+
+ if (!open) return null;
+ const recipients = ['Everyone', ...OPS_DATA.shifts.map(s => s.name)];
+
+ return (
+
+
e.stopPropagation()} style={{
+ width: 'min(520px, 100%)',
+ background: 'white',
+ borderRadius: 18,
+ padding: 24,
+ boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
+ }}>
+
+
+
To
+
+ {recipients.map(r => (
+ setRecipient(r)} style={{
+ height: 36, padding: '0 14px',
+ borderRadius: 18,
+ background: recipient === r ? 'var(--brand-500)' : 'white',
+ border: '1px solid ' + (recipient === r ? 'var(--brand-500)' : 'var(--ink-200)'),
+ color: recipient === r ? 'white' : 'var(--ink-700)',
+ fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
+ }}>{r}
+ ))}
+
+
+
Message
+
+
+ );
+}
+
+// ---------------------------------------------------------------- End-day modal
+function EndDayModal({ open, onClose, onConfirm }) {
+ if (!open) return null;
+ const k = OPS_DATA.kpis;
+ return (
+
+
e.stopPropagation()} style={{
+ width: 'min(560px, 100%)',
+ background: 'white', borderRadius: 18, padding: 28,
+ boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
+ }}>
+
End business day?
+
+ Today's session will close. Make sure all tables are paid and waiters have clocked out.
+
+
+
+ {[
+ ['Revenue', '€' + k.revenue.toFixed(2)],
+ ['Covers', k.covers.toString()],
+ ['Open tables', `${k.tablesOpen} of ${k.tablesTotal}`],
+ ['Avg ticket', '€' + k.avgTicket.toFixed(2)],
+ ].map(([lbl, val]) => (
+
+ ))}
+
+
+ {k.tablesOpen > 0 && (
+
+ ⚠ {k.tablesOpen} tables are still open. They'll need to be closed manually.
+
+ )}
+
+
+ Cancel
+ End day
+
+
+
+ );
+}
+
+window.OpsCards2 = { ShiftsCard, MessagesCard, ComposeModal, EndDayModal };
diff --git a/CLAUDE_DESIGN/ops-ui.jsx b/CLAUDE_DESIGN/ops-ui.jsx
new file mode 100644
index 0000000..25cb5f9
--- /dev/null
+++ b/CLAUDE_DESIGN/ops-ui.jsx
@@ -0,0 +1,145 @@
+// Shared primitives for the ops dashboard
+
+function Avatar({ name, size = 36, status }) {
+ const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'];
+ let h = 0;
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
+ const bg = palette[h % palette.length];
+ const parts = name.split(' ');
+ const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
+ return (
+
+
{initials}
+ {status && (
+
+ )}
+
+ );
+}
+
+function Card({ title, action, children, padding = 22, style }) {
+ return (
+
+ {title && (
+
+ )}
+
{children}
+
+ );
+}
+
+function StatPill({ delta }) {
+ if (delta == null) return null;
+ const up = delta > 0;
+ return (
+
+ {up ? '▲' : '▼'}
+ {Math.abs(delta).toFixed(1)}%
+
+ );
+}
+
+function Stepper({ value, onChange, min = 0, max = 99 }) {
+ return (
+
e.stopPropagation()}>
+
onChange(Math.max(min, value - 1))} disabled={value <= min}
+ style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 18, color: value <= min ? 'var(--ink-300)' : 'var(--ink-900)', cursor: value <= min ? 'default' : 'pointer' }}>−
+
{value}
+
onChange(Math.min(max, value + 1))} style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 18, cursor: 'pointer' }}>+
+
+ );
+}
+
+function Btn({ children, variant = 'secondary', size = 'md', onClick, style }) {
+ const variants = {
+ primary: { bg: 'var(--brand-500)', fg: 'white', bd: 'var(--brand-500)' },
+ danger: { bg: 'var(--alert-500)', fg: 'white', bd: 'var(--alert-500)' },
+ secondary: { bg: 'white', fg: 'var(--ink-900)', bd: 'var(--ink-200)' },
+ ghost: { bg: 'transparent', fg: 'var(--ink-700)', bd: 'transparent' },
+ };
+ const sizes = {
+ sm: { h: 32, px: 12, fs: 13 },
+ md: { h: 40, px: 16, fs: 14 },
+ lg: { h: 48, px: 22, fs: 15 },
+ };
+ const v = variants[variant];
+ const s = sizes[size];
+ return (
+
{children}
+ );
+}
+
+function Icon({ name, size = 20, color = 'currentColor' }) {
+ const paths = {
+ play: 'M8 5V19L19 12L8 5Z',
+ stop: 'M6 6H18V18H6V6Z',
+ bell: 'M12 22C13.1 22 14 21.1 14 20H10C10 21.1 10.9 22 12 22ZM18 16V11C18 7.9 16.4 5.4 13.5 4.7V4C13.5 3.2 12.8 2.5 12 2.5C11.2 2.5 10.5 3.2 10.5 4V4.7C7.6 5.4 6 7.9 6 11V16L4 18V19H20V18L18 16Z',
+ plus: 'M12 5V19M5 12H19',
+ chat: 'M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z',
+ clock: 'M12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22ZM12.5 7H11V13L16.2 16.2L17 14.9L12.5 12.2V7Z',
+ coffee: 'M2 21H20V19H2V21ZM20 8H18V5H4V13C4 15.2 5.8 17 8 17H14C16.2 17 18 15.2 18 13V10H20C21.1 10 22 9.1 22 8V8C22 6.9 21.1 8 20 8Z',
+ users: 'M16 11C17.7 11 19 9.7 19 8C19 6.3 17.7 5 16 5C14.3 5 13 6.3 13 8C13 9.7 14.3 11 16 11ZM8 11C9.7 11 11 9.7 11 8C11 6.3 9.7 5 8 5C6.3 5 5 6.3 5 8C5 9.7 6.3 11 8 11ZM8 13C5.3 13 0 14.3 0 17V19H16V17C16 14.3 10.7 13 8 13ZM16 13C15.7 13 15.3 13 14.9 13.1C16.2 14 17 15.3 17 17V19H24V17C24 14.3 18.7 13 16 13Z',
+ table: 'M3 5C3 3.9 3.9 3 5 3H19C20.1 3 21 3.9 21 5V19C21 20.1 20.1 21 19 21H5C3.9 21 3 20.1 3 19V5ZM5 11H19V5H5V11ZM5 13V19H11V13H5ZM13 13V19H19V13H13Z',
+ check: 'M9 16.2L4.8 12L3.4 13.4L9 19L21 7L19.6 5.6L9 16.2Z',
+ x: 'M19 6.4L17.6 5L12 10.6L6.4 5L5 6.4L10.6 12L5 17.6L6.4 19L12 13.4L17.6 19L19 17.6L13.4 12L19 6.4Z',
+ chevron: 'M9 6L15 12L9 18',
+ send: 'M2 21L23 12L2 3V10L17 12L2 14V21Z',
+ pause: 'M6 4H10V20H6V4ZM14 4H18V20H14V4Z',
+ search: 'M15.5 14H14.7L14.4 13.7C15.4 12.5 16 10.8 16 9C16 5.1 12.9 2 9 2C5.1 2 2 5.1 2 9C2 12.9 5.1 16 9 16C10.8 16 12.5 15.4 13.7 14.4L14 14.7V15.5L19 20.5L20.5 19L15.5 14Z',
+ bolt: 'M11 21H10L11 14H7.5C7 14 7 13.7 7.1 13.5L13 3H14L13 10H16.5C16.9 10 17 10.2 16.9 10.5L11 21Z',
+ sun: 'M12 7C9.2 7 7 9.2 7 12C7 14.8 9.2 17 12 17C14.8 17 17 14.8 17 12C17 9.2 14.8 7 12 7ZM2 13H4C4.6 13 5 12.6 5 12C5 11.4 4.6 11 4 11H2C1.4 11 1 11.4 1 12C1 12.6 1.4 13 2 13ZM20 13H22C22.6 13 23 12.6 23 12C23 11.4 22.6 11 22 11H20C19.4 11 19 11.4 19 12C19 12.6 19.4 13 20 13ZM11 2V4C11 4.6 11.4 5 12 5C12.6 5 13 4.6 13 4V2C13 1.4 12.6 1 12 1C11.4 1 11 1.4 11 2ZM11 20V22C11 22.6 11.4 23 12 23C12.6 23 13 22.6 13 22V20C13 19.4 12.6 19 12 19C11.4 19 11 19.4 11 20ZM5.99 4.58C5.6 4.19 4.96 4.19 4.58 4.58C4.19 4.97 4.19 5.6 4.58 5.99L5.64 7.05C6.03 7.44 6.66 7.44 7.05 7.05C7.44 6.66 7.44 6.03 7.05 5.64L5.99 4.58ZM18.36 16.95C17.97 16.56 17.34 16.56 16.95 16.95C16.56 17.34 16.56 17.97 16.95 18.36L18.01 19.42C18.4 19.81 19.04 19.81 19.42 19.42C19.81 19.03 19.81 18.4 19.42 18.01L18.36 16.95Z',
+ };
+ return (
+
+
+
+ );
+}
+
+window.OpsUI = { Avatar, Card, StatPill, Stepper, Btn, Icon };
diff --git a/CLAUDE_DESIGN/order-app.jsx b/CLAUDE_DESIGN/order-app.jsx
new file mode 100644
index 0000000..538a48d
--- /dev/null
+++ b/CLAUDE_DESIGN/order-app.jsx
@@ -0,0 +1,185 @@
+// Main app — menu list in an iOS frame, tap an item to open the drawer
+
+const { IOSDevice } = window;
+const { MENU, DIAVOLA, OrderDrawer } = window;
+
+function MenuItemRow({ item, onTap, badge }) {
+ const [pressed, setPressed] = React.useState(false);
+ return (
+
setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ onMouseLeave={() => setPressed(false)}
+ onClick={onTap}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 14,
+ padding: '14px 16px',
+ background: pressed ? 'var(--ink-100)' : 'white',
+ border: '1px solid var(--ink-100)',
+ borderRadius: 14,
+ cursor: 'pointer',
+ transition: 'background 120ms ease, transform 100ms ease',
+ transform: pressed ? 'scale(0.99)' : 'scale(1)',
+ position: 'relative',
+ }}
+ >
+
{item.emoji}
+
+
{item.name}
+
{item.desc}
+
+
€{item.price.toFixed(2)}
+ {badge > 0 && (
+
{badge}
+ )}
+
+ );
+}
+
+function MenuScreen({ onTapItem, orderCounts }) {
+ const categories = [
+ { label: 'All', active: true },
+ { label: 'Pizza' },
+ { label: 'Pasta' },
+ { label: 'Desserts' },
+ { label: 'Drinks' },
+ ];
+
+ return (
+
+ {/* Top bar */}
+
+
+
TABLE B2
+
· 4 guests · Marco
+
+
Cart 3
+
+
+
Add item
+
+
+ {categories.map(c => (
+ {c.label}
+ ))}
+
+
+
+ {/* Menu list */}
+
+ {MENU.map(item => (
+ onTapItem(item)}
+ />
+ ))}
+
+
+ {/* Hint banner when drawer not open */}
+
Tap "Pizza Diavola" to open the drawer
+
+ );
+}
+
+function App() {
+ const [drawerOpen, setDrawerOpen] = React.useState(true); // open by default so the design is immediately visible
+ const [orderCounts, setOrderCounts] = React.useState({ margherita: 2, coke: 1 });
+
+ // Tapping a menu item: for this demo only Diavola has a full spec,
+ // so we always feed the drawer the Diavola config but show the tap behavior.
+ const openDrawer = (_item) => setDrawerOpen(true);
+ const closeDrawer = () => setDrawerOpen(false);
+
+ const handleAdd = ({ product, qty }) => {
+ setOrderCounts(prev => ({ ...prev, [product.id]: (prev[product.id] || 0) + qty }));
+ setDrawerOpen(false);
+ };
+
+ return (
+
+ );
+}
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render(
);
diff --git a/CLAUDE_DESIGN/order-drawer.jsx b/CLAUDE_DESIGN/order-drawer.jsx
new file mode 100644
index 0000000..1a70159
--- /dev/null
+++ b/CLAUDE_DESIGN/order-drawer.jsx
@@ -0,0 +1,920 @@
+// Order drawer — add/customize a product before sending to the kitchen.
+// Mobile-portrait, ~90% height bottom sheet with horizontal tabs.
+
+const { DIAVOLA } = window;
+
+// --- utils -----------------------------------------------------------------
+const fmt = (n) => {
+ const sign = n < 0 ? '−' : '';
+ return sign + '€' + Math.abs(n).toFixed(2);
+};
+const fmtSigned = (n) => (n >= 0 ? '+' : '−') + '€' + Math.abs(n).toFixed(2);
+
+// --- stepper (big touch) ---------------------------------------------------
+function Stepper({ value, onChange, min = 0, max = 99, size = 'md' }) {
+ const sizes = {
+ md: { btn: 40, font: 18, w: 108 },
+ lg: { btn: 48, font: 22, w: 132 },
+ };
+ const s = sizes[size];
+ return (
+
e.stopPropagation()}>
+
onChange(Math.max(min, value - 1))}
+ disabled={value <= min}
+ style={{
+ width: s.btn, height: s.btn,
+ border: 'none', background: 'transparent',
+ fontSize: s.font, fontWeight: 500,
+ color: value <= min ? 'var(--ink-300)' : 'var(--ink-900)',
+ cursor: value <= min ? 'default' : 'pointer',
+ }}>−
+
{value}
+
onChange(Math.min(max, value + 1))}
+ disabled={value >= max}
+ style={{
+ width: s.btn, height: s.btn,
+ border: 'none', background: 'transparent',
+ fontSize: s.font, fontWeight: 500,
+ color: value >= max ? 'var(--ink-300)' : 'var(--ink-900)',
+ cursor: value >= max ? 'default' : 'pointer',
+ }}>+
+
+ );
+}
+
+// --- checkmark icon --------------------------------------------------------
+function Check({ size = 18 }) {
+ return (
+
+
+
+ );
+}
+function Chevron({ open, size = 16 }) {
+ return (
+
+
+
+ );
+}
+
+// --- Row primitive (shared look across tabs) -------------------------------
+function Row({ selected, onClick, left, right, children }) {
+ return (
+
+ {left &&
{left}
}
+
{children}
+ {right &&
{right}
}
+
+ );
+}
+
+// --- Checkbox circle (selected / not) --------------------------------------
+function CheckCircle({ selected, size = 26 }) {
+ return (
+
+ {selected && }
+
+ );
+}
+
+// --- Radio dot -------------------------------------------------------------
+function RadioDot({ selected, size = 22 }) {
+ return (
+
+ {selected && (
+
+ )}
+
+ );
+}
+
+// ===========================================================================
+// QUICK OPTIONS TAB
+// ===========================================================================
+function QuickOptionsTab({ options, state, setState }) {
+ return (
+
+ {options.map(opt => {
+ const qty = state[opt.id] || 0;
+ const selected = qty > 0;
+ return (
+
setState({ ...state, [opt.id]: selected ? 0 : 1 })}
+ left={!opt.multi && }
+ right={
+ opt.multi ? (
+ selected ? (
+ setState({ ...state, [opt.id]: v })} />
+ ) : (
+ { e.stopPropagation(); setState({ ...state, [opt.id]: 1 }); }}
+ style={{
+ height: 40, padding: '0 18px',
+ borderRadius: 20,
+ background: 'white',
+ border: '1.5px solid var(--ink-200)',
+ color: 'var(--ink-900)',
+ fontSize: 15, fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >Add
+ )
+ ) : null
+ }
+ >
+ {opt.label}
+ {opt.price > 0 && (
+
+ +€{opt.price.toFixed(2)} {opt.multi ? 'each' : ''}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ===========================================================================
+// EXTRAS TAB — each row expands inline to pick a sub-option
+// ===========================================================================
+function ExtrasTab({ extras, state, setState, expandedId, setExpandedId }) {
+ return (
+
+ {extras.map(ex => {
+ const selection = state[ex.id]; // { qty, subId } | undefined
+ const selected = !!selection;
+ const open = expandedId === ex.id;
+ const subLabel = selection
+ ? ex.subOptions.find(s => s.id === selection.subId)?.label
+ : null;
+
+ const toggle = () => {
+ if (selected) {
+ setState({ ...state, [ex.id]: undefined });
+ if (open) setExpandedId(null);
+ } else {
+ // default to first sub-option, auto-expand so user can change it
+ setState({ ...state, [ex.id]: { qty: 1, subId: ex.subOptions[0].id } });
+ setExpandedId(ex.id);
+ }
+ };
+
+ return (
+
+
|
}
+ right={
+ selected ? (
+
e.stopPropagation()}>
+ {ex.multi && (
+ setState({ ...state, [ex.id]: v === 0 ? undefined : { ...selection, qty: v } })}
+ />
+ )}
+ { e.stopPropagation(); setExpandedId(open ? null : ex.id); }}
+ style={{
+ height: 40, width: 40,
+ borderRadius: '50%',
+ background: 'white',
+ border: '1px solid var(--ink-200)',
+ color: 'var(--ink-700)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer',
+ }}
+ >
+
+
+
+ ) : null
+ }
+ >
+
{ex.label}
+
+ {ex.price > 0 ? `+€${ex.price.toFixed(2)}${ex.multi ? ' each' : ''}` : 'Included'}
+ {subLabel && · {subLabel} }
+
+
+
+ {/* Inline sub-option picker */}
+ {selected && open && (
+
+
{ex.subLabel}
+ {ex.subOptions.map(sub => {
+ const isSel = selection.subId === sub.id;
+ return (
+
setState({ ...state, [ex.id]: { ...selection, subId: sub.id } })}
+ left={ }
+ >
+
+
{sub.label}
+ {sub.price > 0 && (
+
+€{sub.price.toFixed(2)}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+// ===========================================================================
+// INGREDIENTS TAB — remove only
+// ===========================================================================
+function IngredientsTab({ ingredients, state, setState }) {
+ return (
+
+
+ Tap to remove ingredients from this item.
+
+
+ {ingredients.map(ing => {
+ const removed = !!state[ing.id];
+ return (
+
setState({ ...state, [ing.id]: !removed })}
+ right={
+
+ {removed ? 'Removed' : 'Remove'}
+
+ }
+ >
+ {ing.label}
+
+ );
+ })}
+
+
+ );
+}
+
+// ===========================================================================
+// PREFERENCES TAB — radio groups
+// ===========================================================================
+function PreferencesTab({ preferences, state, setState }) {
+ return (
+
+ {preferences.map(pref => {
+ const selected = state[pref.id];
+ return (
+
+
+
{pref.label}
+ {pref.required && (
+
Required
+ )}
+
+
+ {pref.subOptions.map(sub => {
+ const isSel = selected === sub.id;
+ return (
+
setState({ ...state, [pref.id]: sub.id })}
+ left={ }
+ right={
+ sub.price !== 0 ? (
+
+ {fmtSigned(sub.price)}
+
+ ) : null
+ }
+ >
+ {sub.label}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
+
+// ===========================================================================
+// NOTES TAB
+// ===========================================================================
+const QUICK_NOTES = ['No eye contact 😅', 'Table is in a hurry', 'Customer is allergic', 'Cut in smaller pieces', 'Leave box open'];
+
+function NotesTab({ note, setNote }) {
+ return (
+
+
+ Anything specific for the kitchen. Short and clear works best.
+
+
+ );
+}
+
+// ===========================================================================
+// SUMMARY TAB — final config review
+// ===========================================================================
+function SummaryTab({ product, config, lines, onJumpTab }) {
+ const sectionStyle = { marginBottom: 22 };
+ const headerStyle = {
+ fontSize: 12, fontWeight: 700, color: 'var(--ink-500)',
+ textTransform: 'uppercase', letterSpacing: 0.8,
+ padding: '0 2px 8px',
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
+ };
+ const editBtn = (tab) => (
+
onJumpTab(tab)} style={{
+ background: 'none', border: 'none',
+ fontSize: 12, fontWeight: 700, color: 'var(--brand-700)',
+ textTransform: 'uppercase', letterSpacing: 0.8,
+ cursor: 'pointer',
+ }}>Edit
+ );
+
+ // Group summary lines
+ const groups = {
+ pref: lines.filter(l => l.group === 'pref'),
+ quick: lines.filter(l => l.group === 'quick'),
+ extra: lines.filter(l => l.group === 'extra'),
+ removed: lines.filter(l => l.group === 'removed'),
+ };
+
+ const LineItem = ({ l }) => (
+
+
+
+ {l.qty > 1 && {l.qty}× }
+ {l.label}
+
+ {l.detail && (
+
{l.detail}
+ )}
+
+ {l.price !== 0 && (
+
{fmtSigned(l.price)}
+ )}
+
+ );
+
+ const isEmpty = lines.length === 0 && !config.note;
+
+ return (
+
+ {/* Product summary header */}
+
+
{product.emoji}
+
+
{product.name}
+
Base €{product.price.toFixed(2)}
+
+
+
+ {isEmpty && (
+
+ No customization yet. Switch to other tabs to add options.
+
+ )}
+
+ {groups.pref.length > 0 && (
+
+
+ Preferences
+ {editBtn('preferences')}
+
+
+ {groups.pref.map((l, i) => )}
+
+
+ )}
+
+ {groups.quick.length > 0 && (
+
+
+ Quick options
+ {editBtn('quick')}
+
+
+ {groups.quick.map((l, i) => )}
+
+
+ )}
+
+ {groups.extra.length > 0 && (
+
+
+ Extras
+ {editBtn('extras')}
+
+
+ {groups.extra.map((l, i) => )}
+
+
+ )}
+
+ {groups.removed.length > 0 && (
+
+
+ Removed
+ {editBtn('ingredients')}
+
+
+ {groups.removed.map((l, i) => )}
+
+
+ )}
+
+ {config.note && (
+
+
+ Note
+ {editBtn('notes')}
+
+
{config.note}
+
+ )}
+
+ );
+}
+
+// ===========================================================================
+// MAIN DRAWER
+// ===========================================================================
+function OrderDrawer({ product, isOpen, onClose, onAddToOrder }) {
+ // Tabs available for this product (hidden if empty)
+ const tabs = React.useMemo(() => {
+ const list = [];
+ if (product.quickOptions?.length) list.push({ id: 'quick', label: 'Quick' });
+ if (product.extras?.length) list.push({ id: 'extras', label: 'Extras' });
+ if (product.ingredients?.length) list.push({ id: 'ingredients', label: 'Ingredients' });
+ if (product.preferences?.length) list.push({ id: 'preferences', label: 'Preferences' });
+ list.push({ id: 'notes', label: 'Notes' });
+ list.push({ id: 'summary', label: 'Summary' });
+ return list;
+ }, [product]);
+
+ // --- STATE ---
+ const initialPrefs = () => {
+ const p = {};
+ (product.preferences || []).forEach(pref => {
+ const def = pref.subOptions.find(s => s.default);
+ if (def) p[pref.id] = def.id;
+ });
+ return p;
+ };
+
+ const [activeTab, setActiveTab] = React.useState(tabs[0].id);
+ const [quick, setQuick] = React.useState({}); // { id: qty }
+ const [extras, setExtras] = React.useState({}); // { id: {qty, subId} }
+ const [extraExpanded, setExtraExpanded] = React.useState(null);
+ const [removed, setRemoved] = React.useState({}); // { id: true }
+ const [prefs, setPrefs] = React.useState(initialPrefs());
+ const [note, setNote] = React.useState('');
+ const [qty, setQty] = React.useState(1);
+
+ // Reset when drawer opens with a (possibly different) product
+ React.useEffect(() => {
+ if (isOpen) {
+ setActiveTab(tabs[0].id);
+ setQuick({});
+ setExtras({});
+ setExtraExpanded(null);
+ setRemoved({});
+ setPrefs(initialPrefs());
+ setNote('');
+ setQty(1);
+ }
+ }, [isOpen, product.id]);
+
+ const config = { quick, extras, removed, prefs, note };
+
+ // --- Derived: summary lines -------------------------------------------
+ const { lines, itemPrice } = React.useMemo(() => {
+ const L = [];
+ let p = product.price;
+
+ // Preferences
+ (product.preferences || []).forEach(pref => {
+ const selId = prefs[pref.id];
+ if (!selId) return;
+ const sub = pref.subOptions.find(s => s.id === selId);
+ if (!sub) return;
+ if (!sub.default || sub.price !== 0) {
+ L.push({
+ group: 'pref',
+ label: `${pref.label}: ${sub.label}`,
+ qty: 1,
+ price: sub.price,
+ });
+ }
+ p += sub.price;
+ });
+
+ // Quick
+ (product.quickOptions || []).forEach(opt => {
+ const q = quick[opt.id] || 0;
+ if (q === 0) return;
+ const linePrice = opt.price * q;
+ L.push({
+ group: 'quick',
+ label: opt.label,
+ qty: q,
+ price: linePrice,
+ });
+ p += linePrice;
+ });
+
+ // Extras
+ (product.extras || []).forEach(ex => {
+ const sel = extras[ex.id];
+ if (!sel) return;
+ const sub = ex.subOptions.find(s => s.id === sel.subId);
+ const linePrice = (ex.price + (sub?.price || 0)) * sel.qty;
+ L.push({
+ group: 'extra',
+ label: ex.label,
+ detail: sub?.label,
+ qty: sel.qty,
+ price: linePrice,
+ });
+ p += linePrice;
+ });
+
+ // Removed ingredients
+ (product.ingredients || []).forEach(ing => {
+ if (removed[ing.id]) {
+ L.push({
+ group: 'removed',
+ label: `No ${ing.label.toLowerCase()}`,
+ qty: 1,
+ price: 0,
+ });
+ }
+ });
+
+ return { lines: L, itemPrice: p };
+ }, [prefs, quick, extras, removed, product]);
+
+ const total = itemPrice * qty;
+ const customizationCount = lines.length + (note ? 1 : 0);
+
+ // Backdrop click to close
+ const handleBackdrop = (e) => {
+ if (e.target === e.currentTarget) onClose();
+ };
+
+ // --- Render ---
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Sheet */}
+
+ {/* Grab handle */}
+
+
+ {/* Header */}
+
+
{product.emoji}
+
+
{product.name}
+
{product.desc}
+
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+ {tabs.map(t => {
+ const active = activeTab === t.id;
+ const badge = (
+ t.id === 'quick' ? Object.values(quick).filter(v => v > 0).length :
+ t.id === 'extras' ? Object.values(extras).filter(Boolean).length :
+ t.id === 'ingredients' ? Object.values(removed).filter(Boolean).length :
+ t.id === 'notes' ? (note ? 1 : 0) :
+ t.id === 'summary' ? customizationCount :
+ 0
+ );
+ return (
+ setActiveTab(t.id)}
+ style={{
+ padding: '14px 4px',
+ background: 'none',
+ border: 'none',
+ borderBottom: '2px solid ' + (active ? 'var(--brand-500)' : 'transparent'),
+ color: active ? 'var(--brand-700)' : 'var(--ink-500)',
+ fontSize: 15,
+ fontWeight: active ? 700 : 500,
+ fontFamily: 'inherit',
+ cursor: 'pointer',
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ whiteSpace: 'nowrap',
+ marginRight: 10,
+ transition: 'color 120ms ease, border-color 120ms ease',
+ }}
+ >
+ {t.label}
+ {badge > 0 && (
+ {badge}
+ )}
+
+ );
+ })}
+
+
+
+ {/* Scrollable content */}
+
+ {activeTab === 'quick' &&
}
+ {activeTab === 'extras' &&
}
+ {activeTab === 'ingredients' &&
}
+ {activeTab === 'preferences' &&
}
+ {activeTab === 'notes' &&
}
+ {activeTab === 'summary' &&
}
+
+
+ {/* Footer — qty stepper + Add to Order */}
+
+
+ onAddToOrder({ product, config, qty, total })}
+ style={{
+ flex: 1, height: 56,
+ borderRadius: 28,
+ background: 'var(--brand-500)',
+ border: 'none',
+ color: 'white',
+ fontSize: 16, fontWeight: 700,
+ fontFamily: 'inherit',
+ cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: '0 22px',
+ transition: 'transform 100ms ease, background 120ms ease',
+ }}
+ onMouseDown={(e) => e.currentTarget.style.transform = 'scale(0.98)'}
+ onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'}
+ onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
+ >
+ Add to order
+ €{total.toFixed(2)}
+
+
+
+ >
+ );
+}
+
+window.OrderDrawer = OrderDrawer;
diff --git a/CLAUDE_DESIGN/table-card.jsx b/CLAUDE_DESIGN/table-card.jsx
new file mode 100644
index 0000000..5c16f0d
--- /dev/null
+++ b/CLAUDE_DESIGN/table-card.jsx
@@ -0,0 +1,478 @@
+// Table Card — 3 variations
+// All cards share the same fixed dimensions and field positions so the grid
+// stays visually aligned even when fields are empty.
+
+const STATUS = {
+ open: { label: 'Open', tint: 'var(--open-50)', tintStrong: 'var(--open-100)', accent: 'var(--open-500)', ink: 'var(--open-700)' },
+ occupied:{ label: 'Occupied', tint: 'var(--occ-50)', tintStrong: 'var(--occ-100)', accent: 'var(--occ-500)', ink: 'var(--occ-700)' },
+ reserved:{ label: 'Reserved', tint: 'var(--res-50)', tintStrong: 'var(--res-100)', accent: 'var(--res-500)', ink: 'var(--res-700)' },
+ alert: { label: 'Needs attention', tint: 'var(--alert-50)', tintStrong: 'var(--alert-100)', accent: 'var(--alert-500)', ink: 'var(--alert-700)' },
+ dirty: { label: 'Needs cleaning', tint: 'var(--dirty-50)', tintStrong: 'var(--dirty-100)', accent: 'var(--dirty-500)', ink: 'var(--dirty-700)' },
+};
+
+// ----- Shared helpers -----
+
+function formatEuro(n) {
+ if (n == null) return null;
+ return '€' + n.toFixed(2).replace(/\.00$/, '.00');
+}
+
+function formatDuration(mins) {
+ if (mins == null) return null;
+ if (mins < 60) return `${mins}m`;
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
+}
+
+function avatarColor(name) {
+ const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'];
+ let h = 0;
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
+ return palette[h % palette.length];
+}
+
+function Initials({ name, size = 28 }) {
+ const parts = name.split(' ');
+ const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
+ return (
+
{initials}
+ );
+}
+
+function Flag({ kind }) {
+ const map = {
+ VIP: { bg: '#fff4d6', fg: '#8a6d0b', label: 'VIP' },
+ Allergy: { bg: '#fde3dc', fg: '#a5361b', label: 'Allergy' },
+ Birthday: { bg: '#fbe2ee', fg: '#a8276b', label: 'Birthday' },
+ };
+ const s = map[kind] || { bg: '#eceff2', fg: '#3a4049', label: kind };
+ return (
+
{s.label}
+ );
+}
+
+// Placeholder dashes so empty fields keep their footprint but visually disappear
+function EmptyDash({ width = 40 }) {
+ return
— — ;
+}
+
+// ===========================================================================
+// VARIATION 1 — Left accent border + tinted background
+// Stacked: header row, stats row, waiter row. Clean and quiet.
+// ===========================================================================
+function TableCardV1({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
+ const s = STATUS[status];
+ const [hover, setHover] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+
+ // Waiter display rules
+ const showMulti = waiters.length >= 3;
+
+ return (
+
setHover(true)}
+ onMouseLeave={() => { setHover(false); setPressed(false); }}
+ onMouseDown={() => setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ style={{
+ '--cardBg': s.tint,
+ position: 'relative',
+ width: 260, height: 180,
+ padding: '16px 18px 16px 22px',
+ background: s.tint,
+ border: '1px solid ' + s.tintStrong,
+ borderRadius: 'var(--radius)',
+ boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
+ transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
+ transition: 'transform 120ms ease, box-shadow 120ms ease',
+ cursor: 'pointer',
+ textAlign: 'left',
+ font: 'inherit',
+ color: 'inherit',
+ display: 'flex', flexDirection: 'column',
+ outline: 'none',
+ }}
+ >
+ {/* left accent bar */}
+
+
+ {/* Header row: name + status pill */}
+
+
{name}
+
+
+ {s.label}
+
+
+
+ {/* Flags row — fixed height whether or not flags exist */}
+
+ {flags.map(f => )}
+
+
+ {/* Stats row: amount + time. Fixed layout, empty dashes when missing */}
+
+
+
Total
+
+ {amount != null ? formatEuro(amount) : }
+
+
+
+
Time
+
= 90 ? 700 : 500,
+ color: 'var(--ink-900)',
+ }}>
+ {occupiedMins != null ? formatDuration(occupiedMins) : }
+
+
+
+
+ {/* Waiter row */}
+
+ {waiters.length === 0 ? (
+
Unassigned
+ ) : showMulti ? (
+ <>
+
+ {waiters.slice(0, 3).map((w, i) => (
+
+
+
+ ))}
+
+
Multiple ({waiters.length})
+ >
+ ) : (
+ waiters.map((w, i) => (
+
+
+ {w.split(' ')[0]}
+
+ ))
+ )}
+
+
+ );
+}
+
+// ===========================================================================
+// VARIATION 2 — Top stripe + large name on left, stats on right
+// More "at-a-glance" — big name dominates.
+// ===========================================================================
+function TableCardV2({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
+ const s = STATUS[status];
+ const [hover, setHover] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+ const showMulti = waiters.length >= 3;
+
+ return (
+
setHover(true)}
+ onMouseLeave={() => { setHover(false); setPressed(false); }}
+ onMouseDown={() => setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ style={{
+ '--cardBg': s.tint,
+ position: 'relative',
+ width: 260, height: 180,
+ padding: 0,
+ background: s.tint,
+ border: '1px solid ' + s.tintStrong,
+ borderRadius: 'var(--radius)',
+ boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
+ transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
+ transition: 'transform 120ms ease, box-shadow 120ms ease',
+ cursor: 'pointer',
+ textAlign: 'left',
+ font: 'inherit',
+ color: 'inherit',
+ overflow: 'hidden',
+ display: 'flex', flexDirection: 'column',
+ outline: 'none',
+ }}
+ >
+ {/* top stripe */}
+
+
+
+ {/* Top row: name BIG + status label */}
+
+
+ {/* Flags row — fixed height */}
+
+ {flags.map(f => )}
+
+
+ {/* Stats line */}
+
+
+ {amount != null ? formatEuro(amount) : }
+
+
+
= 90 ? 700 : 500,
+ color: 'var(--ink-700)',
+ }}>
+ {occupiedMins != null ? formatDuration(occupiedMins) : }
+
+
+
+ {/* Waiter row */}
+
+ {waiters.length === 0 ? (
+
Unassigned
+ ) : showMulti ? (
+ <>
+
+ {waiters.slice(0, 3).map((w, i) => (
+
+
+
+ ))}
+
+
Multiple ({waiters.length})
+ >
+ ) : (
+ waiters.map((w, i) => (
+
+
+ {w.split(' ')[0]}
+
+ ))
+ )}
+
+
+
+ );
+}
+
+// ===========================================================================
+// VARIATION 3 — Bold name badge, stats right-aligned
+// Name lives in a colored badge in the top-left like a table plaque.
+// Good for tablet thumb reach — name is easy to tap to open.
+// ===========================================================================
+function TableCardV3({ name, status, amount, occupiedMins, waiters = [], flags = [] }) {
+ const s = STATUS[status];
+ const [hover, setHover] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+ const showMulti = waiters.length >= 3;
+
+ return (
+
setHover(true)}
+ onMouseLeave={() => { setHover(false); setPressed(false); }}
+ onMouseDown={() => setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ style={{
+ '--cardBg': s.tint,
+ position: 'relative',
+ width: 260, height: 180,
+ padding: 14,
+ background: s.tint,
+ border: '1px solid ' + s.tintStrong,
+ borderRadius: 'var(--radius)',
+ boxShadow: pressed ? 'inset 0 2px 4px rgba(16,20,24,0.08)' : (hover ? 'var(--shadow-2)' : 'var(--shadow-1)'),
+ transform: pressed ? 'translateY(1px)' : (hover ? 'translateY(-2px)' : 'translateY(0)'),
+ transition: 'transform 120ms ease, box-shadow 120ms ease',
+ cursor: 'pointer',
+ textAlign: 'left',
+ font: 'inherit',
+ color: 'inherit',
+ display: 'flex', flexDirection: 'column',
+ outline: 'none',
+ }}
+ >
+ {/* Top row — plaque + status + flags */}
+
+
{name}
+
+
+
{s.label}
+ {/* Flags row — fixed height so layout stays stable */}
+
+ {flags.map(f => )}
+
+
+
+
+ {/* Stats */}
+
+
+
Total
+
+ {amount != null ? formatEuro(amount) : }
+
+
+
+
Time
+
= 90 ? 700 : 500,
+ color: 'var(--ink-900)',
+ lineHeight: 1.1,
+ }}>
+ {occupiedMins != null ? formatDuration(occupiedMins) : }
+
+
+
+
+ {/* Waiter row */}
+
+ {waiters.length === 0 ? (
+
Unassigned
+ ) : showMulti ? (
+ <>
+
+ {waiters.slice(0, 3).map((w, i) => (
+
+
+
+ ))}
+
+
Multiple ({waiters.length})
+ >
+ ) : (
+ waiters.map((w, i) => (
+
+
+ {w.split(' ')[0]}
+
+ ))
+ )}
+
+
+ );
+}
+
+// Export to window
+Object.assign(window, { TableCardV1, TableCardV2, TableCardV3, STATUS });
diff --git a/manager_dashboard/package-lock.json b/manager_dashboard/package-lock.json
new file mode 100644
index 0000000..bebddba
--- /dev/null
+++ b/manager_dashboard/package-lock.json
@@ -0,0 +1,3137 @@
+{
+ "name": "manager-dashboard",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "manager-dashboard",
+ "version": "0.0.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "axios": "^1.7.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hot-toast": "^2.4.1",
+ "react-router-dom": "^6.28.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.16",
+ "vite": "^6.0.5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz",
+ "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz",
+ "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.99.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
+ "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
+ "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/goober": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+ "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-hot-toast": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+ "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx
index d6fb3fb..c3dba64 100644
--- a/manager_dashboard/src/App.jsx
+++ b/manager_dashboard/src/App.jsx
@@ -2,13 +2,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
import AppLayout from './layouts/AppLayout'
import LoginPage from './pages/LoginPage'
-import DashboardPage from './pages/DashboardPage'
-import OrderDetailPage from './pages/OrderDetailPage'
-import ProductsPage from './pages/ProductsPage'
-import WaitersPage from './pages/WaitersPage'
+import OperationsPage from './pages/OperationsPage'
import TablesPage from './pages/TablesPage'
+import OrderDetailPage from './pages/OrderDetailPage'
+import ManagementPage from './pages/ManagementPage'
import ReportsPage from './pages/ReportsPage'
-import SettingsPage from './pages/SettingsPage'
+import SettingsPage from './pages/Settings/SettingsPage'
function RequireAuth({ children }) {
const token = useAuthStore(s => s.token)
@@ -21,12 +20,12 @@ export default function App() {
} />
}>
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
} />
+ } />
+ } />
} />
} />
diff --git a/manager_dashboard/src/api/client.js b/manager_dashboard/src/api/client.js
index fcea818..a33e121 100644
--- a/manager_dashboard/src/api/client.js
+++ b/manager_dashboard/src/api/client.js
@@ -5,7 +5,7 @@ const BASE_URL = import.meta.env.VITE_API_URL || 'http://192.168.1.10:8000'
const client = axios.create({ baseURL: BASE_URL })
client.interceptors.request.use(config => {
- const token = localStorage.getItem('token')
+ const token = localStorage.getItem('manager_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
@@ -14,7 +14,10 @@ client.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
- localStorage.removeItem('token')
+ // On hard 401 (expired/invalid token) force a full logout
+ localStorage.removeItem('manager_token')
+ localStorage.removeItem('manager_username')
+ localStorage.removeItem('manager_lock_timeout')
window.location.href = '/login'
}
return Promise.reject(err)
diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx
index aa07cb5..93dfb7f 100644
--- a/manager_dashboard/src/components/Sidebar.jsx
+++ b/manager_dashboard/src/components/Sidebar.jsx
@@ -2,12 +2,11 @@ import { NavLink } from 'react-router-dom'
import { useState } from 'react'
const NAV = [
- { to: '/dashboard', icon: '📊', label: 'Dashboard' },
- { to: '/tables', icon: '🪑', label: 'Τραπέζια' },
- { to: '/products', icon: '📦', label: 'Προϊόντα' },
- { to: '/waiters', icon: '👥', label: 'Σερβιτόροι' },
- { to: '/reports', icon: '📋', label: 'Αναφορές' },
- { to: '/settings', icon: '⚙️', label: 'Ρυθμίσεις' },
+ { to: '/operations', icon: '📊', label: 'Διοίκηση' },
+ { to: '/tables', icon: '🪑', label: 'Τραπέζια' },
+ { to: '/reports', icon: '📋', label: 'Αναφορές' },
+ { to: '/management', icon: '🗂️', label: 'Διαχείριση' },
+ { to: '/settings', icon: '⚙️', label: 'Ρυθμίσεις' },
]
export default function Sidebar() {
diff --git a/manager_dashboard/src/icons/add.svg b/manager_dashboard/src/icons/add.svg
new file mode 100644
index 0000000..d95e595
--- /dev/null
+++ b/manager_dashboard/src/icons/add.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/manager_dashboard/src/icons/delete.svg b/manager_dashboard/src/icons/delete.svg
new file mode 100644
index 0000000..99d6013
--- /dev/null
+++ b/manager_dashboard/src/icons/delete.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/manager_dashboard/src/icons/edit.svg b/manager_dashboard/src/icons/edit.svg
new file mode 100644
index 0000000..e91aaa8
--- /dev/null
+++ b/manager_dashboard/src/icons/edit.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/manager_dashboard/src/icons/move-down.svg b/manager_dashboard/src/icons/move-down.svg
new file mode 100644
index 0000000..9d1a594
--- /dev/null
+++ b/manager_dashboard/src/icons/move-down.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/manager_dashboard/src/icons/move-up.svg b/manager_dashboard/src/icons/move-up.svg
new file mode 100644
index 0000000..94b0bf7
--- /dev/null
+++ b/manager_dashboard/src/icons/move-up.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/manager_dashboard/src/index.css b/manager_dashboard/src/index.css
index 397fd9c..b703c6d 100644
--- a/manager_dashboard/src/index.css
+++ b/manager_dashboard/src/index.css
@@ -16,7 +16,7 @@
@apply bg-primary-700 hover:bg-primary-800 text-white;
}
.btn-secondary {
- @apply bg-gray-100 hover:bg-gray-200 text-gray-700;
+ @apply bg-gray-200 hover:bg-gray-300 text-gray-700;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white;
diff --git a/manager_dashboard/src/layouts/AppLayout.jsx b/manager_dashboard/src/layouts/AppLayout.jsx
index 452499c..26795c8 100644
--- a/manager_dashboard/src/layouts/AppLayout.jsx
+++ b/manager_dashboard/src/layouts/AppLayout.jsx
@@ -1,38 +1,219 @@
-import { Outlet } from 'react-router-dom'
-import { useState, useEffect } from 'react'
+import { Outlet, useNavigate } from 'react-router-dom'
+import { useState, useEffect, useRef, useCallback } from 'react'
import Sidebar from '../components/Sidebar'
import useAuthStore from '../store/authStore'
import client from '../api/client'
-export default function AppLayout() {
- const { user, token, login, logout } = useAuthStore()
- const [clock, setClock] = useState(new Date())
+const SETTINGS_KEY = 'manager_lock_timeout'
+const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
- // Fetch user profile once on mount if token exists but user isn't loaded
+// ─── Lock Screen overlay ───────────────────────────────────────────────────────
+
+function LockScreen({ username, onUnlock }) {
+ const [pin, setPin] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ function pressDigit(d) {
+ if (d === '⌫') { setPin(p => p.slice(0, -1)); setError(''); return }
+ if (d === '') return
+ if (pin.length >= 6) return
+ setPin(p => p + d)
+ }
+
+ async function handleSubmit() {
+ if (pin.length < 4) return
+ setError('')
+ setLoading(true)
+ try {
+ const { data } = await client.post('/api/auth/login', { username, pin })
+ const role = data.user.role
+ if (role !== 'manager' && role !== 'sysadmin') {
+ setError('Δεν έχεις δικαιώματα διαχειριστή.')
+ setPin('')
+ return
+ }
+ onUnlock(data.user, data.access_token)
+ } catch {
+ setError('Λανθασμένο PIN')
+ setPin('')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Auto-submit when 4 digits entered (most PINs are 4)
+ useEffect(() => {
+ if (pin.length === 4) handleSubmit()
+ }, [pin])
+
+ return (
+
+
+
🔒
+
+ Κλειδωμένο
+
+
+ {username}
+
+
+ {/* PIN dots */}
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* PIN pad */}
+
+ {DIGITS.map((d, i) => (
+ pressDigit(d)}
+ disabled={d === '' || loading}
+ style={{
+ height: 56, borderRadius: 14, border: 'none', cursor: d === '' ? 'default' : 'pointer',
+ fontSize: 20, fontWeight: 600,
+ background: d === '' ? 'transparent' : d === '⌫' ? '#f3f4f6' : '#f3f4f6',
+ color: d === '⌫' ? '#6b7280' : '#111315',
+ visibility: d === '' ? 'hidden' : 'visible',
+ transition: 'background 80ms',
+ }}
+ onMouseDown={e => { if (d !== '') e.currentTarget.style.background = '#e5e7eb' }}
+ onMouseUp={e => { if (d !== '') e.currentTarget.style.background = '#f3f4f6' }}
+ onMouseLeave={e => { if (d !== '') e.currentTarget.style.background = '#f3f4f6' }}
+ >
+ {d}
+
+ ))}
+
+
+ {error && (
+
{error}
+ )}
+ {loading && (
+
Επαλήθευση…
+ )}
+
+
+ )
+}
+
+// ─── AppLayout ─────────────────────────────────────────────────────────────────
+
+export default function AppLayout() {
+ const { user, token, savedUsername, login, logout, lock, unlock, locked } = useAuthStore()
+ const [clock, setClock] = useState(new Date())
+ const navigate = useNavigate()
+ const lastActivityRef = useRef(Date.now())
+ const lockTimerRef = useRef(null)
+
+ // ── Rehydrate user from token on mount ──────────────────────────────────────
useEffect(() => {
if (token && !user) {
client.get('/auth/me').then(r => login(r.data, token)).catch(() => logout())
}
}, [token])
+ // ── Clock ────────────────────────────────────────────────────────────────────
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
+ // ── Auto-lock timer ──────────────────────────────────────────────────────────
+ const getTimeoutMs = useCallback(() => {
+ const raw = localStorage.getItem(SETTINGS_KEY)
+ const mins = parseInt(raw, 10)
+ if (!isNaN(mins) && mins > 0) return mins * 60 * 1000
+ return null // 0 or unset = disabled
+ }, [])
+
+ const resetActivityTimer = useCallback(() => {
+ lastActivityRef.current = Date.now()
+ }, [])
+
+ useEffect(() => {
+ if (!user || locked) return
+
+ const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click']
+ EVENTS.forEach(e => window.addEventListener(e, resetActivityTimer, { passive: true }))
+
+ function checkIdle() {
+ const timeoutMs = getTimeoutMs()
+ if (!timeoutMs) return
+ if (Date.now() - lastActivityRef.current >= timeoutMs) {
+ lock()
+ }
+ }
+
+ lockTimerRef.current = setInterval(checkIdle, 10_000)
+
+ return () => {
+ EVENTS.forEach(e => window.removeEventListener(e, resetActivityTimer))
+ clearInterval(lockTimerRef.current)
+ }
+ }, [user, locked, getTimeoutMs, resetActivityTimer, lock])
+
+ // ── Handlers ─────────────────────────────────────────────────────────────────
+ function handleLogout() {
+ logout()
+ navigate('/login', { replace: true })
+ }
+
+ function handleUnlock(u, t) {
+ unlock(u, t)
+ }
+
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
+ const displayName = user?.username || savedUsername || ''
return (
+ {/* Lock overlay — rendered on top of everything */}
+ {locked && displayName && (
+
+ )}
+
{/* Top bar */}
{timeStr}
-
+
+ {/* Lock button */}
+
{ e.currentTarget.style.background = '#f3f4f6'; e.currentTarget.style.color = '#374151' }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.color = '#5a6169' }}
+ >🔒
{user?.username}
Αποσύνδεση
diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx
deleted file mode 100644
index fa9b1a5..0000000
--- a/manager_dashboard/src/pages/DashboardPage.jsx
+++ /dev/null
@@ -1,320 +0,0 @@
-import { useState } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { useNavigate } from 'react-router-dom'
-import client from '../api/client'
-
-const API_URL = import.meta.env.VITE_API_URL || ''
-
-const FILTERS = ['all', 'open', 'partially_paid', 'free']
-const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
-
-// ─── Design tokens ────────────────────────────────────────────────────────────
-const COLORS = {
- open: {
- label: 'Ανοιχτό',
- tint: '#eef7f0', tintStrong: '#d7ecdc',
- accent: '#2f9e5e', ink: '#1f7042',
- },
- partially_paid: {
- label: 'Μερική πληρ.',
- tint: '#f4eefb', tintStrong: '#e3d4f3',
- accent: '#7a44c9', ink: '#57309a',
- },
- free: {
- label: 'Ελεύθερο',
- tint: '#f4f4f2', tintStrong: '#dfe2e6',
- accent: '#8a9099', ink: '#5a6169',
- },
-}
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-function formatEuro(n) {
- return '€' + parseFloat(n).toFixed(2)
-}
-
-function formatDuration(openedAt) {
- const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
- if (mins < 60) return `${mins}m`
- const h = Math.floor(mins / 60)
- const m = mins % 60
- return m === 0 ? `${h}h` : `${h}h ${m}m`
-}
-
-function occupiedMinsFromDate(openedAt) {
- return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
-}
-
-function orderTotal(items = []) {
- return items
- .filter(i => i.status !== 'cancelled')
- .reduce((s, i) => s + i.unit_price * i.quantity, 0)
-}
-
-function avatarColor(name) {
- const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
- let h = 0
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
- return palette[h % palette.length]
-}
-
-function WaiterBubble({ waiter, size = 26 }) {
- // waiter: { name, avatarUrl }
- if (waiter.avatarUrl) {
- return (
-
- )
- }
- const parts = waiter.name.trim().split(' ')
- const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
- return (
- {initials}
- )
-}
-
-// ─── V1 Table Card ────────────────────────────────────────────────────────────
-function TableCardV1({ name, status, amount, openedAt, waiters = [], onClick }) {
- const s = COLORS[status] || COLORS.free
- const [hover, setHover] = useState(false)
- const [pressed, setPressed] = useState(false)
-
- const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
- const showMulti = waiters.length >= 3
-
- return (
- setHover(true)}
- onMouseLeave={() => { setHover(false); setPressed(false) }}
- onMouseDown={() => setPressed(true)}
- onMouseUp={() => setPressed(false)}
- style={{
- '--cardBg': s.tint,
- position: 'relative',
- width: '100%', minWidth: 330, height: 200,
- padding: '16px 18px 16px 24px',
- background: s.tint,
- border: '1px solid ' + s.tintStrong,
- borderRadius: 14,
- boxShadow: pressed
- ? 'inset 0 2px 4px rgba(16,20,24,0.08)'
- : hover
- ? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
- : '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
- transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
- transition: 'transform 120ms ease, box-shadow 120ms ease',
- cursor: onClick ? 'pointer' : 'default',
- textAlign: 'left',
- font: 'inherit',
- color: 'inherit',
- display: 'flex', flexDirection: 'column',
- outline: 'none',
- flexShrink: 0,
- }}
- >
- {/* left accent bar */}
-
-
- {/* Header: name + status pill */}
-
-
{name}
-
-
- {s.label}
-
-
-
- {/* Flags row — fixed height placeholder */}
-
-
- {/* Stats row */}
-
-
-
Total
-
- {amount != null ? formatEuro(amount) : — — }
-
-
-
-
Time
-
= 90 ? 700 : 500,
- color: '#111315',
- }}>
- {openedAt ? formatDuration(openedAt) : — — }
-
-
-
-
- {/* Waiter row */}
-
- {waiters.length === 0 ? (
-
Unassigned
- ) : showMulti ? (
- <>
-
- {waiters.slice(0, 3).map((w, i) => (
-
-
-
- ))}
-
-
Multiple ({waiters.length})
- >
- ) : (
- waiters.map((w, i) => (
-
-
- {w.shortName}
-
- ))
- )}
-
-
- )
-}
-
-// ─── Page ─────────────────────────────────────────────────────────────────────
-export default function DashboardPage() {
- const [filter, setFilter] = useState('all')
- const navigate = useNavigate()
-
- const { data: tables = [], isLoading: tablesLoading } = useQuery({
- queryKey: ['tables'],
- queryFn: () => client.get('/api/tables/').then(r => r.data),
- refetchInterval: 5_000,
- })
-
- const { data: orders = [], isLoading: ordersLoading } = useQuery({
- queryKey: ['orders-active'],
- queryFn: () => client.get('/api/orders/').then(r => r.data),
- refetchInterval: 5_000,
- })
-
- const { data: waiters = [] } = useQuery({
- queryKey: ['waiters'],
- queryFn: () => client.get('/api/waiters/').then(r => r.data),
- staleTime: 60_000,
- })
-
- // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
- const waiterMap = Object.fromEntries(waiters.map(w => {
- const name = w.full_name || w.nickname || w.username
- const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
- const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
- return [w.id, { name, shortName, avatarUrl }]
- }))
-
- const tableCards = tables.map(table => {
- const order = orders.find(o =>
- o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
- )
- const tableStatus = order ? order.status : 'free'
- return { table, order, tableStatus }
- })
-
- const filtered = filter === 'all'
- ? tableCards
- : tableCards.filter(c => c.tableStatus === filter)
-
- if (tablesLoading || ordersLoading) {
- return Φόρτωση…
- }
-
- return (
-
-
-
Dashboard
-
- {FILTERS.map(f => (
- setFilter(f)}
- className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
- >
- {FILTER_LABELS[f]}
-
- ))}
-
-
-
- {filtered.length === 0 && (
-
Δεν βρέθηκαν τραπέζια.
- )}
-
-
- {filtered.map(({ table, order, tableStatus }) => {
- const waiterNames = order
- ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
- : []
- const amount = order ? orderTotal(order.items) : null
-
- return (
-
navigate(`/orders/${order.id}`) : undefined}
- />
- )
- })}
-
-
- )
-}
diff --git a/manager_dashboard/src/pages/DashboardTab.jsx b/manager_dashboard/src/pages/DashboardTab.jsx
new file mode 100644
index 0000000..d244958
--- /dev/null
+++ b/manager_dashboard/src/pages/DashboardTab.jsx
@@ -0,0 +1,739 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import toast from 'react-hot-toast'
+import client from '../api/client'
+
+// ─── Business Day + Shift Management Panel ───────────────────────────────────
+
+function fmtTime(iso) {
+ if (!iso) return '—'
+ return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
+}
+
+function fmtShiftDuration(iso) {
+ if (!iso) return ''
+ const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
+ if (mins < 60) return `${mins}λ`
+ const h = Math.floor(mins / 60); const m = mins % 60
+ return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
+}
+
+function StartShiftModal({ waiters, onClose, onStart }) {
+ const [waiterId, setWaiterId] = useState('')
+ const [cash, setCash] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ async function submit() {
+ if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
+ setBusy(true)
+ try {
+ await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
+ onClose()
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
Έναρξη Βάρδιας
+ ✕
+
+
+ Σερβιτόρος
+ setWaiterId(e.target.value)}>
+ — Επιλέξτε —
+ {waiters.map(w => {w.full_name || w.username} )}
+
+
+
+ Αρχικά Μετρητά (€)
+ setCash(e.target.value)}
+ className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
+
+
+ Ακύρωση
+
+ {busy ? 'Εκκίνηση…' : 'Έναρξη'}
+
+
+
+
+ )
+}
+
+function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
+ const hasPendingPayments = details.partially_paid > 0
+
+ if (!hasPendingPayments) {
+ // All tables open but nothing owed — safe to close, just needs confirmation
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
Κλείσιμο Ημέρας
+
+
+ {details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
+
+
Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;
+
+
+
+ Ακύρωση
+
+
+ {busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
+
+
+
+
+ )
+ }
+
+ // Some tables have unpaid items — revenue will be lost, needs hard warning
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
+ !
+
+
Εκκρεμείς Πληρωμές
+
+
+
+ {details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
+ από τα οποία {details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές .
+
+
Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.
+
+
+ Επιλέξτε Ακύρωση για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
+
+
+
+ Ακύρωση
+
+
+ {busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
+
+
+
+
+ )
+}
+
+function BusinessDayPanel() {
+ const qc = useQueryClient()
+ const [showStartShift, setShowStartShift] = useState(false)
+ const [closeDetails, setCloseDetails] = useState(null)
+ const [forceClosing, setForceClosing] = useState(false)
+
+ const { data: businessDay } = useQuery({
+ queryKey: ['business-day'],
+ queryFn: () => client.get('/api/business-day/current').then(r => r.data),
+ refetchInterval: 15_000,
+ })
+
+ const { data: activeShifts = [] } = useQuery({
+ queryKey: ['active-shifts'],
+ queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
+ refetchInterval: 15_000,
+ })
+
+ const { data: allWaiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const waitersWithoutShift = allWaiters.filter(
+ w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
+ )
+
+ const openDayMut = useMutation({
+ mutationFn: () => client.post('/api/business-day/open', {}),
+ onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
+ onError: (e) => toast.error(e.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ async function handleCloseDay(force = false) {
+ setForceClosing(force)
+ try {
+ await client.post('/api/business-day/close', { force })
+ toast.success('Ημέρα έκλεισε!')
+ setCloseDetails(null)
+ qc.invalidateQueries({ queryKey: ['business-day'] })
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ qc.invalidateQueries({ queryKey: ['orders-active'] })
+ } catch (e) {
+ const detail = e.response?.data?.detail
+ if (e.response?.status === 409 && detail?.open_orders) {
+ setCloseDetails(detail)
+ } else {
+ toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
+ }
+ } finally {
+ setForceClosing(false)
+ }
+ }
+
+ async function handleEndShift(shiftId, waiterName) {
+ if (!window.confirm(`Να τελειώσει η βάρδια του ${waiterName};`)) return
+ try {
+ await client.post(`/api/shifts/manager/end/${shiftId}`, {})
+ toast.success('Βάρδια έκλεισε')
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Σφάλμα')
+ }
+ }
+
+ async function handleStartShift(waiterId, startingCash) {
+ await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
+ toast.success('Βάρδια ξεκίνησε!')
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ }
+
+ const isOpen = !!businessDay
+
+ return (
+ <>
+
+ {/* Header row */}
+
+
+
+
+
+ {isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
+
+ {isOpen && businessDay?.opened_at && (
+
+ από {fmtTime(businessDay.opened_at)}
+
+ )}
+
+
+
+ {isOpen && waitersWithoutShift.length > 0 && (
+ setShowStartShift(true)}
+ className="h-8 px-3 rounded-lg bg-white border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-50"
+ >
+ + Βάρδια
+
+ )}
+ {isOpen ? (
+ handleCloseDay(false)}
+ className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
+ >
+ Κλείσιμο Ημέρας
+
+ ) : (
+ openDayMut.mutate()}
+ disabled={openDayMut.isPending}
+ className="h-8 px-4 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700 disabled:opacity-60"
+ >
+ {openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
+
+ )}
+
+
+
+ {/* Active shifts */}
+ {isOpen && (
+
+ {activeShifts.length === 0 ? (
+
Κανένας σερβιτόρος σε βάρδια
+ ) : (
+
+ {activeShifts.map(s => (
+
+
+ {s.waiter_name}
+ {fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}
+ {s.total_collected > 0 && (
+ €{s.total_collected.toFixed(2)}
+ )}
+
+
handleEndShift(s.id, s.waiter_name)}
+ className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
+ title="Τέλος βάρδιας"
+ >
+ ⏹
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {showStartShift && (
+ setShowStartShift(false)}
+ onStart={handleStartShift}
+ />
+ )}
+ {closeDetails && (
+ setCloseDetails(null)}
+ onConfirm={() => handleCloseDay(true)}
+ busy={forceClosing}
+ />
+ )}
+ >
+ )
+}
+
+const API_URL = import.meta.env.VITE_API_URL || ''
+
+const FILTERS = ['all', 'open', 'partially_paid', 'free']
+const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' }
+
+// ─── Design tokens ────────────────────────────────────────────────────────────
+const COLORS = {
+ open: {
+ label: 'Ανοιχτό',
+ tint: '#eef7f0', tintStrong: '#d7ecdc',
+ accent: '#2f9e5e', ink: '#1f7042',
+ },
+ partially_paid: {
+ label: 'Μερική πληρ.',
+ tint: '#f4eefb', tintStrong: '#e3d4f3',
+ accent: '#7a44c9', ink: '#57309a',
+ },
+ free: {
+ label: 'Ελεύθερο',
+ tint: '#f4f4f2', tintStrong: '#dfe2e6',
+ accent: '#8a9099', ink: '#5a6169',
+ },
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+function formatEuro(n) {
+ return '€' + parseFloat(n).toFixed(2)
+}
+
+function formatDuration(openedAt) {
+ const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
+ if (mins < 60) return `${mins}m`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m === 0 ? `${h}h` : `${h}h ${m}m`
+}
+
+function occupiedMinsFromDate(openedAt) {
+ return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
+}
+
+function orderTotal(items = []) {
+ return items
+ .filter(i => i.status !== 'cancelled')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+}
+
+function avatarColor(name) {
+ const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
+ let h = 0
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
+ return palette[h % palette.length]
+}
+
+function WaiterBubble({ waiter, size = 26 }) {
+ // waiter: { name, avatarUrl }
+ if (waiter.avatarUrl) {
+ return (
+
+ )
+ }
+ const parts = waiter.name.trim().split(' ')
+ const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
+ return (
+ {initials}
+ )
+}
+
+// ─── V1 Table Card ────────────────────────────────────────────────────────────
+function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, onClick }) {
+ const s = COLORS[status] || COLORS.free
+ const [hover, setHover] = useState(false)
+ const [pressed, setPressed] = useState(false)
+
+ const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
+ const showMulti = waiters.length >= 3
+
+ return (
+ setHover(true)}
+ onMouseLeave={() => { setHover(false); setPressed(false) }}
+ onMouseDown={() => setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ style={{
+ '--cardBg': s.tint,
+ position: 'relative',
+ width: '100%', minWidth: 330, height: 200,
+ padding: '16px 18px 16px 24px',
+ background: s.tint,
+ border: '1px solid ' + s.tintStrong,
+ borderRadius: 14,
+ boxShadow: pressed
+ ? 'inset 0 2px 4px rgba(16,20,24,0.08)'
+ : hover
+ ? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
+ : '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
+ transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
+ transition: 'transform 120ms ease, box-shadow 120ms ease',
+ cursor: onClick ? 'pointer' : 'default',
+ textAlign: 'left',
+ font: 'inherit',
+ color: 'inherit',
+ display: 'flex', flexDirection: 'column',
+ outline: 'none',
+ flexShrink: 0,
+ }}
+ >
+ {/* left accent bar */}
+
+
+ {/* Header: name + status pill */}
+
+
{name}
+
+
+ {s.label}
+
+
+
+ {/* Flags row */}
+
+ {hasPendingPrint && (
+
+ ⏳ Εκκρεμής εκτύπωση
+
+ )}
+
+
+ {/* Stats row */}
+
+
+
Total
+
+ {amount != null ? formatEuro(amount) : — — }
+
+
+
+
Time
+
= 90 ? 700 : 500,
+ color: '#111315',
+ }}>
+ {openedAt ? formatDuration(openedAt) : — — }
+
+
+
+
+ {/* Waiter row */}
+
+ {waiters.length === 0 ? (
+
Unassigned
+ ) : showMulti ? (
+ <>
+
+ {waiters.slice(0, 3).map((w, i) => (
+
+
+
+ ))}
+
+
Multiple ({waiters.length})
+ >
+ ) : (
+ waiters.map((w, i) => (
+
+
+ {w.shortName}
+
+ ))
+ )}
+
+
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+export default function DashboardPage() {
+ const [filter, setFilter] = useState('all')
+ const [retryingId, setRetryingId] = useState(null)
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const { data: tables = [], isLoading: tablesLoading } = useQuery({
+ queryKey: ['tables'],
+ queryFn: () => client.get('/api/tables/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const { data: orders = [], isLoading: ordersLoading } = useQuery({
+ queryKey: ['orders-active'],
+ queryFn: () => client.get('/api/orders/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const { data: waiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl }
+ const waiterMap = Object.fromEntries(waiters.map(w => {
+ const name = w.full_name || w.nickname || w.username
+ const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
+ const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
+ return [w.id, { name, shortName, avatarUrl }]
+ }))
+
+ const tableCards = tables.map(table => {
+ const order = orders.find(o =>
+ o.table_id === table.id && ['open', 'partially_paid'].includes(o.status)
+ )
+ const tableStatus = order ? order.status : 'free'
+ const hasPendingPrint = order
+ ? order.items.some(i => i.status === 'active' && !i.printed)
+ : false
+ return { table, order, tableStatus, hasPendingPrint }
+ })
+
+ const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
+
+ async function retrySingleOrder(orderId) {
+ setRetryingId(orderId)
+ try {
+ const res = await client.post(`/api/orders/${orderId}/retry-print`)
+ const results = res.data.print_results ?? []
+ const allOk = results.length === 0 || results.every(r => r.success)
+ if (allOk) {
+ toast.success('Εκτυπώθηκε επιτυχώς')
+ } else {
+ const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
+ toast.error(`Αποτυχία: ${failed}`)
+ }
+ queryClient.invalidateQueries({ queryKey: ['orders-active'] })
+ } catch {
+ toast.error('Σφάλμα επικοινωνίας')
+ } finally {
+ setRetryingId(null)
+ }
+ }
+
+ async function retryAllOrders() {
+ for (const { order } of pendingPrintOrders) {
+ if (order) await retrySingleOrder(order.id)
+ }
+ }
+
+ const filtered = filter === 'all'
+ ? tableCards
+ : tableCards.filter(c => c.tableStatus === filter)
+
+ if (tablesLoading || ordersLoading) {
+ return Φόρτωση…
+ }
+
+ return (
+
+
+
+
+
Dashboard
+
+ {FILTERS.map(f => (
+ setFilter(f)}
+ className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
+ >
+ {FILTER_LABELS[f]}
+
+ ))}
+
+
+
+ {filtered.length === 0 && (
+
Δεν βρέθηκαν τραπέζια.
+ )}
+
+
+ {filtered.map(({ table, order, tableStatus, hasPendingPrint }) => {
+ const waiterNames = order
+ ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
+ : []
+ const amount = order ? orderTotal(order.items) : null
+
+ return (
+
navigate(`/orders/${order.id}`) : undefined}
+ />
+ )
+ })}
+
+
+ {/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
+ {pendingPrintOrders.length > 0 && (
+
+
+
+
⏳
+
+
Εκκρεμείς Εκτυπώσεις
+
+ {pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
+
+
+
+
+ {retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
+
+
+
+
+ {pendingPrintOrders.map(({ table, order }) => {
+ const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
+ const tableName = table.label || `T${table.number}`
+ return (
+
+
+ {tableName}
+
+
+
+ {unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
+
+
+ {unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
+
+
+
+ navigate(`/orders/${order.id}`)}
+ >
+ Λεπτομέρειες
+
+ retrySingleOrder(order.id)}
+ disabled={retryingId === order.id}
+ >
+ {retryingId === order.id ? '…' : 'Εκτύπωση'}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/ManagementPage.jsx b/manager_dashboard/src/pages/ManagementPage.jsx
new file mode 100644
index 0000000..b0fe49c
--- /dev/null
+++ b/manager_dashboard/src/pages/ManagementPage.jsx
@@ -0,0 +1,57 @@
+import { useState } from 'react'
+import ProductsTab from './ProductsTab'
+import TablesConfigTab from './TablesConfigTab'
+import StaffTab from './StaffTab'
+
+const TABS = [
+ { key: 'products', label: 'Προϊόντα' },
+ { key: 'tables', label: 'Τραπέζια' },
+ { key: 'staff', label: 'Προσωπικό' },
+]
+
+export default function ManagementPage() {
+ const [activeTab, setActiveTab] = useState('products')
+
+ return (
+
+ {/* Tab bar */}
+
+ {TABS.map(tab => (
+ setActiveTab(tab.key)}
+ style={{
+ height: 40,
+ padding: '0 20px',
+ borderRadius: '8px 8px 0 0',
+ border: 'none',
+ borderBottom: activeTab === tab.key ? '2px solid #3758c9' : '2px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.key ? '#3758c9' : '#6b7280',
+ fontSize: 14,
+ fontWeight: activeTab === tab.key ? 700 : 500,
+ cursor: 'pointer',
+ transition: 'color 120ms ease, border-color 120ms ease',
+ fontFamily: 'inherit',
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {activeTab === 'products' &&
}
+ {activeTab === 'tables' &&
}
+ {activeTab === 'staff' &&
}
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/OperationsPage.jsx b/manager_dashboard/src/pages/OperationsPage.jsx
new file mode 100644
index 0000000..03b42c8
--- /dev/null
+++ b/manager_dashboard/src/pages/OperationsPage.jsx
@@ -0,0 +1,1610 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import toast from 'react-hot-toast'
+import client from '../api/client'
+import StatusBadge from '../components/StatusBadge'
+import ConfirmModal from '../components/ConfirmModal'
+
+const API_URL = import.meta.env.VITE_API_URL || ''
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmtTime(iso) {
+ if (!iso) return '—'
+ return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
+}
+
+function fmtDuration(isoStart) {
+ if (!isoStart) return '—'
+ const mins = Math.floor((Date.now() - new Date(isoStart).getTime()) / 60000)
+ if (mins < 60) return `${mins}λ`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
+}
+
+function fmtEuro(n) {
+ return '€' + parseFloat(n || 0).toFixed(2)
+}
+
+function orderTotal(items = []) {
+ return items
+ .filter(i => i.status !== 'cancelled')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+}
+
+function avatarColor(name) {
+ const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
+ let h = 0
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
+ return palette[h % palette.length]
+}
+
+function Avatar({ name, avatarUrl, size = 36 }) {
+ if (avatarUrl) {
+ return (
+
+ )
+ }
+ const parts = (name || '?').trim().split(' ')
+ const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
+ return (
+ {initials}
+ )
+}
+
+// ─── Table color tokens ────────────────────────────────────────────────────────
+
+const TABLE_COLORS = {
+ open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' },
+ partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' },
+ free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
+}
+
+// ─── Business Day / Shift modals ───────────────────────────────────────────────
+
+function StartShiftModal({ waiters, onClose, onStart }) {
+ const [waiterId, setWaiterId] = useState('')
+ const [cash, setCash] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ async function submit() {
+ if (!waiterId) { toast.error('Επιλέξτε σερβιτόρο'); return }
+ setBusy(true)
+ try {
+ await onStart(Number(waiterId), cash ? parseFloat(cash) : null)
+ onClose()
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
Έναρξη Βάρδιας
+ ✕
+
+
+ Σερβιτόρος
+ setWaiterId(e.target.value)}>
+ — Επιλέξτε —
+ {waiters.map(w => {w.full_name || w.username} )}
+
+
+
+ Αρχικά Μετρητά (€)
+ setCash(e.target.value)}
+ className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none" />
+
+
+ Ακύρωση
+
+ {busy ? 'Εκκίνηση…' : 'Έναρξη'}
+
+
+
+
+ )
+}
+
+function CloseConfirmModal({ details, onClose, onConfirm, busy }) {
+ if (!details.partially_paid) {
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
Κλείσιμο Ημέρας
+
+
+ {details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
+
+
Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;
+
+
+ Ακύρωση
+
+ {busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
+
+
+
+
+ )
+ }
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
+ !
+
+
Εκκρεμείς Πληρωμές
+
+
+
+ {details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
+ από τα οποία {details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές .
+
+
Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.
+
+
+ Επιλέξτε Ακύρωση για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
+
+
+ Ακύρωση
+
+ {busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
+
+
+
+
+ )
+}
+
+// ─── Order quick-view modal ────────────────────────────────────────────────────
+
+function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
+ const qc = useQueryClient()
+
+ const { data: order, isLoading } = useQuery({
+ queryKey: ['order', orderId],
+ queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
+ enabled: !!orderId,
+ })
+
+ const { data: waiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const { data: printers = [] } = useQuery({
+ queryKey: ['printers'],
+ queryFn: () => client.get('/api/system/printers').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const [confirmAction, setConfirmAction] = useState(null)
+ const [printerId, setPrinterId] = useState('')
+
+ const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ['order', orderId] })
+ qc.invalidateQueries({ queryKey: ['orders-active'] })
+ }
+
+ const payItems = useMutation({
+ mutationFn: (item_ids) => client.post(`/api/orders/${orderId}/pay`, { item_ids }),
+ onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
+ onError: () => toast.error('Σφάλμα πληρωμής'),
+ })
+
+ const cancelItem = useMutation({
+ mutationFn: (itemId) => client.delete(`/api/orders/${orderId}/items/${itemId}`),
+ onSuccess: () => { toast.success('Αντικείμενο ακυρώθηκε'); invalidate() },
+ onError: () => toast.error('Σφάλμα ακύρωσης'),
+ })
+
+ const cancelOrder = useMutation({
+ mutationFn: () => client.delete(`/api/orders/${orderId}`),
+ onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); invalidate(); onClose() },
+ onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
+ })
+
+ const closeOrder = useMutation({
+ mutationFn: () => client.post(`/api/orders/${orderId}/close`),
+ onSuccess: () => { toast.success('Παραγγελία έκλεισε'); invalidate(); onClose() },
+ onError: () => toast.error('Σφάλμα κλεισίματος'),
+ })
+
+ const printOrder = useMutation({
+ mutationFn: (pid) => client.post(`/api/orders/${orderId}/print`, { printer_id: pid }),
+ onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
+ onError: () => toast.error('Σφάλμα εκτύπωσης'),
+ })
+
+ function handleConfirm() {
+ if (!confirmAction) return
+ if (confirmAction.type === 'cancelItem') cancelItem.mutate(confirmAction.payload)
+ if (confirmAction.type === 'cancelOrder') cancelOrder.mutate()
+ if (confirmAction.type === 'closeOrder') closeOrder.mutate()
+ setConfirmAction(null)
+ }
+
+ const total = order ? orderTotal(order.items) : 0
+ const activeItems = order ? order.items.filter(i => i.status === 'active') : []
+ const isOpen = order ? ['open', 'partially_paid'].includes(order.status) : false
+
+ return (
+ { if (e.target === e.currentTarget) onClose() }}
+ >
+
+ {/* Modal header */}
+
+
+
+ Τραπέζι {tableName}
+
+ {order && (
+
+ Παραγγελία #{order.id} · από {fmtTime(order.opened_at)} · {fmtDuration(order.opened_at)}
+
+ )}
+
+
+ {order && (
+ {fmtEuro(total)}
+ )}
+ {order && (
+
+ )}
+ ✕
+
+
+
+ {/* Scrollable body */}
+
+ {isLoading && (
+
Φόρτωση…
+ )}
+
+ {order && (
+ <>
+ {/* Waiters row */}
+ {order.waiters.length > 0 && (
+
+
Προσωπικό
+
+ {order.waiters.map(w => (
+ {waiterMap[w.waiter_id] || `#${w.waiter_id}`}
+ ))}
+
+
+ )}
+
+ {/* Items list */}
+
+ {order.items.length === 0 && (
+
Κανένα αντικείμενο.
+ )}
+ {order.items.map(item => (
+
+
+
+ {item.product?.name ?? `#${item.product_id}`}
+ ×{item.quantity}
+
+ {item.notes &&
{item.notes}
}
+ {item.paid_by && (
+
+ Πληρώθηκε
+
+ )}
+
+
+ {fmtEuro(item.unit_price * item.quantity)}
+ {isOpen && item.status === 'active' && (
+ <>
+ payItems.mutate([item.id])}
+ style={{
+ height: 28, padding: '0 10px', borderRadius: 6,
+ border: '1px solid #d7ecdc', background: '#eef7f0',
+ color: '#1f7042', fontSize: 11, fontWeight: 700, cursor: 'pointer',
+ }}
+ >Πληρωμή
+ setConfirmAction({ type: 'cancelItem', payload: item.id })}
+ style={{
+ height: 28, padding: '0 10px', borderRadius: 6,
+ border: '1px solid #fecaca', background: '#fef2f2',
+ color: '#dc2626', fontSize: 11, fontWeight: 700, cursor: 'pointer',
+ }}
+ >✕
+ >
+ )}
+
+
+ ))}
+
+ >
+ )}
+
+
+ {/* Footer actions */}
+ {order && (
+
+ {isOpen && activeItems.length > 0 && (
+
payItems.mutate(activeItems.map(i => i.id))}
+ style={{
+ height: 36, padding: '0 16px', borderRadius: 8,
+ background: '#3758c9', border: 'none', color: 'white',
+ fontSize: 13, fontWeight: 700, cursor: 'pointer',
+ }}
+ >Πληρωμή όλων
+ )}
+ {isOpen && (
+ <>
+
setConfirmAction({ type: 'closeOrder' })}
+ style={{
+ height: 36, padding: '0 14px', borderRadius: 8,
+ border: '1px solid #dfe2e6', background: 'white',
+ color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
+ }}
+ >Κλείσιμο
+
setConfirmAction({ type: 'cancelOrder' })}
+ style={{
+ height: 36, padding: '0 14px', borderRadius: 8,
+ border: '1px solid #fecaca', background: '#fef2f2',
+ color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
+ }}
+ >Ακύρωση
+ >
+ )}
+ {printers.length > 0 && (
+
+ setPrinterId(e.target.value)}
+ style={{
+ height: 36, paddingLeft: 10, paddingRight: 28, borderRadius: 8,
+ border: '1px solid #dfe2e6', background: 'white',
+ color: '#2b2f33', fontSize: 12, cursor: 'pointer',
+ }}
+ >
+ Εκτυπωτής…
+ {printers.map(p => {p.name} )}
+
+ { if (printerId) printOrder.mutate(Number(printerId)) }}
+ disabled={!printerId}
+ style={{
+ height: 36, padding: '0 14px', borderRadius: 8,
+ border: '1px solid #dfe2e6', background: 'white',
+ color: '#2b2f33', fontSize: 13, fontWeight: 600,
+ cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
+ }}
+ >🖨 Εκτύπωση
+
+ )}
+
0 ? 0 : 'auto',
+ }}
+ >Πλήρης εικόνα →
+
+ )}
+
+
+ {confirmAction && (
+
setConfirmAction(null)}
+ />
+ )}
+
+ )
+}
+
+// ─── KPI Card ─────────────────────────────────────────────────────────────────
+
+function KpiCard({ label, value, sub, accent = '#3758c9', pct }) {
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+ {pct != null && (
+
+ )}
+
+ )
+}
+
+// ─── Mini table chip ──────────────────────────────────────────────────────────
+
+function TableChip({ name, status, amount, onClick }) {
+ const c = TABLE_COLORS[status] || TABLE_COLORS.free
+ return (
+ { if (onClick) { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)' } }}
+ onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '' }}
+ >
+ {name}
+
+ )
+}
+
+// ─── Shifts card ──────────────────────────────────────────────────────────────
+
+// ─── End shift confirmation modal ─────────────────────────────────────────────
+
+function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
+ ⏹
+
+
+
Τέλος βάρδιας;
+
{shift.waiter_name}
+
+
+
+
+ Έναρξη {fmtTime(shift.started_at)}
+
+
+ Διάρκεια {fmtDuration(shift.started_at)}
+
+
+ Είσπραξη {fmtEuro(shift.total_collected)}
+
+
+
Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.
+
+ Άκυρο
+
+ {busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
+
+
+
+
+ )
+}
+
+// ─── Shift summary modal ───────────────────────────────────────────────────────
+
+function ShiftSummaryModal({ shiftId, onConfirm }) {
+ const [summary, setSummary] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ client.get(`/api/shifts/${shiftId}/summary`)
+ .then(r => setSummary(r.data))
+ .catch(() => setSummary(null))
+ .finally(() => setLoading(false))
+ }, [shiftId])
+
+ function fmtMins(mins) {
+ if (mins == null) return '—'
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
+ }
+
+ const totalItems = summary?.orders?.reduce((s, o) => s + o.items.reduce((ss, i) => ss + i.quantity, 0), 0) ?? 0
+
+ return (
+
+
+ {/* Header */}
+
+
Σύνοψη Βάρδιας
+ {summary &&
{summary.waiter_name}
}
+
+
+ {loading &&
Φόρτωση…
}
+
+ {!loading && summary && (
+
+ {/* KPI row */}
+
+ {[
+ { label: 'Έναρξη', value: fmtTime(summary.started_at) },
+ { label: 'Ώρες', value: fmtMins(summary.duration_minutes) },
+ { label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEuro(summary.starting_cash) : '—' },
+ { label: 'Είσπραξη', value: fmtEuro(summary.total_collected), accent: '#2f9e5e' },
+ ].map(k => (
+
+
{k.label}
+
{k.value}
+
+ ))}
+
+
+ {/* Net to deliver */}
+
+
+
Σύνολο προς παράδοση
+
Είσπραξη + αρχικά μετρητά
+
+
+ {fmtEuro(summary.net_to_deliver)}
+
+
+
+ {/* Orders breakdown */}
+
+ Παραγγελίες ({summary.orders.length}) · {totalItems} αντικείμενα
+
+ {summary.orders.length === 0 && (
+
Δεν υπάρχουν πληρωμές σε αυτή τη βάρδια
+ )}
+ {summary.orders.map(o => (
+
+
+
+ Παραγγελία #{o.order_id}
+ {o.table_id && · Τραπέζι {o.table_id} }
+
+
+ {fmtEuro(o.items.reduce((s, i) => s + i.subtotal, 0))}
+
+
+ {o.items.map(item => (
+
+ {item.product_name} ×{item.quantity}
+ {fmtEuro(item.subtotal)}
+
+ ))}
+
+ ))}
+
+ )}
+
+ {!loading && !summary && (
+
Σφάλμα φόρτωσης
+ )}
+
+ {/* Footer — no close on outside click, must confirm */}
+
+ ✓ Επιβεβαίωση και Κλείσιμο
+
+
+
+ )
+}
+
+// ─── Compose for single waiter (quick message from shift row) ─────────────────
+
+function QuickMessageModal({ waiter, tables, templates, onClose, onSent }) {
+ const [body, setBody] = useState('')
+ const sendMut = useMutation({
+ mutationFn: (payload) => client.post('/api/messages/send', payload),
+ onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
+ onError: () => toast.error('Σφάλμα αποστολής'),
+ })
+ function send() {
+ if (!body.trim()) return
+ sendMut.mutate({ body: body.trim(), target_waiter_ids: [waiter.id], table_ids: [] })
+ }
+ return (
+ { if (e.target === e.currentTarget) onClose() }}>
+
+
+
💬 Μήνυμα σε {waiter.name}
+
✕
+
+ {templates.length > 0 && (
+
+ {templates.map(t => (
+ setBody(t.body)} style={{
+ height: 26, padding: '0 10px', borderRadius: 6,
+ border: `1px solid ${body === t.body ? '#3758c9' : '#dfe2e6'}`,
+ background: body === t.body ? '#eff3ff' : '#f9fafb',
+ color: body === t.body ? '#3758c9' : '#374151',
+ fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
+ }}>{t.body}
+ ))}
+
+ )}
+
+
+ )
+}
+
+function ShiftsCard({ activeShifts, waitersWithoutShift, isOpen, onStartShift, onEndShift, onMessageWaiter }) {
+ return (
+
+
+
Βάρδιες σε εξέλιξη
+ {isOpen && waitersWithoutShift.length > 0 && (
+
+ Έναρξη
+ )}
+
+
+ {activeShifts.length === 0 ? (
+
Κανένας σερβιτόρος σε βάρδια
+ ) : (
+ activeShifts.map(s => {
+ const activeBreak = Array.isArray(s.breaks) ? s.breaks.find(b => !b.ended_at) : null
+ return (
+
+
+
+
+ {s.waiter_name}
+ {activeBreak && (
+
+ ☕ Διάλειμμα · {fmtDuration(activeBreak.started_at)}
+
+ )}
+
+
+ από {fmtTime(s.started_at)} · {fmtDuration(s.started_at)}
+ {s.total_collected > 0 && (
+ {fmtEuro(s.total_collected)}
+ )}
+
+
+
onMessageWaiter(s)}
+ title="Αποστολή μηνύματος"
+ style={{
+ height: 28, width: 28, borderRadius: 8, flexShrink: 0,
+ border: '1px solid #dfe2e6', background: 'white',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontSize: 14, cursor: 'pointer',
+ }}
+ >💬
+
onEndShift(s)}
+ style={{
+ height: 28, padding: '0 10px', borderRadius: 8,
+ border: '1px solid #fecaca', background: '#fef2f2',
+ color: '#dc2626', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
+ }}
+ >Τέλος
+
+ )
+ })
+ )}
+
+
+ )
+}
+
+// ─── Revenue bar chart ─────────────────────────────────────────────────────────
+
+function RevenueChart({ orders }) {
+ const now = new Date()
+ const currentHour = now.getHours()
+ const buckets = {}
+ for (let h = 10; h <= 23; h++) buckets[h] = 0
+ orders.forEach(o => {
+ const h = new Date(o.opened_at).getHours()
+ if (h >= 10 && h <= 23) buckets[h] = (buckets[h] || 0) + orderTotal(o.items)
+ })
+ const hours = Object.keys(buckets).map(Number)
+ const max = Math.max(...Object.values(buckets), 1)
+
+ return (
+
+
Έσοδα ανά ώρα
+
+ {hours.map(h => {
+ const val = buckets[h]
+ const heightPct = (val / max) * 100
+ const isCurrent = h === currentHour
+ const isFuture = h > currentHour
+ return (
+
+
+
0 ? `${heightPct}%` : '3px',
+ minHeight: val > 0 ? 4 : 3, borderRadius: 4,
+ background: isCurrent ? '#3758c9' : isFuture ? '#edeff1' : '#c2cff0',
+ transition: 'height 300ms ease',
+ }} />
+
+
{h}
+
+ )
+ })}
+
+
+ )
+}
+
+// ─── Reservations stub ────────────────────────────────────────────────────────
+
+function ReservationsCard() {
+ return (
+
+
+
Κρατήσεις σήμερα
+
+ Νέα
+
+
+
📅
+ Σύντομα — σύστημα κρατήσεων υπό ανάπτυξη
+
+
+ )
+}
+
+// ─── Messages card ────────────────────────────────────────────────────────────
+
+function ComposeModal({ waiters, tables, templates, onClose, onSent }) {
+ const [selectedWaiters, setSelectedWaiters] = useState([]) // [] = all
+ const [selectedTables, setSelectedTables] = useState([])
+ const [body, setBody] = useState('')
+
+ const sendMut = useMutation({
+ mutationFn: (payload) => client.post('/api/messages/send', payload),
+ onSuccess: () => { toast.success('Εστάλη!'); onSent(); onClose() },
+ onError: () => toast.error('Σφάλμα αποστολής'),
+ })
+
+ function toggleWaiter(id) {
+ setSelectedWaiters(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
+ }
+ function toggleTable(id) {
+ setSelectedTables(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
+ }
+
+ function send() {
+ if (!body.trim()) return
+ sendMut.mutate({
+ body: body.trim(),
+ target_waiter_ids: selectedWaiters, // [] = broadcast all
+ table_ids: selectedTables,
+ })
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ {/* Recipients */}
+
+
+ Παραλήπτες {selectedWaiters.length === 0 && (Όλοι) }
+
+
+ {waiters.map(w => {
+ const name = w.nickname || w.full_name || w.username
+ const sel = selectedWaiters.includes(w.id)
+ return (
+ toggleWaiter(w.id)} style={{
+ height: 30, padding: '0 12px', borderRadius: 999,
+ border: '1.5px solid ' + (sel ? '#3758c9' : '#dfe2e6'),
+ background: sel ? '#eff3ff' : 'white',
+ color: sel ? '#3758c9' : '#374151',
+ fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
+ }}>{name}
+ )
+ })}
+
+
+
+ {/* Tables (optional) */}
+ {tables.length > 0 && (
+
+
+ Σχετικά Τραπέζια (προαιρετικό)
+
+
+ {tables.map(t => {
+ const name = t.label || `T${t.number}`
+ const sel = selectedTables.includes(t.id)
+ return (
+ toggleTable(t.id)} style={{
+ height: 28, padding: '0 10px', borderRadius: 6,
+ border: '1.5px solid ' + (sel ? '#7a44c9' : '#dfe2e6'),
+ background: sel ? '#f5eeff' : 'white',
+ color: sel ? '#7a44c9' : '#374151',
+ fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
+ }}>{name}
+ )
+ })}
+
+
+ )}
+
+ {/* Quick templates */}
+ {templates.length > 0 && (
+
+
Γρήγορα Μηνύματα
+
+ {templates.map(t => (
+ setBody(t.body)} style={{
+ height: 28, padding: '0 12px', borderRadius: 6,
+ border: '1px solid ' + (body === t.body ? '#3758c9' : '#dfe2e6'),
+ background: body === t.body ? '#eff3ff' : '#f9fafb',
+ color: body === t.body ? '#3758c9' : '#374151',
+ fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
+ }}>{t.body}
+ ))}
+
+
+ )}
+
+ {/* Custom body */}
+
+
+ )
+}
+
+function MessagesCard({ waiters, tables }) {
+ const qc = useQueryClient()
+ const [composing, setComposing] = useState(false)
+
+ const { data: messages = [] } = useQuery({
+ queryKey: ['messages-all'],
+ queryFn: () => client.get('/api/messages/all?limit=20').then(r => r.data),
+ refetchInterval: 10_000,
+ })
+
+ const { data: templates = [] } = useQuery({
+ queryKey: ['quick-templates'],
+ queryFn: () => client.get('/api/messages/templates').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ function parseJsonField(val) {
+ if (Array.isArray(val)) return val
+ try { return JSON.parse(val || '[]') } catch { return [] }
+ }
+
+ function fmtTargets(msg) {
+ const ids = parseJsonField(msg.target_waiter_ids)
+ if (ids.length === 0) return 'Όλοι'
+ return ids.map(id => {
+ const w = waiters.find(w => w.id === id)
+ return w ? (w.nickname || w.full_name || w.username) : `#${id}`
+ }).join(', ')
+ }
+
+ return (
+ <>
+ {composing && (
+
setComposing(false)}
+ onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
+ />
+ )}
+
+
+
Μηνύματα
+
setComposing(true)}
+ style={{
+ height: 28, padding: '0 12px', borderRadius: 8,
+ border: '1px solid #dfe2e6', background: 'white',
+ color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ }}
+ >+ Νέο
+
+
+ {messages.length === 0 ? (
+
+
💬
+ Δεν υπάρχουν μηνύματα ακόμα
+
+ ) : (
+
+ {messages.map(msg => {
+ const ackedIds = parseJsonField(msg.acked_by).length
+ const totalTargets = parseJsonField(msg.target_waiter_ids).length || waiters.length
+ return (
+
+
+
{msg.body}
+
+ Προς: {fmtTargets(msg)} · {new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
+
+
+
= totalTargets ? '#16a34a' : '#f59e0b',
+ background: ackedIds >= totalTargets ? '#f0fdf4' : '#fffbeb',
+ border: `1px solid ${ackedIds >= totalTargets ? '#bbf7d0' : '#fde68a'}`,
+ borderRadius: 999, padding: '2px 8px', whiteSpace: 'nowrap', flexShrink: 0,
+ }}>
+ {ackedIds}/{totalTargets} ✓
+
+
+ )
+ })}
+
+ )}
+
+ >
+ )
+}
+
+// ─── Pending prints panel ──────────────────────────────────────────────────────
+
+function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onViewOrder, retryingId }) {
+ if (pendingPrintOrders.length === 0) return null
+ return (
+
+
+
+
⏳
+
+
Εκκρεμείς Εκτυπώσεις
+
+ {pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί
+
+
+
+
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
+
+ {pendingPrintOrders.map(({ table, order }) => {
+ const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
+ const tableName = table.label || `T${table.number}`
+ return (
+
+
{tableName}
+
+
+ {unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
+
+
+ {unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
+
+
+
+ onViewOrder(order.id)} className="btn btn-secondary text-xs">Λεπτομέρειες
+ onRetrySingle(order.id)} disabled={retryingId === order.id}
+ style={{
+ height: 28, padding: '0 10px', borderRadius: 6,
+ background: '#c2410c', border: 'none', color: 'white',
+ fontSize: 11, fontWeight: 600, cursor: 'pointer',
+ }}
+ >{retryingId === order.id ? '…' : 'Εκτύπωση'}
+
+
+ )
+ })}
+
+ )
+}
+
+// ─── Main page ─────────────────────────────────────────────────────────────────
+
+export default function OperationsPage() {
+ const [showStartShift, setShowStartShift] = useState(false)
+ const [closeDetails, setCloseDetails] = useState(null)
+ const [forceClosing, setForceClosing] = useState(false)
+ const [retryingId, setRetryingId] = useState(null)
+ const [quickView, setQuickView] = useState(null)
+ // End shift flow: confirm modal → summary modal
+ const [endShiftTarget, setEndShiftTarget] = useState(null) // shift object to confirm
+ const [endShiftBusy, setEndShiftBusy] = useState(false)
+ const [shiftSummaryId, setShiftSummaryId] = useState(null) // show summary for this shift id
+ // Quick message to single waiter
+ const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+
+ const { data: businessDay } = useQuery({
+ queryKey: ['business-day'],
+ queryFn: () => client.get('/api/business-day/current').then(r => r.data),
+ refetchInterval: 10_000,
+ })
+
+ const { data: activeShifts = [] } = useQuery({
+ queryKey: ['active-shifts'],
+ queryFn: () => client.get('/api/shifts/?active_only=true').then(r => r.data.shifts ?? []),
+ refetchInterval: 10_000,
+ })
+
+ const { data: allWaiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const { data: quickTemplates = [] } = useQuery({
+ queryKey: ['quick-templates'],
+ queryFn: () => client.get('/api/messages/templates').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const { data: tables = [], isLoading: tablesLoading } = useQuery({
+ queryKey: ['tables'],
+ queryFn: () => client.get('/api/tables/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const { data: orders = [], isLoading: ordersLoading } = useQuery({
+ queryKey: ['orders-active'],
+ queryFn: () => client.get('/api/orders/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const openDayMut = useMutation({
+ mutationFn: () => client.post('/api/business-day/open', {}),
+ onSuccess: () => { toast.success('Ημέρα ανοίχτηκε!'); qc.invalidateQueries({ queryKey: ['business-day'] }) },
+ onError: (e) => toast.error(e.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ async function handleCloseDay(force = false) {
+ setForceClosing(force)
+ try {
+ await client.post('/api/business-day/close', { force })
+ toast.success('Ημέρα έκλεισε!')
+ setCloseDetails(null)
+ qc.invalidateQueries({ queryKey: ['business-day'] })
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ qc.invalidateQueries({ queryKey: ['orders-active'] })
+ qc.invalidateQueries({ queryKey: ['tables'] })
+ qc.invalidateQueries({ queryKey: ['messages-all'] })
+ } catch (e) {
+ const detail = e.response?.data?.detail
+ if (e.response?.status === 409 && detail?.open_orders) setCloseDetails(detail)
+ else toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
+ } finally {
+ setForceClosing(false)
+ }
+ }
+
+ async function handleEndShiftConfirm() {
+ if (!endShiftTarget) return
+ setEndShiftBusy(true)
+ try {
+ await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {})
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ const summaryId = endShiftTarget.id
+ setEndShiftTarget(null)
+ setShiftSummaryId(summaryId)
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Σφάλμα')
+ } finally {
+ setEndShiftBusy(false)
+ }
+ }
+
+ async function handleStartShift(waiterId, startingCash) {
+ await client.post('/api/shifts/manager/start', { waiter_id: waiterId, starting_cash: startingCash })
+ toast.success('Βάρδια ξεκίνησε!')
+ qc.invalidateQueries({ queryKey: ['active-shifts'] })
+ }
+
+ async function retrySingleOrder(orderId) {
+ setRetryingId(orderId)
+ try {
+ const res = await client.post(`/api/orders/${orderId}/retry-print`)
+ const results = res.data.print_results ?? []
+ const allOk = results.length === 0 || results.every(r => r.success)
+ if (allOk) toast.success('Εκτυπώθηκε επιτυχώς')
+ else {
+ const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
+ toast.error(`Αποτυχία: ${failed}`)
+ }
+ qc.invalidateQueries({ queryKey: ['orders-active'] })
+ } catch {
+ toast.error('Σφάλμα επικοινωνίας')
+ } finally {
+ setRetryingId(null)
+ }
+ }
+
+ async function retryAllOrders() {
+ for (const { order } of pendingPrintOrders) {
+ if (order) await retrySingleOrder(order.id)
+ }
+ }
+
+ const isOpen = !!businessDay
+
+ const waitersWithoutShift = allWaiters.filter(
+ w => w.role === 'waiter' && !activeShifts.some(s => s.waiter_id === w.id)
+ )
+
+ // Build table cards
+ const tableCards = tables.map(table => {
+ const order = orders.find(o =>
+ o.table_id === table.id && ['open', 'partially_paid', 'paid'].includes(o.status)
+ )
+ const tableStatus = order ? order.status : 'free'
+ const hasPendingPrint = order ? order.items.some(i => i.status === 'active' && !i.printed) : false
+ return { table, order, tableStatus, hasPendingPrint }
+ })
+
+ const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
+
+ // KPIs — revenue scoped to orders that opened after business day start
+ const businessDayStart = businessDay?.opened_at ? new Date(businessDay.opened_at) : null
+ const dayOrders = businessDayStart
+ ? orders.filter(o => new Date(o.opened_at) >= businessDayStart)
+ : orders
+
+ const totalRevenue = dayOrders.reduce((s, o) => s + orderTotal(o.items), 0)
+ const openCount = tableCards.filter(c => c.tableStatus === 'open').length
+ const partialCount = tableCards.filter(c => c.tableStatus === 'partially_paid').length
+ const occupiedCount = openCount + partialCount
+ const freeCount = tableCards.filter(c => c.tableStatus === 'free').length
+
+ // Avg ticket: revenue divided by number of orders with at least one paid item
+ const paidOrders = dayOrders.filter(o => o.items.some(i => i.paid_by))
+ const avgTicket = paidOrders.length > 0 ? totalRevenue / paidOrders.length : 0
+
+ // Total collected by all active shifts
+ const totalCollected = activeShifts.reduce((s, sh) => s + (sh.total_collected || 0), 0)
+
+ const todayStr = new Date().toLocaleDateString('el-GR', { weekday: 'long', day: 'numeric', month: 'long' })
+
+ if (tablesLoading || ordersLoading) {
+ return Φόρτωση…
+ }
+
+ return (
+
+
+ {/* ── Header bar ─────────────────────────────────────────────────────── */}
+
+
+
+
Διοίκηση
+
+
+ {isOpen ? 'Ημέρα Ανοιχτή' : 'Ημέρα Κλειστή'}
+
+
+
+ {todayStr}
+ {isOpen && businessDay?.opened_at && ` · από ${fmtTime(businessDay.opened_at)} · ${fmtDuration(businessDay.opened_at)}`}
+
+
+
+ {isOpen ? (
+ handleCloseDay(false)} style={{
+ height: 38, padding: '0 18px', borderRadius: 10,
+ background: '#dc2626', border: 'none', color: 'white',
+ fontSize: 13, fontWeight: 700, cursor: 'pointer',
+ }}>Κλείσιμο Ημέρας
+ ) : (
+ openDayMut.mutate()} disabled={openDayMut.isPending} style={{
+ height: 38, padding: '0 18px', borderRadius: 10,
+ background: '#16a34a', border: 'none', color: 'white',
+ fontSize: 13, fontWeight: 700, cursor: 'pointer',
+ }}>{openDayMut.isPending ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
+ )}
+
+
+
+ {/* ── KPI strip — full-width flex row ────────────────────────────────── */}
+
+
+ 0 ? (occupiedCount / tables.length) * 100 : 0}
+ />
+ 0 ? activeShifts.map(s => s.waiter_name.split(' ')[0]).join(', ') : 'Κανένας σε βάρδια'}
+ accent="#2f9e5e"
+ />
+
+ {pendingPrintOrders.length > 0 && (
+
+ )}
+
+
+ {/* ── Main 2-column layout: 65 / 35 ──────────────────────────────────── */}
+
+
+ {/* ── Left column — Shifts + Tables + Revenue + Pending prints ───────── */}
+
+
+ {/* Shifts card — FIRST */}
+
setShowStartShift(true)}
+ onEndShift={(shift) => setEndShiftTarget(shift)}
+ onMessageWaiter={(s) => setMessageWaiter({ id: s.waiter_id, name: s.waiter_name })}
+ />
+
+ {/* Tables overview — SECOND */}
+
+
+
Τραπέζια τώρα
+
navigate('/tables')}
+ style={{
+ height: 28, padding: '0 12px', borderRadius: 8,
+ border: '1px solid #dfe2e6', background: 'white',
+ color: '#2b2f33', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ }}
+ >Πλήρης εικόνα →
+
+
+
+ {tableCards.map(({ table, tableStatus, order }) => (
+
setQuickView({ orderId: order.id, tableName: table.label || `T${table.number}` }) : undefined}
+ />
+ ))}
+
+
+ {Object.entries(TABLE_COLORS).map(([key, c]) => (
+
+
+ {c.label}
+
+ {tableCards.filter(tc => tc.tableStatus === key).length}
+
+
+ ))}
+
+
+
+
+ {/* Revenue chart */}
+
+
+ {/* Pending prints */}
+ setQuickView({ orderId: id, tableName: tables.find(t => tableCards.find(tc => tc.order?.id === id)?.table.id === t.id)?.label || '—' })}
+ retryingId={retryingId}
+ />
+
+
+ {/* ── Right column — Reservations + Messages ───────────────────────── */}
+
+
+
+
+
+
+ {/* ── Modals ─────────────────────────────────────────────────────────── */}
+ {showStartShift && (
+
setShowStartShift(false)}
+ onStart={handleStartShift}
+ />
+ )}
+
+ {endShiftTarget && (
+ setEndShiftTarget(null)}
+ onConfirm={handleEndShiftConfirm}
+ busy={endShiftBusy}
+ />
+ )}
+
+ {shiftSummaryId && (
+ setShiftSummaryId(null)}
+ />
+ )}
+
+ {messageWaiter && (
+ setMessageWaiter(null)}
+ onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
+ />
+ )}
+ {closeDetails && (
+ setCloseDetails(null)}
+ onConfirm={() => handleCloseDay(true)}
+ busy={forceClosing}
+ />
+ )}
+ {quickView && (
+ setQuickView(null)}
+ onOpenFull={() => { navigate(`/orders/${quickView.orderId}`); setQuickView(null) }}
+ />
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/OrderDetailPage.jsx b/manager_dashboard/src/pages/OrderDetailPage.jsx
index f5d5685..44718ff 100644
--- a/manager_dashboard/src/pages/OrderDetailPage.jsx
+++ b/manager_dashboard/src/pages/OrderDetailPage.jsx
@@ -122,7 +122,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
onError: () => toast.error('Σφάλμα εκτύπωσης'),
})
- const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username]))
+ const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.nickname || w.full_name || w.username]))
const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
const invalidate = () => {
@@ -138,13 +138,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const cancelOrder = useMutation({
mutationFn: () => client.delete(`/api/orders/${orderId}`),
- onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/dashboard') },
+ onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/tables') },
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
})
const closeOrder = useMutation({
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
- onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/dashboard') },
+ onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/tables') },
onError: () => toast.error('Σφάλμα κλεισίματος'),
})
@@ -222,7 +222,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{tab === 'overview' && <>
{/* Waiters */}
-
Σερβιτόροι
+
Προσωπικό
{order.waiters.map(w => (
@@ -239,13 +239,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
))}
{isOpen && !readOnly && (
{ if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
+ Πρόσθεσε
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
- {w.username}
+ {w.nickname || w.full_name || w.username}
))}
)}
diff --git a/manager_dashboard/src/pages/ProductsPage.jsx b/manager_dashboard/src/pages/ProductsTab.jsx
similarity index 51%
rename from manager_dashboard/src/pages/ProductsPage.jsx
rename to manager_dashboard/src/pages/ProductsTab.jsx
index 6fb0143..fc430a1 100644
--- a/manager_dashboard/src/pages/ProductsPage.jsx
+++ b/manager_dashboard/src/pages/ProductsTab.jsx
@@ -5,8 +5,8 @@ import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
const EMPTY_PRODUCT = {
- name: '', category_id: '', base_price: '', is_available: true,
- printer_zone_id: '', options: [], ingredients: [], preference_sets: [],
+ name: '', category_id: '', base_price: '', is_available: true, lifecycle_status: 'active',
+ printer_zone_id: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
}
const COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
@@ -24,6 +24,82 @@ function ColorPicker({ value, onChange }) {
)
}
+function IconBase({ className = '', viewBox, strokeWidth = '1.5', children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function AddIcon({ className = '' }) {
+ return (
+
+
+
+
+
+ )
+}
+
+function MoveUpIcon({ className = '' }) {
+ return (
+
+
+
+
+ )
+}
+
+function MoveDownIcon({ className = '' }) {
+ return (
+
+
+
+
+ )
+}
+
+function EditIcon({ className = '' }) {
+ return (
+
+
+
+ )
+}
+
+function DeleteIcon({ className = '' }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+function HeartIcon({ filled, className = '' }) {
+ return (
+
+
+
+ )
+}
+
function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
return (
@@ -35,7 +111,6 @@ function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
)
}
-// Default-toggle button: filled circle = default, empty circle = not default
function DefaultBtn({ isDefault, onClick, title }) {
return (
+
+
+ )
+}
+
function PriceInput({ value, onChange, placeholder, className = '', allowNegative = false }) {
const step = 0.10
const num = parseFloat(value) || 0
@@ -86,7 +177,6 @@ function moveItem(arr, i, dir) {
return next
}
-// ─── Sub-choice rows (shared between Options and Preferences) ─────────────────
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
if (!subChoices || subChoices.length === 0) return null
return (
@@ -115,6 +205,15 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
)
}
+// Build the ordered list of sub-category-like rows for a parent:
+// interleaves the "General" virtual row at its general_sort_order position
+function buildSubList(parent, subcategories) {
+ const subs = [...subcategories].sort((a, b) => a.sort_order - b.sort_order)
+ const generalRow = { _isGeneral: true, sort_order: parent.general_sort_order }
+ const all = [...subs, generalRow].sort((a, b) => a.sort_order - b.sort_order)
+ return all
+}
+
export default function ProductsPage() {
const qc = useQueryClient()
const [selectedCat, setSelectedCat] = useState(null)
@@ -122,6 +221,7 @@ export default function ProductsPage() {
const [editCat, setEditCat] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null)
const [showInactive, setShowInactive] = useState(false)
+ const [showArchived, setShowArchived] = useState(false)
const [multiSelect, setMultiSelect] = useState(false)
const [selected, setSelected] = useState(new Set())
const [sortMode, setSortMode] = useState('custom')
@@ -141,8 +241,26 @@ export default function ProductsPage() {
})
const printers = statusData?.printers ?? []
- const filteredByCat = selectedCat ? allProducts.filter(p => p.category_id === selectedCat) : allProducts
- const baseList = showInactive ? filteredByCat : filteredByCat.filter(p => p.is_available)
+ // selectedCat can be:
+ // null → all products
+ // number → specific category (if top-level: includes all sub-cats too)
+ // '__general_
' → only direct products of that top-level category (no sub-cat)
+ const isGeneralSel = typeof selectedCat === 'string' && selectedCat.startsWith('__general_')
+ const generalParentId = isGeneralSel ? Number(selectedCat.replace('__general_', '')) : null
+ const selectedCatObj = isGeneralSel ? null : categories.find(c => c.id === selectedCat)
+ const visibleCatIds = isGeneralSel
+ ? [generalParentId] // only direct products on the parent
+ : selectedCat
+ ? selectedCatObj?.parent_id == null
+ ? [selectedCat, ...categories.filter(c => c.parent_id === selectedCat).map(c => c.id)]
+ : [selectedCat]
+ : null
+ const filteredByCat = visibleCatIds ? allProducts.filter(p => visibleCatIds.includes(p.category_id)) : allProducts
+ const baseList = filteredByCat.filter(p => {
+ if (p.lifecycle_status === 'archived') return showArchived
+ if (!p.is_available) return showInactive
+ return true
+ })
const products = [...baseList].sort((a, b) => {
if (sortMode === 'name') return a.name.localeCompare(b.name, 'el')
if (sortMode === 'price') return a.base_price - b.base_price
@@ -168,6 +286,18 @@ export default function ProductsPage() {
mutationFn: items => client.put('/api/products/categories/reorder', items),
onSuccess: () => invalidate(),
})
+ const reorderSubcats = useMutation({
+ mutationFn: items => client.put('/api/products/categories/reorder-subcategories', items),
+ onSuccess: () => invalidate(),
+ })
+ const reorderGeneral = useMutation({
+ mutationFn: items => client.put('/api/products/categories/reorder-general', items),
+ onSuccess: () => invalidate(),
+ })
+ const toggleAutoExpanded = useMutation({
+ mutationFn: ({ id, auto_expanded }) => client.put(`/api/products/categories/${id}`, { auto_expanded }),
+ onSuccess: () => invalidate(),
+ })
const saveProduct = useMutation({
mutationFn: b => editProduct?.id ? client.put(`/api/products/${editProduct.id}`, b) : client.post('/api/products/', b),
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
@@ -178,9 +308,9 @@ export default function ProductsPage() {
onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'),
})
- const deactivateProduct = useMutation({
+ const archiveProduct = useMutation({
mutationFn: id => client.delete(`/api/products/${id}`),
- onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDelete(null); invalidate() },
+ onSuccess: () => { toast.success('Αρχειοθετήθηκε'); setConfirmDelete(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const hardDeleteProduct = useMutation({
@@ -204,7 +334,7 @@ export default function ProductsPage() {
}
function moveCat(cat, dir) {
- const sorted = [...categories].sort((a, b) => a.sort_order - b.sort_order)
+ const sorted = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const idx = sorted.findIndex(c => c.id === cat.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
@@ -216,6 +346,41 @@ export default function ProductsPage() {
reorderCats.mutate(updates)
}
+ // Shared helper: move any row (sub-cat or General) in the mixed sub-list.
+ // Normalises the whole list to 0-based sequential indices, swaps the two positions,
+ // then writes all updated values. This avoids colliding sort_order values.
+ function _moveInSubList(parent, itemIdx, dir) {
+ const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
+ const subList = buildSubList(parent, subs)
+ const swapIdx = itemIdx + dir
+ if (itemIdx < 0 || swapIdx < 0 || swapIdx >= subList.length) return
+ // Normalise to clean sequential indices
+ const normalised = subList.map((row, i) => ({ ...row, _normOrder: i }))
+ ;[normalised[itemIdx]._normOrder, normalised[swapIdx]._normOrder] =
+ [normalised[swapIdx]._normOrder, normalised[itemIdx]._normOrder]
+ // Write subcats and general separately
+ const subcatUpdates = normalised
+ .filter(r => !r._isGeneral)
+ .map(r => ({ id: r.id, sort_order: r._normOrder }))
+ const generalRow = normalised.find(r => r._isGeneral)
+ if (subcatUpdates.length) reorderSubcats.mutate(subcatUpdates)
+ if (generalRow) reorderGeneral.mutate([{ id: parent.id, general_sort_order: generalRow._normOrder }])
+ }
+
+ function moveSubcat(parent, subcat, dir) {
+ const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
+ const subList = buildSubList(parent, subs)
+ const idx = subList.findIndex(r => !r._isGeneral && r.id === subcat.id)
+ _moveInSubList(parent, idx, dir)
+ }
+
+ function moveGeneral(parent, dir) {
+ const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
+ const subList = buildSubList(parent, subs)
+ const idx = subList.findIndex(r => r._isGeneral)
+ _moveInSubList(parent, idx, dir)
+ }
+
function toggleSelect(id) {
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
}
@@ -229,62 +394,180 @@ export default function ProductsPage() {
try {
if (action === 'available') { await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { is_available: true }))); toast.success(`${ids.length} διαθέσιμα`) }
else if (action === 'unavailable') { await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { is_available: false }))); toast.success(`${ids.length} μη διαθέσιμα`) }
- else if (action === 'delete') { setConfirmDelete({ type: 'bulk-hard', ids }); return }
+ else if (action === 'archive') { setConfirmDelete({ type: 'bulk-archive', ids }); return }
invalidate(); exitMultiSelect()
} catch { toast.error('Σφάλμα') }
}
- async function confirmBulkDelete(ids) {
- const results = await Promise.allSettled(ids.map(id => client.delete(`/api/products/${id}?hard=true`)))
+ async function confirmBulkArchive(ids) {
+ const results = await Promise.allSettled(ids.map(id => client.delete(`/api/products/${id}`)))
const ok = results.filter(r => r.status === 'fulfilled').length
const fail = results.filter(r => r.status === 'rejected').length
- if (ok) toast.success(`${ok} διαγράφηκαν`)
- if (fail) toast.error(`${fail} δεν διαγράφηκαν (υπάρχουν σε παραγγελίες)`)
+ if (ok) toast.success(`${ok} αρχειοθετήθηκαν ή διαγράφηκαν`)
+ if (fail) toast.error(`${fail} απέτυχαν`)
setConfirmDelete(null); invalidate(); exitMultiSelect()
}
function handleConfirmDelete() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
+ if (confirmDelete.type === 'product-archive') archiveProduct.mutate(confirmDelete.id)
if (confirmDelete.type === 'product-hard') hardDeleteProduct.mutate(confirmDelete.id)
- if (confirmDelete.type === 'bulk-hard') confirmBulkDelete(confirmDelete.ids)
+ if (confirmDelete.type === 'bulk-archive') confirmBulkArchive(confirmDelete.ids)
}
const printerName = id => printers.find(p => p.id === id)?.name ?? `#${id}`
+ const categoryIconClass = 'w-4 h-4'
+ const categoryActionBtnClass = 'p-1 rounded-md hover:bg-black/5 disabled:opacity-30 disabled:cursor-not-allowed'
+ const categoryMoveBtnClass = `${categoryActionBtnClass} text-primary-700 hover:text-primary-800`
+ const categoryEditBtnClass = `${categoryActionBtnClass} text-orange-500 hover:text-orange-600`
+ const categoryDeleteBtnClass = `${categoryActionBtnClass} text-red-600 hover:text-red-700`
+
+ const topLevelCats = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
return (
{/* Left: Categories */}
-
+
Κατηγορίες
-
setEditCat({})} className="btn btn-primary text-xs px-2 py-1 min-h-0 h-8">+
+
setEditCat({ _isNew: true })}
+ className="btn btn-primary px-2 py-1 min-h-0 h-8 text-white"
+ title="Προσθήκη κατηγορίας"
+ aria-label="Προσθήκη κατηγορίας"
+ >
+
+
setSelectedCat(null)}
className={`w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${!selectedCat ? 'bg-primary-700 text-white' : 'hover:bg-gray-100 text-gray-700'}`}>
Όλα
- {[...categories].sort((a, b) => a.sort_order - b.sort_order).map((cat, idx, arr) => (
-
- {cat.color && }
- setSelectedCat(cat.id)} className="flex-1 text-left px-2 py-2.5 text-sm font-medium truncate">{cat.name}
- moveCat(cat, -1)} disabled={idx === 0} className="p-1 text-xs disabled:opacity-30">↑
- moveCat(cat, 1)} disabled={idx === arr.length - 1} className="p-1 text-xs disabled:opacity-30">↓
- setEditCat(cat)} className="p-1 text-xs">✏️
- setConfirmDelete({ type: 'category', id: cat.id })} className="p-1 text-xs mr-1">🗑
-
- ))}
+
+ {topLevelCats.map((cat, idx) => {
+ const subs = categories.filter(c => c.parent_id === cat.id).sort((a, b) => a.sort_order - b.sort_order)
+ const subList = buildSubList(cat, subs)
+ const isParentActive = selectedCat === cat.id
+ return (
+
+ {/* Top-level category row */}
+
+ {cat.color &&
}
+
setSelectedCat(cat.id)} className="flex-1 text-left px-2 py-2.5 text-sm font-medium truncate">{cat.name}
+ {/* Add sub-category button */}
+
setEditCat({ _isNew: true, parent_id: cat.id })}
+ className={`${categoryActionBtnClass} text-green-600 hover:text-green-700`}
+ title="Προσθήκη υποκατηγορίας"
+ >
+
+
+
moveCat(cat, -1)} disabled={idx === 0} className={categoryMoveBtnClass} title="Μετακίνηση πάνω">
+
+
+
moveCat(cat, 1)} disabled={idx === topLevelCats.length - 1} className={categoryMoveBtnClass} title="Μετακίνηση κάτω">
+
+
+
setEditCat(cat)} className={categoryEditBtnClass} title="Επεξεργασία">
+
+
+
setConfirmDelete({ type: 'category', id: cat.id })} className={`${categoryDeleteBtnClass} mr-1`} title="Διαγραφή">
+
+
+
+
+ {/* Sub-category rows (indented), interleaved with General group */}
+ {subList.map((row, subIdx) => {
+ if (row._isGeneral) {
+ // Virtual "General" row — products directly on the parent
+ const isGeneralActive = selectedCat === `__general_${cat.id}`
+ return (
+
+
+ setSelectedCat(`__general_${cat.id}`)}
+ className={`flex-1 text-left px-2 py-2 text-xs italic truncate ${selectedCat === `__general_${cat.id}` ? 'font-semibold' : 'text-gray-400'}`}
+ >Γενικά
+ moveGeneral(cat, -1)} disabled={subIdx === 0} className={`${categoryMoveBtnClass} opacity-60`} title="Μετακίνηση Γενικά πάνω">
+
+
+ moveGeneral(cat, 1)} disabled={subIdx === subList.length - 1} className={`${categoryMoveBtnClass} opacity-60 mr-1`} title="Μετακίνηση Γενικά κάτω">
+
+
+
+ )
+ }
+ const isSubActive = selectedCat === row.id
+ return (
+
+ {row.color
+ ?
+ :
+ }
+ setSelectedCat(row.id)} className="flex-1 text-left px-2 py-2 text-xs font-medium truncate">{row.name}
+ {/* Auto-expanded toggle */}
+ toggleAutoExpanded.mutate({ id: row.id, auto_expanded: !row.auto_expanded })}
+ title={row.auto_expanded ? 'Auto-expanded: ON — κλικ για απενεργοποίηση' : 'Auto-expanded: OFF — κλικ για ενεργοποίηση'}
+ className={`px-1.5 py-0.5 rounded text-xs font-bold shrink-0 transition-colors ${
+ row.auto_expanded
+ ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
+ : 'bg-gray-100 text-gray-400 hover:bg-gray-200'
+ }`}
+ >
+ ↕
+
+ moveSubcat(cat, row, -1)} disabled={subIdx === 0} className={categoryMoveBtnClass} title="Μετακίνηση πάνω">
+
+
+ moveSubcat(cat, row, 1)} disabled={subIdx === subList.length - 1} className={categoryMoveBtnClass} title="Μετακίνηση κάτω">
+
+
+ setEditCat(row)} className={categoryEditBtnClass} title="Επεξεργασία">
+
+
+ setConfirmDelete({ type: 'category', id: row.id })} className={`${categoryDeleteBtnClass} mr-1`} title="Διαγραφή">
+
+
+
+ )
+ })}
+
+ )
+ })}
{/* Right: Products */}
- Προϊόντα {selectedCat ? `— ${categories.find(c => c.id === selectedCat)?.name}` : ''}
+ {isGeneralSel
+ ? (() => {
+ const parent = categories.find(c => c.id === generalParentId)
+ return `Προϊόντα — ${parent?.name ?? '?'} / Γενικά`
+ })()
+ : selectedCat
+ ? (() => {
+ const cat = categories.find(c => c.id === selectedCat)
+ if (!cat) return 'Προϊόντα'
+ if (cat.parent_id) {
+ const parent = categories.find(c => c.id === cat.parent_id)
+ return `Προϊόντα — ${parent?.name ?? '?'} / ${cat.name}`
+ }
+ return `Προϊόντα — ${cat.name}`
+ })()
+ : 'Προϊόντα'
+ }
setShowInactive(e.target.checked)} className="accent-primary-700" />
- Εμφάνιση ανενεργών
+ Ανενεργά
+
+
+ setShowArchived(e.target.checked)} className="accent-primary-700" />
+ Αρχειοθετημένα
setSortMode(e.target.value)} className="input text-sm py-1 h-9 min-h-0 w-auto pr-8">
Σειρά: Προσαρμοσμένη
@@ -299,7 +582,7 @@ export default function ProductsPage() {
Καθαρισμός
bulkAction('available')} disabled={!selected.size} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8 text-green-600">Διαθέσιμο
bulkAction('unavailable')} disabled={!selected.size} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8 text-amber-600">Μη διαθέσιμο
- bulkAction('delete')} disabled={!selected.size} className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8">Διαγραφή
+ bulkAction('archive')} disabled={!selected.size} className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8">Αρχειοθέτηση
Ακύρωση
)}
@@ -309,61 +592,100 @@ export default function ProductsPage() {
{products.length === 0 &&
Δεν υπάρχουν προϊόντα.
}
- {products.map((p, idx) => (
-
toggleSelect(p.id) : undefined}
- className={`card p-4 flex items-center gap-3 transition-opacity ${!p.is_available ? 'opacity-60' : ''} ${multiSelect ? 'cursor-pointer select-none' : ''} ${multiSelect && selected.has(p.id) ? 'ring-2 ring-primary-500 bg-primary-50' : ''}`}
- >
- {multiSelect && (
-
toggleSelect(p.id)}
- onClick={e => e.stopPropagation()} className="w-4 h-4 accent-primary-700 shrink-0" />
- )}
- {p.image_url && (
-
- )}
-
-
- {p.name}
- {!p.is_available && (ανενεργό) }
-
-
- {categories.find(c => c.id === p.category_id)?.name ?? '—'} · €{p.base_price.toFixed(2)}
- {p.printer_zone_id && ` · ${printerName(p.printer_zone_id)}`}
-
+ {products.map((p, idx) => {
+ const isArchived = p.lifecycle_status === 'archived'
+ return (
+
toggleSelect(p.id) : undefined}
+ className={`card p-4 flex items-center gap-3 transition-opacity ${(!p.is_available || isArchived) ? 'opacity-60' : ''} ${multiSelect ? 'cursor-pointer select-none' : ''} ${multiSelect && selected.has(p.id) ? 'ring-2 ring-primary-500 bg-primary-50' : ''}`}
+ >
+ {multiSelect && (
+
toggleSelect(p.id)}
+ onClick={e => e.stopPropagation()} className="w-4 h-4 accent-primary-700 shrink-0" />
+ )}
+ {p.image_url && (
+
+ )}
+
+
+ {p.name}
+ {isArchived && (
+ Αρχείο
+ )}
+ {!isArchived && !p.is_available && (
+ Ανενεργό
+ )}
+
+
+ {(() => {
+ const cat = categories.find(c => c.id === p.category_id)
+ if (!cat) return '—'
+ if (cat.parent_id) {
+ const parent = categories.find(c => c.id === cat.parent_id)
+ return `${parent?.name ?? '?'} / ${cat.name}`
+ }
+ return cat.name
+ })()} · €{p.base_price.toFixed(2)}
+ {p.printer_zone_id && ` · ${printerName(p.printer_zone_id)}`}
+
+
+ {!multiSelect && (
+ <>
+ {/* Availability toggle — only for non-archived products */}
+ {!isArchived && (
+
{ e.stopPropagation(); toggleAvail.mutate({ id: p.id, is_available: !p.is_available }) }}
+ className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors shrink-0 ${
+ p.is_available
+ ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
+ : 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
+ }`}
+ >
+
+ {p.is_available ? 'Διαθέσιμο' : 'Ανενεργό'}
+
+ )}
+
setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Επεξεργασία
+ {/* Archive/delete button — context-sensitive */}
+ {isArchived ? (
+
setConfirmDelete({ type: 'product-hard', id: p.id })}
+ className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0"
+ title="Οριστική διαγραφή"
+ >
+ Διαγραφή
+
+ ) : (
+
setConfirmDelete({ type: 'product-archive', id: p.id })}
+ className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0"
+ title="Αρχειοθέτηση (διατηρείται για ιστορικό)"
+ >
+ Αρχειοθέτηση
+
+ )}
+ {sortMode === 'custom' && !isArchived && (
+
+ moveProd(p, -1)} disabled={idx === 0} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▲
+ moveProd(p, 1)} disabled={idx === products.length - 1} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▼
+
+ )}
+ >
+ )}
- {!multiSelect && (
- <>
- {/* Availability toggle button — green when available, grey when not */}
-
{ e.stopPropagation(); toggleAvail.mutate({ id: p.id, is_available: !p.is_available }) }}
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors shrink-0 ${
- p.is_available
- ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
- : 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
- }`}
- >
-
- {p.is_available ? 'Διαθέσιμο' : 'Ανενεργό'}
-
-
setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Επεξεργασία
-
setConfirmDelete({ type: 'product-hard', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Διαγραφή
- {/* Reorder arrows — far right */}
- {sortMode === 'custom' && (
-
- moveProd(p, -1)} disabled={idx === 0} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▲
- moveProd(p, 1)} disabled={idx === products.length - 1} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▼
-
- )}
- >
- )}
-
- ))}
+ )
+ })}
{editCat !== null && (
-
saveCat.mutate({ name, color })} onClose={() => setEditCat(null)} />
+ c.id === editCat.parent_id)?.name : null}
+ onSave={(name, color) => saveCat.mutate({ name, color, parent_id: editCat.parent_id ?? null })}
+ onClose={() => setEditCat(null)}
+ />
)}
{editProduct !== null && (
setConfirmDelete(null)}
@@ -391,13 +717,21 @@ export default function ProductsPage() {
)
}
-function CategoryFormModal({ cat, onSave, onClose }) {
+function CategoryFormModal({ cat, parentName, onSave, onClose }) {
const [name, setName] = useState(cat.name || '')
const [color, setColor] = useState(cat.color || '')
+ const isNew = !cat.id
+ const isSub = !!cat.parent_id
+ const title = isNew
+ ? isSub ? `Νέα υποκατηγορία${parentName ? ` σε «${parentName}»` : ''}` : 'Νέα κατηγορία'
+ : isSub ? 'Επεξεργασία υποκατηγορίας' : 'Επεξεργασία κατηγορίας'
return (
-
{cat.id ? 'Επεξεργασία κατηγορίας' : 'Νέα κατηγορία'}
+
{title}
+ {isSub && parentName && (
+
Κατηγορία: {parentName}
+ )}
Όνομα
setName(e.target.value)} autoFocus />
@@ -423,13 +757,30 @@ function buildFormFromProduct(product) {
category_id: product.category_id ?? '',
base_price: product.base_price ?? '',
is_available: product.is_available ?? true,
+ lifecycle_status: product.lifecycle_status ?? 'active',
printer_zone_id: product.printer_zone_id ?? '',
+ quick_options: product.quick_options?.map(q => ({
+ name: q.name,
+ price: q.price ?? 0,
+ allow_multiple: q.allow_multiple ?? false,
+ sort_order: q.sort_order ?? 0,
+ is_favorite: q.is_favorite ?? false,
+ favorite_sort_order: q.favorite_sort_order ?? 0,
+ })) ?? [],
options: product.options?.map(o => ({
name: o.name,
extra_cost: o.extra_cost ?? 0,
+ allow_multiple: o.allow_multiple ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
+ is_favorite: o.is_favorite ?? false,
+ favorite_sort_order: o.favorite_sort_order ?? 0,
+ })) ?? [],
+ ingredients: product.ingredients?.map(i => ({
+ name: i.name,
+ extra_cost: i.extra_cost ?? 0,
+ is_favorite: i.is_favorite ?? false,
+ favorite_sort_order: i.favorite_sort_order ?? 0,
})) ?? [],
- ingredients: product.ingredients?.map(i => ({ name: i.name, extra_cost: i.extra_cost ?? 0 })) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1,
@@ -443,13 +794,65 @@ function buildFormFromProduct(product) {
name: ps.shared_subset.name,
choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
} : null,
+ is_favorite: ps.is_favorite ?? false,
+ favorite_sort_order: ps.favorite_sort_order ?? 0,
})) ?? [],
}
}
+// Build a flat sorted list of all favorited items across all types
+function buildFavoritesList(form) {
+ const items = []
+ form.quick_options.forEach((q, i) => {
+ if (q.is_favorite) items.push({ type: 'quick', idx: i, favorite_sort_order: q.favorite_sort_order ?? 0 })
+ })
+ form.ingredients.forEach((ing, i) => {
+ if (ing.is_favorite) items.push({ type: 'ingredient', idx: i, favorite_sort_order: ing.favorite_sort_order ?? 0 })
+ })
+ form.options.forEach((o, i) => {
+ if (o.is_favorite) items.push({ type: 'option', idx: i, favorite_sort_order: o.favorite_sort_order ?? 0 })
+ })
+ form.preference_sets.forEach((ps, i) => {
+ if (ps.is_favorite) items.push({ type: 'pref', idx: i, favorite_sort_order: ps.favorite_sort_order ?? 0 })
+ })
+ return items.sort((a, b) => a.favorite_sort_order - b.favorite_sort_order)
+}
+
+function getFavSortField(form, type, idx) {
+ if (type === 'quick') return form.quick_options[idx]?.favorite_sort_order ?? 0
+ if (type === 'ingredient') return form.ingredients[idx]?.favorite_sort_order ?? 0
+ if (type === 'option') return form.options[idx]?.favorite_sort_order ?? 0
+ if (type === 'pref') return form.preference_sets[idx]?.favorite_sort_order ?? 0
+ return 0
+}
+
+function setFavSortField(form, type, idx, value) {
+ if (type === 'quick') return { ...form, quick_options: form.quick_options.map((q, i) => i === idx ? { ...q, favorite_sort_order: value } : q) }
+ if (type === 'ingredient') return { ...form, ingredients: form.ingredients.map((ing, i) => i === idx ? { ...ing, favorite_sort_order: value } : ing) }
+ if (type === 'option') return { ...form, options: form.options.map((o, i) => i === idx ? { ...o, favorite_sort_order: value } : o) }
+ if (type === 'pref') return { ...form, preference_sets: form.preference_sets.map((ps, i) => i === idx ? { ...ps, favorite_sort_order: value } : ps) }
+ return form
+}
+
+function getItemLabel(form, type, idx) {
+ if (type === 'quick') return form.quick_options[idx]?.name || '(χωρίς όνομα)'
+ if (type === 'ingredient') return form.ingredients[idx]?.name || '(χωρίς όνομα)'
+ if (type === 'option') return form.options[idx]?.name || '(χωρίς όνομα)'
+ if (type === 'pref') return form.preference_sets[idx]?.name || '(χωρίς όνομα)'
+ return ''
+}
+
+function getItemTypeLabel(type) {
+ if (type === 'quick') return 'Γρήγορη'
+ if (type === 'ingredient') return 'Υλικό'
+ if (type === 'option') return 'Έξτρα'
+ if (type === 'pref') return 'Προτίμηση'
+ return ''
+}
+
function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
- const [activeTab, setActiveTab] = useState('ingredients')
+ const [activeTab, setActiveTab] = useState('favorites')
const [imageFile, setImageFile] = useState(null)
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
@@ -462,14 +865,54 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
useEffect(() => {
setForm(buildFormFromProduct(product))
- setActiveTab('ingredients')
+ setActiveTab('favorites')
setImageFile(null)
}, [product.id, product.name])
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
+ // ── Favorites reorder ──
+ function moveFavorite(favList, favIdx, dir) {
+ const newList = [...favList]
+ const swapIdx = favIdx + dir
+ if (swapIdx < 0 || swapIdx >= newList.length) return
+ // Swap favorite_sort_order values
+ const aOrder = newList[favIdx].favorite_sort_order
+ const bOrder = newList[swapIdx].favorite_sort_order
+ setForm(f => {
+ let next = setFavSortField(f, newList[favIdx].type, newList[favIdx].idx, bOrder)
+ next = setFavSortField(next, newList[swapIdx].type, newList[swapIdx].idx, aOrder)
+ return next
+ })
+ }
+
+ function toggleFavorite(type, idx) {
+ setForm(f => {
+ const currentFavs = buildFavoritesList(f)
+ const isFav = (() => {
+ if (type === 'quick') return f.quick_options[idx]?.is_favorite
+ if (type === 'ingredient') return f.ingredients[idx]?.is_favorite
+ if (type === 'option') return f.options[idx]?.is_favorite
+ if (type === 'pref') return f.preference_sets[idx]?.is_favorite
+ })()
+ // Assign next available sort order when adding
+ const newSortOrder = isFav ? 0 : (currentFavs.length > 0 ? Math.max(...currentFavs.map(x => x.favorite_sort_order)) + 1 : 0)
+ if (type === 'quick') return { ...f, quick_options: f.quick_options.map((q, i) => i === idx ? { ...q, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : q) }
+ if (type === 'ingredient') return { ...f, ingredients: f.ingredients.map((ing, i) => i === idx ? { ...ing, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ing) }
+ if (type === 'option') return { ...f, options: f.options.map((o, i) => i === idx ? { ...o, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : o) }
+ if (type === 'pref') return { ...f, preference_sets: f.preference_sets.map((ps, i) => i === idx ? { ...ps, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ps) }
+ return f
+ })
+ }
+
+ // ── Quick Options ──
+ function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) }
+ function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
+ function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
+ function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
+
// ── Options ──
- function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, sub_choices: [] }] })) }
+ function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
function moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) }
@@ -505,7 +948,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
}
// ── Ingredients ──
- function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0 }] })) }
+ function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) }
@@ -513,12 +956,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
// ── Preference sets ──
function addPrefSet() {
const newIdx = form.preference_sets.length
- setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null }] }))
+ setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
setActiveTab(newIdx)
}
function removePrefSet(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) }))
- setActiveTab('ingredients')
+ setActiveTab('favorites')
}
function setPrefSetField(si, k, v) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, [k]: v } : ps) }))
@@ -589,7 +1032,6 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
)}))
}
- // Shared subset helpers
function setSharedSubsetName(si, name) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => {
if (idx !== si) return ps
@@ -632,13 +1074,30 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
is_available: form.is_available,
+ lifecycle_status: form.lifecycle_status,
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
+ quick_options: form.quick_options.map((q, i) => ({
+ name: q.name,
+ price: parseFloat(q.price) || 0,
+ allow_multiple: q.allow_multiple ?? false,
+ sort_order: i,
+ is_favorite: q.is_favorite ?? false,
+ favorite_sort_order: q.favorite_sort_order ?? 0,
+ })),
options: form.options.map(o => ({
name: o.name,
extra_cost: parseFloat(o.extra_cost) || 0,
+ allow_multiple: o.allow_multiple ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
+ is_favorite: o.is_favorite ?? false,
+ favorite_sort_order: o.favorite_sort_order ?? 0,
+ })),
+ ingredients: form.ingredients.map(i => ({
+ name: i.name,
+ extra_cost: parseFloat(i.extra_cost) || 0,
+ is_favorite: i.is_favorite ?? false,
+ favorite_sort_order: i.favorite_sort_order ?? 0,
})),
- ingredients: form.ingredients.map(i => ({ name: i.name, extra_cost: parseFloat(i.extra_cost) || 0 })),
preference_sets: form.preference_sets.map(ps => ({
name: ps.name,
default_choice_index: ps.default_choice_index >= 0 ? ps.default_choice_index : null,
@@ -652,13 +1111,19 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
disables_subset: c.disables_subset ?? false,
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
})),
+ is_favorite: ps.is_favorite ?? false,
+ favorite_sort_order: ps.favorite_sort_order ?? 0,
})),
}
}
async function submit() {
- onSave(buildBody())
- if (imageFile && product.id) {
+ if (!imageFile) {
+ onSave(buildBody())
+ return
+ }
+ if (!isNew) {
+ onSave(buildBody())
setUploading(true)
try {
const fd = new FormData(); fd.append('file', imageFile)
@@ -666,22 +1131,39 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
qc.invalidateQueries({ queryKey: ['products-all'] })
} catch { toast.error('Σφάλμα ανεβάσματος εικόνας') }
finally { setUploading(false) }
+ } else {
+ setUploading(true)
+ try {
+ const res = await client.post('/api/products/', buildBody())
+ const newId = res.data.id
+ const fd = new FormData(); fd.append('file', imageFile)
+ await client.post(`/api/products/${newId}/image`, fd)
+ qc.invalidateQueries({ queryKey: ['products-all'] })
+ onClose()
+ } catch { toast.error('Σφάλμα αποθήκευσης') }
+ finally { setUploading(false) }
}
}
const isNew = !product.id
const canSave = form.name.trim() && form.base_price
+ const favCount = buildFavoritesList(form).length
+
const tabs = [
+ { key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
+ { key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
{ key: 'ingredients', label: 'Υλικά', count: form.ingredients.length },
- { key: 'options', label: 'Επιλογές', count: form.options.length },
+ { key: 'options', label: 'Έξτρα', count: form.options.length },
...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })),
{ key: '__add_pref__', label: '+ Προτίμηση', isAdd: true },
]
+ const favList = buildFavoritesList(form)
+
return (
-
+
{/* ── Header ── */}
@@ -695,7 +1177,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
{/* LEFT: product info */}
-
+
Στοιχεία προϊόντος
@@ -712,7 +1194,13 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
Κατηγορία
setField('category_id', e.target.value)}>
— Χωρίς κατηγορία —
- {categories.map(c => {c.name} )}
+ {categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
+ const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
+ return [
+ {parent.name} ,
+ ...subs.map(s => ↳ {s.name} ),
+ ]
+ })}
@@ -724,6 +1212,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
+ {/* Availability toggle */}
setField('is_available', !form.is_available)}
@@ -737,19 +1226,26 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
- {!isNew && (
-
-
Εικόνα προϊόντος
-
- {product.image_url && (
-
- )}
-
setImageFile(e.target.files[0])} />
-
-
- )}
+ {/* Image upload */}
+
+
Εικόνα προϊόντος
+ {product.image_url && (
+
+ )}
+ {imageFile && (
+
{imageFile.name}
+ )}
+
+
+ {imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
+ setImageFile(e.target.files[0] ?? null)} />
+
+ {isNew && imageFile && (
+
Η εικόνα θα ανέβει μαζί με την αποθήκευση.
+ )}
+
{/* RIGHT: tabs */}
@@ -769,6 +1265,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
isActive ? 'border-primary-600 text-primary-700 bg-primary-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}>
+ {tab.isFavTab &&
0} className={`w-3.5 h-3.5 ${favCount > 0 ? 'text-rose-500' : 'text-gray-400'}`} />}
{tab.label}
{tab.count > 0 && (
@@ -783,6 +1280,79 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
{/* Tab content */}
+ {/* ── Favorites tab ── */}
+ {activeTab === 'favorites' && (
+
+
+ Αγαπημένα — εμφανίζονται πρώτα στον σερβιτόρο. Σημειώστε ως αγαπημένο οποιοδήποτε στοιχείο από τις άλλες καρτέλες. Εδώ μπορείτε να τα αναδιατάξετε.
+
+ {favList.length === 0 && (
+
+ Δεν υπάρχουν αγαπημένα. Χρησιμοποιήστε το σε γρήγορες επιλογές, υλικά, έξτρα ή προτιμήσεις.
+
+ )}
+
+ {favList.map((fav, fi) => {
+ const label = getItemLabel(form, fav.type, fav.idx)
+ const typeLabel = getItemTypeLabel(fav.type)
+ return (
+
+
moveFavorite(favList, fi, -1)}
+ onDown={() => moveFavorite(favList, fi, 1)}
+ disableUp={fi === 0}
+ disableDown={fi === favList.length - 1}
+ />
+
+
+
{label}
+
{typeLabel}
+
+ toggleFavorite(fav.type, fav.idx)}
+ className="text-xs text-gray-400 hover:text-red-500 transition-colors px-2 py-1 rounded hover:bg-red-50"
+ >
+ Αφαίρεση
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* ── Quick Options tab ── */}
+ {activeTab === 'quick' && (
+
+
+
Γρήγορες επιλογές — χωρίς υπο-επιλογές.
+
+ Επιλογή
+
+ {!form.quick_options.length &&
Δεν υπάρχουν γρήγορες επιλογές.
}
+
+ {form.quick_options.map((q, i) => (
+
+ ))}
+
+
+ )}
+
{/* ── Ingredients tab ── */}
{activeTab === 'ingredients' && (
@@ -793,9 +1363,10 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
{!form.ingredients.length &&
Δεν υπάρχουν υλικά.
}
{form.ingredients.map((ing, i) => (
-
+
moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
+ toggleFavorite('ingredient', i)} />
setIngredient(i, 'name', e.target.value)} />
setIngredient(i, 'extra_cost', v)}
@@ -807,24 +1378,31 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
)}
- {/* ── Options tab ── */}
+ {/* ── Extras tab ── */}
{activeTab === 'options' && (
-
Προσθέτα (πολλαπλή επιλογή). Κάθε επιλογή μπορεί να έχει δικές της υπο-επιλογές.
-
+ Επιλογή
+
Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.
+
+ Έξτρα
- {!form.options.length &&
Δεν υπάρχουν επιλογές.
}
+ {!form.options.length &&
Δεν υπάρχουν extras.
}
{form.options.map((opt, i) => (
-
-
+
+
moveOption(i, -1)} onDown={() => moveOption(i, 1)}
disableUp={i === 0} disableDown={i === form.options.length - 1} />
- toggleFavorite('option', i)} />
+ setOption(i, 'name', e.target.value)} />
setOption(i, 'extra_cost', v)}
allowNegative className="w-32" />
+
+ setOption(i, 'allow_multiple', e.target.checked)}
+ className="accent-primary-700 w-4 h-4" />
+ Πολλαπλά
+
addOptionSubChoice(i)}
className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">
+ Υπο-επιλογές
@@ -857,6 +1435,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
setPrefSetField(si, 'name', e.target.value)} autoFocus />
+ toggleFavorite('pref', si)} />
removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή
@@ -870,10 +1449,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
- toggleDefaultChoice(si, ci)}
- />
+ toggleDefaultChoice(si, ci)} />
setChoice(si, ci, 'name', e.target.value)} />
setChoice(si, ci, 'extra_cost', v)}
@@ -909,7 +1485,6 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
+ Επιλογή
- {/* Shared subset */}
@@ -939,8 +1514,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
moveSharedSubsetChoice(si, sci, -1)} onDown={() => moveSharedSubsetChoice(si, sci, 1)}
disableUp={sci === 0} disableDown={sci === ps.shared_subset.choices.length - 1} />
- setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} />
+ setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} />
setSharedSubsetChoice(si, sci, 'name', e.target.value)} />
setSharedSubsetChoice(si, sci, 'extra_cost', v)}
diff --git a/manager_dashboard/src/pages/ReportsPage.jsx b/manager_dashboard/src/pages/ReportsPage.jsx
index e3f6bc9..b2bb979 100644
--- a/manager_dashboard/src/pages/ReportsPage.jsx
+++ b/manager_dashboard/src/pages/ReportsPage.jsx
@@ -377,10 +377,13 @@ export default function ReportsPage() {
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
const TABS = [
- ['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
- ['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
- ['printers', 'Σύνοψη εκτυπωτών'],
- ['history', 'Ιστορικό παραγγελιών'],
+ ['shift', 'Πληρωμές Βάρδιας'],
+ ['shift-orders', 'Παραγγελίες Βάρδιας'],
+ ['shifts-history','Ιστορικό Βαρδιών'],
+ ['printers', 'Εκτυπωτές'],
+ ['history', 'Ιστορικό Παραγγελιών'],
+ ['product-perf', 'Απόδοση Προϊόντων'],
+ ['traffic', 'Ανάλυση Κίνησης'],
]
return (
@@ -393,10 +396,13 @@ export default function ReportsPage() {
))}
- {tab === 'shift' &&
}
- {tab === 'shift-orders' &&
}
- {tab === 'printers' &&
}
- {tab === 'history' &&
}
+ {tab === 'shift' &&
}
+ {tab === 'shift-orders' &&
}
+ {tab === 'shifts-history' &&
}
+ {tab === 'printers' &&
}
+ {tab === 'history' &&
}
+ {tab === 'product-perf' &&
}
+ {tab === 'traffic' &&
}
)
}
@@ -934,3 +940,324 @@ function HistoryTab({ filters, setFilters }) {
)
}
+
+// ── Shifts History Tab ────────────────────────────────────────────────────────
+
+function ShiftsHistoryTab() {
+ const [fromDt, setFromDt] = useState(todayStart())
+ const [toDt, setToDt] = useState(todayEnd())
+ const [waiterId, setWaiterId] = useState('')
+ const [activeOnly, setActiveOnly] = useState(false)
+
+ const { data: waiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const params = new URLSearchParams({ from: fromDt, to: toDt })
+ if (waiterId) params.set('waiter_id', waiterId)
+ if (activeOnly) params.set('active_only', 'true')
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['report-shifts', fromDt, toDt, waiterId, activeOnly],
+ queryFn: () => client.get(`/api/reports/shifts?${params}`).then(r => r.data),
+ })
+
+ const rows = data?.shifts ?? []
+ const grandTotal = rows.reduce((s, r) => s + (r.total_collected || 0), 0)
+ const grandDeliver = rows.reduce((s, r) => s + (r.net_to_deliver || 0), 0)
+
+ const csvRows = rows.map(r => ({
+ Σερβιτόρος: r.waiter_name,
+ Έναρξη: fmtDt(r.started_at),
+ Λήξη: fmtDt(r.ended_at),
+ 'Αρχικά (€)': r.starting_cash?.toFixed(2) ?? '',
+ 'Εισπράχθηκαν (€)': (r.total_collected || 0).toFixed(2),
+ 'Προς απόδοση (€)': (r.net_to_deliver || 0).toFixed(2),
+ Κατάσταση: r.is_active ? 'Ενεργή' : 'Έκλεισε',
+ }))
+
+ return (
+
+
+
+ Από
+ setFromDt(e.target.value)} />
+
+
+ Έως
+ setToDt(e.target.value)} />
+
+
+ Σερβιτόρος
+ setWaiterId(e.target.value)}>
+ Όλοι
+ {waiters.map(w => {w.full_name || w.username} )}
+
+
+
+ setActiveOnly(e.target.checked)} />
+ Μόνο ενεργές
+
+
refetch()} className={BTN_SEC}>Ανανέωση
+ {rows.length > 0 && (
+
csvDownload(csvRows, `shifts_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
+ Εξαγωγή CSV
+
+ )}
+
+
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && rows.length === 0 && (
+
Δεν βρέθηκαν βάρδιες.
+ )}
+
+ {rows.length > 0 && (
+
+
+
+
+ Σερβιτόρος
+ Έναρξη
+ Λήξη
+ Αρχικά (€)
+ Εισπράχθηκαν (€)
+ Προς Απόδοση (€)
+ Κατάσταση
+
+
+
+ {rows.map((r, i) => (
+
+ {r.waiter_name}
+ {fmtDt(r.started_at)}
+ {r.ended_at ? fmtDt(r.ended_at) : '—'}
+
+ {r.starting_cash != null ? `€${r.starting_cash.toFixed(2)}` : '—'}
+
+
+ €{(r.total_collected || 0).toFixed(2)}
+
+
+ €{(r.net_to_deliver || 0).toFixed(2)}
+
+
+ {r.is_active
+ ? ● Ενεργή
+ : Έκλεισε }
+
+
+ ))}
+
+
+
+ Σύνολο
+ €{grandTotal.toFixed(2)}
+ €{grandDeliver.toFixed(2)}
+
+
+
+
+
+ )}
+
+ )
+}
+
+// ── Product Performance Tab ───────────────────────────────────────────────────
+
+function ProductPerformanceTab() {
+ const [fromDt, setFromDt] = useState(todayStart())
+ const [toDt, setToDt] = useState(todayEnd())
+ const [sortBy, setSortBy] = useState('qty_sold') // 'qty_sold' | 'revenue'
+
+ const params = new URLSearchParams({ from: fromDt, to: toDt })
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['product-performance', fromDt, toDt],
+ queryFn: () => client.get(`/api/reports/products/performance?${params}`).then(r => r.data),
+ })
+
+ const rows = [...(data?.products ?? [])].sort((a, b) => b[sortBy] - a[sortBy])
+ const maxVal = rows.length > 0 ? rows[0][sortBy] : 1
+
+ const csvRows = rows.map((r, i) => ({
+ Κατάταξη: i + 1,
+ Προϊόν: r.product_name,
+ 'Τεμάχια': r.qty_sold,
+ 'Παραγγελίες': r.order_count,
+ 'Έσοδα (€)': r.revenue.toFixed(2),
+ }))
+
+ return (
+
+
+
+ Από
+ setFromDt(e.target.value)} />
+
+
+ Έως
+ setToDt(e.target.value)} />
+
+
+ Ταξινόμηση
+ setSortBy(e.target.value)}>
+ Τεμάχια
+ Έσοδα
+
+
+
refetch()} className={BTN_SEC}>Ανανέωση
+ {rows.length > 0 && (
+
csvDownload(csvRows, `products_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
+ Εξαγωγή CSV
+
+ )}
+
+
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && rows.length === 0 && (
+
Δεν βρέθηκαν δεδομένα.
+ )}
+
+ {rows.length > 0 && (
+
+
+
+
+ #
+ Προϊόν
+ Τεμάχια
+ Παραγγελίες
+ Έσοδα (€)
+
+
+
+
+ {rows.map((r, i) => {
+ const barPct = Math.round((r[sortBy] / maxVal) * 100)
+ return (
+
+ {i + 1}
+ {r.product_name}
+ {r.qty_sold}
+ {r.order_count}
+ €{r.revenue.toFixed(2)}
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+ )
+}
+
+// ── Traffic Analysis Tab ──────────────────────────────────────────────────────
+
+function TrafficTab() {
+ const [fromDt, setFromDt] = useState(todayStart())
+ const [toDt, setToDt] = useState(todayEnd())
+ const [view, setView] = useState('hour') // 'hour' | 'weekday'
+
+ const params = new URLSearchParams({ from: fromDt, to: toDt })
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['traffic', fromDt, toDt],
+ queryFn: () => client.get(`/api/reports/traffic?${params}`).then(r => r.data),
+ })
+
+ const hourData = data?.by_hour ?? []
+ const weekdayData = data?.by_weekday ?? []
+
+ const activeData = view === 'hour' ? hourData : weekdayData
+ const maxOrders = Math.max(...activeData.map(d => d.orders), 1)
+ const maxRevenue = Math.max(...activeData.map(d => d.revenue), 1)
+
+ function label(d) {
+ if (view === 'hour') return `${String(d.hour).padStart(2,'0')}:00`
+ return d.label
+ }
+
+ return (
+
+
+
+ Από
+ setFromDt(e.target.value)} />
+
+
+ Έως
+ setToDt(e.target.value)} />
+
+
+ setView('hour')} className={`h-10 px-3 rounded-l-lg border text-sm font-medium transition-colors ${view === 'hour' ? 'bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}>Ώρα
+ setView('weekday')} className={`h-10 px-3 rounded-r-lg border text-sm font-medium transition-colors ${view === 'weekday' ? 'bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}`}>Ημέρα
+
+
refetch()} className={BTN_SEC}>Ανανέωση
+
+
+ {isLoading &&
Φόρτωση…
}
+
+ {!isLoading && (
+
+ {/* Orders chart */}
+
+
Παραγγελίες ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}
+
+ {activeData.map((d, i) => (
+
+ ))}
+
+
+
+ {/* Revenue chart */}
+
+
Έσοδα ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}
+
+ {activeData.map((d, i) => (
+
+
{label(d)}
+
+
+
€{d.revenue.toFixed(0)}
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/Settings/SettingsPage.jsx b/manager_dashboard/src/pages/Settings/SettingsPage.jsx
new file mode 100644
index 0000000..5a0d71a
--- /dev/null
+++ b/manager_dashboard/src/pages/Settings/SettingsPage.jsx
@@ -0,0 +1,51 @@
+import { useState } from 'react'
+import AppInfoTab from './tabs/AppInfoTab'
+import ColoursTab from './tabs/ColoursTab'
+
+const TABS = [
+ { key: 'app-info', label: 'App Info' },
+ { key: 'colours', label: 'UI Personalization' },
+]
+
+export default function SettingsPage() {
+ const [activeTab, setActiveTab] = useState('app-info')
+
+ return (
+
+
Ρυθμίσεις
+
+ {/* Tab bar */}
+
+ {TABS.map(tab => (
+ setActiveTab(tab.key)}
+ style={{
+ padding: '10px 20px',
+ fontSize: 14,
+ fontWeight: 600,
+ border: 'none',
+ background: 'none',
+ cursor: 'pointer',
+ color: activeTab === tab.key ? '#3758c9' : '#6b7280',
+ borderBottom: `2px solid ${activeTab === tab.key ? '#3758c9' : 'transparent'}`,
+ marginBottom: -2,
+ borderRadius: '6px 6px 0 0',
+ transition: 'color 0.12s',
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {/* Tab content */}
+ {activeTab === 'app-info' &&
}
+ {activeTab === 'colours' &&
}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx b/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
new file mode 100644
index 0000000..3032623
--- /dev/null
+++ b/manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
@@ -0,0 +1,541 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import toast from 'react-hot-toast'
+import client from '../../../api/client'
+import useAuthStore from '../../../store/authStore'
+
+function Toggle({ checked, onChange, disabled }) {
+ return (
+
!disabled && onChange(!checked)}
+ style={{
+ width: 44, height: 24, borderRadius: 999, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
+ background: checked ? '#16a34a' : '#d1d5db',
+ position: 'relative', transition: 'background 150ms', flexShrink: 0, opacity: disabled ? 0.5 : 1,
+ }}
+ >
+
+
+ )
+}
+
+const COMMON_TIMEZONES = [
+ 'Europe/Athens', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Rome',
+ 'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Bucharest',
+ 'Europe/Helsinki', 'Europe/Istanbul', 'America/New_York', 'America/Chicago',
+ 'America/Denver', 'America/Los_Angeles', 'UTC',
+]
+
+function TimezoneSection() {
+ const qc = useQueryClient()
+ const { data: settings, isLoading } = useQuery({
+ queryKey: ['pos-settings'],
+ queryFn: () => client.get('/api/settings/').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const updateMut = useMutation({
+ mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
+ onError: () => toast.error('Σφάλμα αποθήκευσης'),
+ })
+ const currentTz = settings?.['system.timezone']?.value ?? 'Europe/Athens'
+ const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone
+ return (
+
+
+
Ζώνη Ώρας
+
+ Η ζώνη ώρας που χρησιμοποιεί το backend για χρονοσφραγίδες. Αν οι ώρες έναρξης βάρδιας εμφανίζονται λανθασμένες, ρυθμίστε αυτό να ταιριάζει με την τοπική σας ζώνη.
+
+
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && (
+
+
+ updateMut.mutate({ key: 'system.timezone', value: e.target.value })}
+ disabled={updateMut.isPending}
+ className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none flex-1 max-w-xs"
+ >
+ {COMMON_TIMEZONES.map(tz => {tz} )}
+
+ {updateMut.isPending && Αποθήκευση… }
+
+
+ Ζώνη ώρας browser: {browserTz}
+ {browserTz !== currentTz && (
+ ⚠ Διαφέρει από τη ρύθμιση backend
+ )}
+
+
+ Η αλλαγή ζώνης ώρας αποθηκεύεται και εφαρμόζεται στο frontend αμέσως. Για πλήρη εφαρμογή στον backend server (χρονοσφραγίδες), απαιτείται επανεκκίνηση του container.
+
+
+ )}
+
+ )
+}
+
+const LOCK_TIMEOUT_OPTIONS = [
+ { label: 'Απενεργοποιημένο', value: 0 },
+ { label: '1 λεπτό', value: 1 },
+ { label: '5 λεπτά', value: 5 },
+ { label: '10 λεπτά', value: 10 },
+ { label: '15 λεπτά', value: 15 },
+ { label: '30 λεπτά', value: 30 },
+ { label: '60 λεπτά', value: 60 },
+]
+
+const LOCK_SETTINGS_KEY = 'manager_lock_timeout'
+
+function AutoLockSection() {
+ const raw = parseInt(localStorage.getItem(LOCK_SETTINGS_KEY) || '0', 10)
+ const [timeout, setTimeout_] = useState(isNaN(raw) ? 0 : raw)
+
+ function handleChange(val) {
+ const n = parseInt(val, 10)
+ setTimeout_(n)
+ if (n > 0) {
+ localStorage.setItem(LOCK_SETTINGS_KEY, String(n))
+ } else {
+ localStorage.removeItem(LOCK_SETTINGS_KEY)
+ }
+ }
+
+ return (
+
+
+
Αυτόματο Κλείδωμα Διαχειριστή
+
+ Αν δεν υπάρξει δραστηριότητα για το παρακάτω διάστημα, η οθόνη κλειδώνει και ζητάει PIN.
+ Το 0 απενεργοποιεί το αυτόματο κλείδωμα.
+
+
+
+ handleChange(e.target.value)}
+ className="h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none w-52"
+ >
+ {LOCK_TIMEOUT_OPTIONS.map(o => (
+ {o.label}
+ ))}
+
+ {timeout > 0 && (
+
+ Κλείδωμα μετά από {timeout} {timeout === 1 ? 'λεπτό' : 'λεπτά'} αδράνειας
+
+ )}
+ {timeout === 0 && (
+ Μόνο χειροκίνητο κλείδωμα (κουμπί 🔒)
+ )}
+
+
+ )
+}
+
+function ShiftSettingsSection() {
+ const qc = useQueryClient()
+ const { data: settings, isLoading } = useQuery({
+ queryKey: ['pos-settings'],
+ queryFn: () => client.get('/api/settings/').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const updateMut = useMutation({
+ mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
+ onError: () => toast.error('Σφάλμα αποθήκευσης'),
+ })
+ function toggle(key, current) {
+ updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
+ }
+ const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
+ const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
+ return (
+
+
+
Ρυθμίσεις Βάρδιας
+
Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους
+
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && (
+ <>
+
+
+
Αυτόματη Έναρξη Βάρδιας
+
Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους
+
+
toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
+
+
+
+
Αυτόματο Κλείσιμο Βάρδιας
+
Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους
+
+
toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
+
+ >
+ )}
+
+ )
+}
+
+// ─── Flag definitions ─────────────────────────────────────────────────────────
+
+const FLAG_COLORS = [
+ '#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6',
+ '#8b5cf6', '#ec4899', '#06b6d4', '#6b7280', '#dc2626',
+]
+const RESTAURANT_EMOJIS = [
+ '🍽️', '🥂', '🍾', '🎂', '🎉', '🍰', '🥳', '👶', '🐶', '🐱',
+ '♿', '🌿', '🥗', '⭐', '💎', '🔥', '❄️', '⏳', '🧹', '⚠️',
+]
+
+function EmojiPicker({ value, onChange }) {
+ const [open, setOpen] = useState(false)
+ return (
+
+
setOpen(o => !o)} style={{
+ width: 60, height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
+ background: 'white', fontSize: 20, textAlign: 'center', cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}>{value || '+'}
+ {open && (
+
+ {RESTAURANT_EMOJIS.map(e => (
+ { onChange(e); setOpen(false) }} style={{
+ fontSize: 20, background: value === e ? '#eff3ff' : 'none',
+ border: 'none', borderRadius: 6, padding: '4px 0', cursor: 'pointer',
+ }}>{e}
+ ))}
+ { onChange(''); setOpen(false) }} style={{
+ fontSize: 11, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', borderRadius: 6,
+ }}>✕ clear
+
+ )}
+
+ )
+}
+
+function FlagDisplayModeSection() {
+ const qc = useQueryClient()
+ const { data: settings } = useQuery({
+ queryKey: ['pos-settings'],
+ queryFn: () => client.get('/api/settings/').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const updateMut = useMutation({
+ mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
+ onError: () => toast.error('Σφάλμα αποθήκευσης'),
+ })
+ const current = settings?.['flags.display_mode']?.value ?? 'both'
+ const options = [
+ { value: 'icon', label: '😀 Μόνο εικονίδιο' },
+ { value: 'text', label: 'Aa Μόνο κείμενο' },
+ { value: 'both', label: '😀 Aa Και τα δύο' },
+ ]
+ return (
+
+
+ Εμφάνιση σημαιών στις κάρτες τραπεζιών
+
+
+ {options.map(o => (
+ updateMut.mutate({ key: 'flags.display_mode', value: o.value })} style={{
+ height: 32, padding: '0 12px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ border: `1.5px solid ${current === o.value ? '#3758c9' : '#dfe2e6'}`,
+ background: current === o.value ? '#eff3ff' : 'white',
+ color: current === o.value ? '#3758c9' : '#374151',
+ }}>{o.label}
+ ))}
+
+
+ )
+}
+
+function FlagDefsSection() {
+ const qc = useQueryClient()
+ const [editingId, setEditingId] = useState(null)
+ const [editForm, setEditForm] = useState({})
+ const [newForm, setNewForm] = useState({ name: '', emoji: '', color: '#6b7280' })
+ const [showNew, setShowNew] = useState(false)
+ const { data: flags = [], isLoading } = useQuery({
+ queryKey: ['flag-defs'],
+ queryFn: () => client.get('/api/flags/defs?include_inactive=true').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const createMut = useMutation({
+ mutationFn: (body) => client.post('/api/flags/defs', body),
+ onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setShowNew(false); setNewForm({ name: '', emoji: '', color: '#6b7280' }) },
+ onError: () => toast.error('Σφάλμα'),
+ })
+ const updateMut = useMutation({
+ mutationFn: ({ id, ...body }) => client.put(`/api/flags/defs/${id}`, body),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }); setEditingId(null) },
+ onError: () => toast.error('Σφάλμα αποθήκευσης'),
+ })
+ const deleteMut = useMutation({
+ mutationFn: (id) => client.delete(`/api/flags/defs/${id}`),
+ onSuccess: () => { toast.success('Απενεργοποιήθηκε'); qc.invalidateQueries({ queryKey: ['flag-defs'] }) },
+ onError: () => toast.error('Σφάλμα'),
+ })
+ function startEdit(flag) {
+ setEditingId(flag.id)
+ setEditForm({ name: flag.name, emoji: flag.emoji || '', color: flag.color || '#6b7280', sort_order: flag.sort_order })
+ }
+ const rowStyle = { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }
+ return (
+
+
+
+
Σημαίες Τραπεζιών
+
Χρησιμοποιούνται για να επισημαίνετε καταστάσεις στα τραπέζια
+
+
setShowNew(v => !v)} style={{
+ height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
+ }}>+ Νέα
+
+
+ {showNew && (
+
+
setNewForm(f => ({ ...f, emoji: v }))} />
+ setNewForm(f => ({ ...f, name: e.target.value }))}
+ style={{ flex: 1, minWidth: 160, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
+
+ {FLAG_COLORS.map(c => (
+ setNewForm(f => ({ ...f, color: c }))}
+ style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: newForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
+ ))}
+
+ createMut.mutate(newForm)} disabled={!newForm.name.trim() || createMut.isPending}
+ style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση
+ setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο
+
+ )}
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && flags.length === 0 && (
+
Δεν υπάρχουν σημαίες ακόμα.
+ )}
+ {flags.map(flag => (
+
+ {editingId === flag.id ? (
+
+
setEditForm(f => ({ ...f, emoji: v }))} />
+ setEditForm(f => ({ ...f, name: e.target.value }))}
+ style={{ flex: 1, minWidth: 120, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
+
+ {FLAG_COLORS.map(c => (
+ setEditForm(f => ({ ...f, color: c }))}
+ style={{ width: 20, height: 20, borderRadius: '50%', background: c, border: editForm.color === c ? '3px solid #111' : '2px solid transparent', cursor: 'pointer' }} />
+ ))}
+
+ updateMut.mutate({ id: flag.id, ...editForm })} disabled={updateMut.isPending}
+ style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓
+ setEditingId(null)}
+ style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕
+
+ ) : (
+ <>
+
+ {flag.emoji || '🏷️'}
+
+
{flag.name}
+ {!flag.is_active &&
Ανενεργή }
+
startEdit(flag)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία
+ {flag.is_active && (
+
deleteMut.mutate(flag.id)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή
+ )}
+ >
+ )}
+
+ ))}
+
+ )
+}
+
+// ─── Quick message templates ──────────────────────────────────────────────────
+
+function QuickTemplatesSection() {
+ const qc = useQueryClient()
+ const [editingId, setEditingId] = useState(null)
+ const [editBody, setEditBody] = useState('')
+ const [newBody, setNewBody] = useState('')
+ const [showNew, setShowNew] = useState(false)
+ const { data: templates = [], isLoading } = useQuery({
+ queryKey: ['quick-templates'],
+ queryFn: () => client.get('/api/messages/templates').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const createMut = useMutation({
+ mutationFn: (body) => client.post('/api/messages/templates', body),
+ onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setShowNew(false); setNewBody('') },
+ onError: () => toast.error('Σφάλμα'),
+ })
+ const updateMut = useMutation({
+ mutationFn: ({ id, body }) => client.put(`/api/messages/templates/${id}`, { body }),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
+ onError: () => toast.error('Σφάλμα αποθήκευσης'),
+ })
+ const deleteMut = useMutation({
+ mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
+ onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
+ onError: () => toast.error('Σφάλμα'),
+ })
+ return (
+
+
+
+
Γρήγορα Μηνύματα
+
Πρότυπα μηνυμάτων για γρήγορη αποστολή στο προσωπικό
+
+
setShowNew(v => !v)} style={{
+ height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
+ }}>+ Νέο
+
+ {showNew && (
+
+ setNewBody(e.target.value)}
+ style={{ flex: 1, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
+ createMut.mutate({ body: newBody, sort_order: templates.length + 1 })}
+ disabled={!newBody.trim() || createMut.isPending}
+ style={{ height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white', border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Αποθήκευση
+ setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο
+
+ )}
+ {isLoading &&
Φόρτωση…
}
+ {!isLoading && templates.length === 0 && (
+
Δεν υπάρχουν πρότυπα ακόμα.
+ )}
+ {templates.map((t, idx) => (
+
+ {idx + 1}.
+ {editingId === t.id ? (
+ <>
+ setEditBody(e.target.value)}
+ style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
+ updateMut.mutate({ id: t.id, body: editBody })} disabled={updateMut.isPending}
+ style={{ height: 32, padding: '0 12px', borderRadius: 6, background: '#16a34a', color: 'white', border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>✓
+ setEditingId(null)}
+ style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕
+ >
+ ) : (
+ <>
+ {t.body}
+ { setEditingId(t.id); setEditBody(t.body) }}
+ style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία
+ deleteMut.mutate(t.id)}
+ style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή
+ >
+ )}
+
+ ))}
+
+ )
+}
+
+function formatUptime(seconds) {
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ const s = seconds % 60
+ return `${h}ω ${m}λ ${s}δ`
+}
+
+export default function AppInfoTab() {
+ const user = useAuthStore(s => s.user)
+ const qc = useQueryClient()
+ const { data: status, isLoading } = useQuery({
+ queryKey: ['system-status'],
+ queryFn: () => client.get('/api/system/status').then(r => r.data),
+ refetchInterval: 30_000,
+ })
+ const testPrint = useMutation({
+ mutationFn: (id) => client.post(`/api/system/printers/test?printer_id=${id}`),
+ onSuccess: (res) => {
+ const d = res.data
+ d.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${d.error}`)
+ },
+ onError: () => toast.error('Σφάλμα επικοινωνίας'),
+ })
+ if (isLoading) return
Φόρτωση…
+ return (
+
+ {/* System info */}
+
+
Σύστημα
+
+
Uptime
+
{formatUptime(status?.uptime_seconds ?? 0)}
+
Άδεια χρήσης
+
+ {status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
+
+
Κατάσταση
+
+ {status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
+
+ {status?.expires_at && (
+ <>
+
Λήξη άδειας
+
{new Date(status.expires_at).toLocaleDateString('el-GR')}
+ >
+ )}
+
+
+
+ {/* Printers */}
+
+
+
Εκτυπωτές
+
+ {(!status?.printers || status.printers.length === 0) && (
+
Δεν βρέθηκαν εκτυπωτές.
+ )}
+ {status?.printers?.map(p => (
+
+
+
+ {p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
+
+
testPrint.mutate(p.id)} disabled={testPrint.isPending} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">
+ Test Print
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {user?.role === 'sysadmin' && (
+
+
Sysadmin
+
Έλεγχος κλειδώματος συστήματος.
+
+ client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
+ className="btn btn-primary text-sm">Ξεκλείδωμα
+ client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
+ className="btn btn-danger text-sm">Κλείδωμα
+
+
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx b/manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
new file mode 100644
index 0000000..2e1f110
--- /dev/null
+++ b/manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
@@ -0,0 +1,511 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { DEFAULT_COLOURS } from '../../../store/tableColourStore'
+import client from '../../../api/client'
+import toast from 'react-hot-toast'
+
+// ─── Colour slot metadata ────────────────────────────────────────────────────
+
+const SLOTS = [
+ { key: 'cardBg', label: 'Primary Background', hint: 'Card background' },
+ { key: 'badgeBg', label: 'Secondary Background', hint: 'Status badge container' },
+ { key: 'nameText', label: 'Primary Text', hint: 'Table name' },
+ { key: 'badgeText', label: 'Secondary Text', hint: 'Badge label' },
+]
+
+const STATUSES = [
+ { key: 'free', label: 'Free Table' },
+ { key: 'open', label: 'Open Table (not mine)' },
+ { key: 'mine', label: 'Open Table (assigned to me)' },
+ { key: 'partially_paid', label: 'Partially Paid Table' },
+ { key: 'paid', label: 'Fully Paid Table' },
+]
+
+const STATUS_LABELS_MOCK = {
+ free: 'ΕΛΕΥΘΕΡΟ',
+ open: 'ΑΝΟΙΧΤΟ',
+ mine: 'ΔΙΚΟ ΜΟΥ',
+ partially_paid: 'ΜΕΡ. ΠΛHΡ.',
+ paid: 'ΠΛΗΡΩΜΕΝΟ',
+}
+
+// Quick-suggest palettes per slot type
+const QUICK_SWATCHES = {
+ cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
+ badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
+ nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
+ badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
+}
+
+// ─── Color picker modal ──────────────────────────────────────────────────────
+
+// Parse any css colour string into { hex, alpha }.
+// Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b)
+function parseColour(v) {
+ if (!v) return { hex: '#ffffff', alpha: 1 }
+ const s = v.trim()
+ // rgba / rgb
+ const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/)
+ if (rgbaMatch) {
+ const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
+ const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
+ const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
+ const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
+ return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) }
+ }
+ // #rgb shorthand
+ if (/^#[0-9a-fA-F]{3}$/.test(s)) {
+ const [, r, g, b] = s
+ return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 }
+ }
+ // #rrggbb
+ if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 }
+ return { hex: '#ffffff', alpha: 1 }
+}
+
+function buildColour(hex, alpha) {
+ if (alpha >= 1) return hex
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `rgba(${r},${g},${b},${alpha.toFixed(2)})`
+}
+
+function ColourPickerModal({ value, onClose, onChange, slot }) {
+ const parsed = parseColour(value)
+ const [hex, setHex] = useState(parsed.hex)
+ const [alpha, setAlpha] = useState(parsed.alpha)
+
+ // keep parent in sync whenever hex or alpha changes
+ useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha])
+
+ function commitSwatch(v) {
+ const p = parseColour(v)
+ setHex(p.hex)
+ setAlpha(p.alpha)
+ }
+
+ const preview = buildColour(hex, alpha)
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
Pick a Colour
+
{SLOTS.find(s => s.key === slot)?.label}
+
+
×
+
+
+ {/* Preview swatch — checkerboard behind so alpha is visible */}
+
+
0.5 ? '#fff' : '#374151',
+ textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
+ }}>
+ {preview}
+
+
+
+ {/* Colour picker + hex input */}
+
+
Colour
+
+ setHex(e.target.value)}
+ style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
+ />
+ {
+ const v = e.target.value
+ setHex(v)
+ }}
+ spellCheck={false}
+ style={{
+ flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb',
+ padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827',
+ }}
+ />
+
+
+
+ {/* Opacity slider — always visible */}
+
+
+
Opacity
+
{Math.round(alpha * 100)}%
+
+ {/* Gradient track so you can see what you're dragging */}
+
+
setAlpha(parseFloat(e.target.value))}
+ style={{
+ position: 'absolute', inset: 0, width: '100%', height: '100%',
+ opacity: 0, cursor: 'pointer', margin: 0,
+ }}
+ />
+ {/* thumb indicator */}
+
+
+
+
+ {/* Quick swatches */}
+
+
Quick select
+
+ {(QUICK_SWATCHES[slot] || []).map(c => {
+ const p = parseColour(c)
+ const built = buildColour(p.hex, p.alpha)
+ return (
+
commitSwatch(c)}
+ style={{
+ width: 36, height: 36, borderRadius: 8,
+ backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)`,
+ backgroundSize: '8px 8px',
+ backgroundPosition: '0 0,0 4px,4px -4px,-4px 0',
+ position: 'relative', overflow: 'hidden',
+ border: built === preview ? '3px solid #3758c9' : '2px solid #e5e7eb',
+ cursor: 'pointer', flexShrink: 0,
+ boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
+ }}
+ >
+
+
+ )
+ })}
+
+
+
+
+ Done
+
+
+
+ )
+}
+
+// ─── Single colour slot row ──────────────────────────────────────────────────
+
+function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
+ return (
+
+
onOpen(mode, status, slotKey, value)}
+ style={{
+ width: 44, height: 28, borderRadius: 8, background: value,
+ border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
+ boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
+ }}
+ />
+
+
+ )
+}
+
+// ─── Mini mock table card (for preview) ──────────────────────────────────────
+
+function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
+ return (
+
+ {/* Table name + group */}
+
+ {mockName}
+ {groupName}
+
+ {/* Status badge — tight equal padding on all sides */}
+
+
+ {label}
+
+
+
+ )
+}
+
+// ─── Preview panel (6 mock cards per theme) ──────────────────────────────────
+
+function PreviewPanel({ colours, mode }) {
+ const isDark = mode === 'dark'
+ const panelBg = isDark ? '#0d1520' : '#f1f5f9'
+ const panelLabel = isDark ? '🌙 Dark Mode Preview' : '☀️ Light Mode Preview'
+ const labelCol = isDark ? '#94a3b8' : '#64748b'
+
+ const mockCards = [
+ { status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' },
+ { status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' },
+ { status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' },
+ { status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
+ { status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
+ { status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
+ ]
+
+ return (
+
+
+ {panelLabel}
+
+
+ {mockCards.map((mc, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Status block (one status, showing all 4 slots) ──────────────────────────
+
+function StatusBlock({ mode, status, label, colours, onOpen }) {
+ const cfg = colours[mode][status]
+ return (
+
+
+
+
+
+
+
{label}
+
Click a swatch to edit
+
+
+
+ {SLOTS.map(slot => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Mode section (light or dark) ────────────────────────────────────────────
+
+function ModeSection({ mode, colours, onOpen }) {
+ const label = mode === 'light' ? '☀️ Light Mode' : '🌙 Dark Mode'
+ return (
+
+
{label}
+
+ {STATUSES.map(s => (
+
+ ))}
+
+
+ )
+}
+
+// ─── Main tab ────────────────────────────────────────────────────────────────
+
+export default function ColoursTab() {
+ const [colours, setColours] = useState(DEFAULT_COLOURS)
+ const [modal, setModal] = useState(null) // { mode, status, slot, value }
+ const [saving, setSaving] = useState(false)
+ const saveTimer = useRef(null)
+
+ // Load from backend on mount
+ useEffect(() => {
+ client.get('/api/settings/').then(r => {
+ const raw = r.data?.['ui.table_colours']?.value
+ if (raw) {
+ try { setColours(JSON.parse(raw)) } catch {}
+ }
+ })
+ }, [])
+
+ // Debounced save to backend — 600 ms after last change
+ const saveToBackend = useCallback((next) => {
+ clearTimeout(saveTimer.current)
+ setSaving(true)
+ saveTimer.current = setTimeout(() => {
+ client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) })
+ .then(() => setSaving(false))
+ .catch(() => { toast.error('Failed to save colours'); setSaving(false) })
+ }, 600)
+ }, [])
+
+ function setColour(mode, status, slot, value) {
+ setColours(prev => {
+ const next = {
+ ...prev,
+ [mode]: {
+ ...prev[mode],
+ [status]: { ...prev[mode][status], [slot]: value },
+ },
+ }
+ saveToBackend(next)
+ return next
+ })
+ }
+
+ function openModal(mode, status, slot, value) {
+ setModal({ mode, status, slot, value })
+ }
+
+ function handleChange(value) {
+ setColour(modal.mode, modal.status, modal.slot, value)
+ setModal(m => ({ ...m, value }))
+ }
+
+ function handleReset() {
+ if (window.confirm('Reset ALL colours to defaults? This cannot be undone.')) {
+ setColours(DEFAULT_COLOURS)
+ saveToBackend(DEFAULT_COLOURS)
+ }
+ }
+
+ return (
+
+ {/* Section header */}
+
+
UI Personalization
+
+ Customise how the Waiter App looks. Changes are saved to the server and sync to all devices automatically.
+ {saving && Saving… }
+
+
+
+ {/* Section: Waiter App — Table Colour Schemes */}
+
+
+
+ Waiter App — Table Colour Schemes
+
+
+ Each table card has four colour slots. Click any colour swatch below to open the colour picker.
+
+
+
+ {/* Live previews side by side */}
+
+
+ {/* Light + Dark mode settings */}
+
+
+
+
+
+
+ {/* Reset all button at bottom */}
+
+
+ Reset All to Defaults
+
+
+
+ {/* Colour picker modal */}
+ {modal && (
+
setModal(null)}
+ onChange={handleChange}
+ />
+ )}
+
+ )
+}
diff --git a/manager_dashboard/src/pages/SettingsPage.jsx b/manager_dashboard/src/pages/SettingsPage.jsx
deleted file mode 100644
index 08079cd..0000000
--- a/manager_dashboard/src/pages/SettingsPage.jsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import toast from 'react-hot-toast'
-import client from '../api/client'
-import useAuthStore from '../store/authStore'
-
-function formatUptime(seconds) {
- const h = Math.floor(seconds / 3600)
- const m = Math.floor((seconds % 3600) / 60)
- const s = seconds % 60
- return `${h}ω ${m}λ ${s}δ`
-}
-
-export default function SettingsPage() {
- const user = useAuthStore(s => s.user)
- const qc = useQueryClient()
-
- const { data: status, isLoading } = useQuery({
- queryKey: ['system-status'],
- queryFn: () => client.get('/api/system/status').then(r => r.data),
- refetchInterval: 30_000,
- })
-
- const testPrint = useMutation({
- mutationFn: (id) => client.post(`/api/system/printers/test?printer_id=${id}`),
- onSuccess: (res) => {
- const d = res.data
- d.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${d.error}`)
- },
- onError: () => toast.error('Σφάλμα επικοινωνίας'),
- })
-
- if (isLoading) return
Φόρτωση…
-
- return (
-
-
Ρυθμίσεις
-
- {/* System info */}
-
-
Σύστημα
-
-
Uptime
-
{formatUptime(status?.uptime_seconds ?? 0)}
-
Άδεια χρήσης
-
- {status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
-
-
Κατάσταση
-
- {status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
-
- {status?.expires_at && (
- <>
-
Λήξη άδειας
-
{new Date(status.expires_at).toLocaleDateString('el-GR')}
- >
- )}
-
-
-
- {/* Printers */}
-
-
-
Εκτυπωτές
-
-
- {(!status?.printers || status.printers.length === 0) && (
-
Δεν βρέθηκαν εκτυπωτές.
- )}
-
- {status?.printers?.map(p => (
-
-
-
- {p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
-
-
testPrint.mutate(p.id)}
- disabled={testPrint.isPending}
- className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
- >
- Test Print
-
-
- ))}
-
-
- {/* Sysadmin-only section */}
- {user?.role === 'sysadmin' && (
-
-
Sysadmin
-
Έλεγχος κλειδώματος συστήματος.
-
- client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
- className="btn btn-primary text-sm"
- >
- Ξεκλείδωμα
-
- client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
- className="btn btn-danger text-sm"
- >
- Κλείδωμα
-
-
-
- )}
-
- )
-}
diff --git a/manager_dashboard/src/pages/WaitersPage.jsx b/manager_dashboard/src/pages/StaffTab.jsx
similarity index 76%
rename from manager_dashboard/src/pages/WaitersPage.jsx
rename to manager_dashboard/src/pages/StaffTab.jsx
index b330c24..893f623 100644
--- a/manager_dashboard/src/pages/WaitersPage.jsx
+++ b/manager_dashboard/src/pages/StaffTab.jsx
@@ -149,6 +149,8 @@ function ZoneModal({ waiter, groups, onClose }) {
}
+const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter', pin: '' }
+
export default function WaitersPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
@@ -156,10 +158,14 @@ export default function WaitersPage() {
const [zoneModal, setZoneModal] = useState(null) // waiter object
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
const [newPin, setNewPin] = useState('')
- const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' })
+ const [newForm, setNewForm] = useState(EMPTY_FORM)
+ const [newAvatarFile, setNewAvatarFile] = useState(null)
+ const [newAvatarPreview, setNewAvatarPreview] = useState(null)
+
const [editModal, setEditModal] = useState(null) // waiter object
- const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '' })
+ const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', role: 'waiter' })
const avatarInputRef = useRef(null)
+ const newAvatarInputRef = useRef(null)
const { data: waiters = [], isLoading } = useQuery({
queryKey: ['waiters'],
@@ -174,8 +180,23 @@ export default function WaitersPage() {
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
const createWaiter = useMutation({
- mutationFn: (body) => client.post('/api/waiters/', body),
- onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
+ mutationFn: async (body) => {
+ const res = await client.post('/api/waiters/', body)
+ if (newAvatarFile) {
+ const fd = new FormData()
+ fd.append('file', newAvatarFile)
+ await client.post(`/api/waiters/${res.data.id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
+ }
+ return res
+ },
+ onSuccess: () => {
+ toast.success('Σερβιτόρος δημιουργήθηκε')
+ setAddModal(false)
+ setNewForm(EMPTY_FORM)
+ setNewAvatarFile(null)
+ setNewAvatarPreview(null)
+ invalidate()
+ },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
@@ -231,8 +252,7 @@ export default function WaitersPage() {
return (
-
-
Σερβιτόροι
+
setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος
@@ -263,7 +283,7 @@ export default function WaitersPage() {
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
-
{ setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία
+
{ setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', role: w.role || 'waiter' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία
{w.role === 'waiter' && (
setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες
)}
@@ -279,39 +299,79 @@ export default function WaitersPage() {
{/* Add waiter modal */}
{addModal && (
-
+
Νέος σερβιτόρος
+
+ {/* Avatar picker */}
+
+ {newAvatarPreview ? (
+
+ ) : (
+
+ 👤
+
+ )}
+
+ {
+ const file = e.target.files?.[0]
+ if (file) {
+ setNewAvatarFile(file)
+ setNewAvatarPreview(URL.createObjectURL(file))
+ }
+ e.target.value = ''
+ }}
+ />
+ newAvatarInputRef.current?.click()} type="button" className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8">
+ {newAvatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
+
+ {newAvatarPreview && (
+ { setNewAvatarFile(null); setNewAvatarPreview(null) }} className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50">
+ Αφαίρεση
+
+ )}
+
+
+
- Πλήρες όνομα
+ Πλήρες όνομα *
setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
- Όνομα χρήστη
- setNewForm(f => ({ ...f, username: e.target.value }))} />
+ Παρατσούκλι (nickname) *
+ setNewForm(f => ({ ...f, nickname: e.target.value }))} />
Κινητό τηλέφωνο
setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
- Ρόλος
+ Όνομα χρήστη *
+ setNewForm(f => ({ ...f, username: e.target.value }))} />
+
+
+ Ρόλος *
setNewForm(f => ({ ...f, role: e.target.value }))}>
Σερβιτόρος
Διαχειριστής
-
PIN
+
PIN *
setNewForm(f => ({ ...f, pin }))} />
- setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση
+ { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }} className="flex-1 btn btn-secondary">Ακύρωση
createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
- disabled={!newForm.username.trim() || newForm.pin.length < 4}
+ onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
+ disabled={createWaiter.isPending || !newForm.username.trim() || !newForm.full_name.trim() || !newForm.nickname.trim() || newForm.pin.length < 4}
className="flex-1 btn btn-primary"
>
- Δημιουργία
+ {createWaiter.isPending ? 'Δημιουργία…' : 'Δημιουργία'}
@@ -321,8 +381,8 @@ export default function WaitersPage() {
{/* Edit profile modal */}
{editModal && (
-
-
Επεξεργασία — {editModal.username}
+
+
Επεξεργασία — {editModal.full_name || editModal.username}
{/* Avatar section */}
@@ -344,7 +404,7 @@ export default function WaitersPage() {
disabled={uploadAvatar.isPending}
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
>
- {uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
+ {uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
{editModal.avatar_url && (
- Όνομα χρήστη
- setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
+ Πλήρες όνομα *
+ setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
- Πλήρες όνομα
- setEditForm(f => ({ ...f, full_name: e.target.value }))} />
-
-
- Παρατσούκλι (nickname)
+ Παρατσούκλι (nickname) *
setEditForm(f => ({ ...f, nickname: e.target.value }))} />
Κινητό τηλέφωνο
setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
+
+ Όνομα χρήστη *
+ setEditForm(f => ({ ...f, username: e.target.value }))} />
+
+
+ Ρόλος *
+ setEditForm(f => ({ ...f, role: e.target.value }))}>
+ Σερβιτόρος
+ Διαχειριστής
+
+
setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση
updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null })}
- disabled={updateWaiter.isPending || !editForm.username.trim()}
+ onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null, role: editForm.role })}
+ disabled={updateWaiter.isPending || !editForm.username.trim() || !editForm.full_name.trim() || !editForm.nickname.trim()}
className="flex-1 btn btn-primary"
>
- Αποθήκευση
+ {updateWaiter.isPending ? 'Αποθήκευση…' : 'Αποθήκευση'}
diff --git a/manager_dashboard/src/pages/TablesConfigTab.jsx b/manager_dashboard/src/pages/TablesConfigTab.jsx
new file mode 100644
index 0000000..838a827
--- /dev/null
+++ b/manager_dashboard/src/pages/TablesConfigTab.jsx
@@ -0,0 +1,356 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import toast from 'react-hot-toast'
+import client from '../api/client'
+import ConfirmModal from '../components/ConfirmModal'
+
+const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
+
+function ZoneColorPicker({ value, onChange }) {
+ return (
+
+ onChange(null)}
+ className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
+ style={{ borderColor: !value ? '#000' : 'transparent' }}
+ title="Χωρίς χρώμα"
+ />
+ {ZONE_COLORS.map(c => (
+ onChange(c)}
+ className="w-7 h-7 rounded-full border-2 transition-all"
+ style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
+ />
+ ))}
+
+ )
+}
+
+export default function TablesPage() {
+ const qc = useQueryClient()
+ const [addModal, setAddModal] = useState(false)
+ const [editModal, setEditModal] = useState(null)
+ const [batchModal, setBatchModal] = useState(null) // group object or null
+ const [groupModal, setGroupModal] = useState(null) // null | {} | group object
+ const [confirmDelete, setConfirmDelete] = useState(null)
+ const [showInactive, setShowInactive] = useState(false)
+ const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
+
+ const { data: tables = [], isLoading } = useQuery({
+ queryKey: ['tables-all', showInactive],
+ queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).then(r => r.data),
+ })
+
+ const { data: groups = [] } = useQuery({
+ queryKey: ['table-groups'],
+ queryFn: () => client.get('/api/tables/groups').then(r => r.data),
+ })
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ['tables-all'] })
+ qc.invalidateQueries({ queryKey: ['tables'] })
+ }
+ const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
+
+ const createTable = useMutation({
+ mutationFn: (body) => client.post('/api/tables/', body),
+ onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
+ onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ const batchCreate = useMutation({
+ mutationFn: (body) => client.post('/api/tables/batch', body),
+ onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
+ onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ const updateTable = useMutation({
+ mutationFn: ({ id, ...body }) => client.put(`/api/tables/${id}`, body),
+ onSuccess: () => { toast.success('Αποθηκεύτηκε'); setEditModal(null); invalidate() },
+ onError: () => toast.error('Σφάλμα'),
+ })
+
+ const deleteTable = useMutation({
+ mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
+ onSuccess: (_, vars) => {
+ toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
+ setConfirmDelete(null)
+ invalidate()
+ },
+ onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ const saveGroup = useMutation({
+ mutationFn: (body) => groupModal?.id
+ ? client.put(`/api/tables/groups/${groupModal.id}`, body)
+ : client.post('/api/tables/groups', body),
+ onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
+ onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
+ })
+
+ const deleteGroup = useMutation({
+ mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
+ onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
+ onError: () => toast.error('Σφάλμα'),
+ })
+
+ // Filter tables for the active tab
+ const visibleTables = activeTab === 'all'
+ ? tables
+ : activeTab === 'ungrouped'
+ ? tables.filter(t => !t.group_id)
+ : tables.filter(t => t.group_id === activeTab)
+
+ if (isLoading) return
Φόρτωση…
+
+ return (
+
+
+
+ setShowInactive(e.target.checked)} className="accent-primary-700" />
+ Εμφάνιση ανενεργών
+
+ setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη
+ setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι
+
+
+ {/* Zone tabs */}
+
+ {[
+ { id: 'all', label: 'Όλα', color: null },
+ ...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} – ${g.name}` : g.name, color: g.color, group: g })),
+ ...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
+ ].map(tab => (
+ setActiveTab(tab.id)}
+ className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
+ activeTab === tab.id
+ ? 'bg-white border border-b-white border-gray-200 -mb-px text-primary-700'
+ : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
+ }`}
+ >
+ {tab.color && }
+ {tab.label}
+
+ ({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
+
+
+ ))}
+
+
+ {/* Zone header (when viewing a specific zone) */}
+ {activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
+ const g = groups.find(g => g.id === activeTab)
+ if (!g) return null
+ return (
+
+
+ {g.name}
+ {g.prefix && {g.prefix} }
+
+
setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης
+
setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη
+
+ )
+ })()}
+
+ {/* Tables list */}
+
+ {visibleTables.length === 0 && (
+
+ {showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
+
+ )}
+ {visibleTables.map((t, idx) => (
+
+
{idx + 1}
+
{t.label || `Τραπέζι ${t.number}`}
+ {t.group && (
+
+ {t.group.name}
+
+ )}
+ {!t.is_active &&
Ανενεργό }
+
setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία
+ {t.is_active
+ ?
!t.has_active_order && setConfirmDelete({ id: t.id, hard: false })}
+ disabled={t.has_active_order}
+ title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
+ className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
+ >Απενεργ.
+ :
updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-green-600 hover:bg-green-50">Ενεργοπ.
+ }
+
!t.has_active_order && setConfirmDelete({ id: t.id, hard: true })}
+ disabled={t.has_active_order}
+ title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
+ className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-8 disabled:opacity-40 disabled:cursor-not-allowed"
+ >Διαγραφή
+
+ ))}
+
+
+ {/* Add single table */}
+ {addModal && (
+
createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
+ onClose={() => setAddModal(false)}
+ />
+ )}
+
+ {/* Edit table */}
+ {editModal && (
+ updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
+ onClose={() => setEditModal(null)}
+ />
+ )}
+
+ {/* Batch add */}
+ {batchModal !== null && (
+ batchCreate.mutate(body)}
+ onClose={() => setBatchModal(null)}
+ />
+ )}
+
+ {/* Group/Zone form */}
+ {groupModal !== null && (
+ saveGroup.mutate(data)}
+ onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
+ onClose={() => setGroupModal(null)}
+ />
+ )}
+
+ {/* Delete confirmation */}
+ {confirmDelete && (
+ deleteTable.mutate(confirmDelete)}
+ onCancel={() => setConfirmDelete(null)}
+ />
+ )}
+
+ )
+}
+
+function TableModal({ title, initial, groups, onSave, onClose }) {
+ const [form, setForm] = useState(initial)
+ return (
+
+
+
{title}
+
+
Όνομα τραπεζιού
+
setForm(f => ({ ...f, label: e.target.value }))}
+ autoFocus
+ />
+
Αφήστε κενό για αυτόματη αρίθμηση.
+
+
+ Ζώνη
+ setForm(f => ({ ...f, group_id: e.target.value }))}>
+ — Χωρίς ζώνη —
+ {groups.map(g => {g.name}{g.prefix ? ` (${g.prefix})` : ''} )}
+
+
+
+ Ακύρωση
+ onSave(form)} className="flex-1 btn btn-primary">Αποθήκευση
+
+
+
+ )
+}
+
+function BatchModal({ group, onSave, onClose }) {
+ const [count, setCount] = useState(5)
+ const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
+ return (
+
+
+
Μαζική προσθήκη τραπεζιών
+ {group &&
Ζώνη: {group.name}
}
+
+
Πρόθεμα ονόματος
+
setPrefix(e.target.value)}
+ autoFocus
+ />
+
Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.
+
+
+ Πλήθος
+ setCount(Number(e.target.value))} />
+
+
+ Ακύρωση
+ onSave({ group_id: group?.id ?? null, count, name_prefix: prefix })}
+ disabled={count < 1 || !prefix.trim()}
+ className="flex-1 btn btn-primary"
+ >
+ Δημιουργία {count > 0 && prefix.trim() ? `(${prefix.trim()}1 … ${prefix.trim()}${count})` : ''}
+
+
+
+
+ )
+}
+
+function GroupModal({ group, onSave, onDelete, onClose }) {
+ const [name, setName] = useState(group.name || '')
+ const [prefix, setPrefix] = useState(group.prefix || '')
+ const [color, setColor] = useState(group.color || null)
+ return (
+
+
+
{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}
+
+ Όνομα ζώνης *
+ setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
+
+
+
Πρόθεμα (για μαζική δημιουργία)
+
setPrefix(e.target.value)} placeholder="π.χ. BS" />
+
Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.
+
+
+ Χρώμα ζώνης
+
+
+
+ {onDelete && Διαγραφή }
+ Ακύρωση
+ onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση
+
+
+
+ )
+}
diff --git a/manager_dashboard/src/pages/TablesPage.jsx b/manager_dashboard/src/pages/TablesPage.jsx
index a96ed43..3436340 100644
--- a/manager_dashboard/src/pages/TablesPage.jsx
+++ b/manager_dashboard/src/pages/TablesPage.jsx
@@ -1,360 +1,814 @@
-import { useState } from 'react'
+import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
-import ConfirmModal from '../components/ConfirmModal'
-const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
+function relativeTime(isoStr) {
+ if (!isoStr) return ''
+ const diffMs = Date.now() - new Date(isoStr).getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ if (diffMins < 1) return 'μόλις τώρα'
+ if (diffMins < 60) return `πριν ${diffMins}λ`
+ const h = Math.floor(diffMins / 60)
+ if (h < 24) return `πριν ${h}ω`
+ return `πριν ${Math.floor(h / 24)}μ`
+}
-function ZoneColorPicker({ value, onChange }) {
+function FlagsDetailModal({ flags, tableName, onClose }) {
return (
-
-
onChange(null)}
- className="w-7 h-7 rounded-full border-2 bg-gray-200 transition-all"
- style={{ borderColor: !value ? '#000' : 'transparent' }}
- title="Χωρίς χρώμα"
- />
- {ZONE_COLORS.map(c => (
- onChange(c)}
- className="w-7 h-7 rounded-full border-2 transition-all"
- style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
- />
- ))}
+
+
e.stopPropagation()}>
+
+
Σημαίες — {tableName}
+
✕
+
+
+ {flags.map(f => (
+
+ {f.emoji &&
{f.emoji} }
+
+
{f.name}
+ {f.assigned_at && (
+
{relativeTime(f.assigned_at)}
+ )}
+
+
+ ))}
+
+
)
}
-export default function TablesPage() {
- const qc = useQueryClient()
- const [addModal, setAddModal] = useState(false)
- const [editModal, setEditModal] = useState(null)
- const [batchModal, setBatchModal] = useState(null) // group object or null
- const [groupModal, setGroupModal] = useState(null) // null | {} | group object
- const [confirmDelete, setConfirmDelete] = useState(null)
- const [showInactive, setShowInactive] = useState(false)
- const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
+const API_URL = import.meta.env.VITE_API_URL || ''
- const { data: tables = [], isLoading } = useQuery({
- queryKey: ['tables-all', showInactive],
- queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).then(r => r.data),
+const STATUS_OPTIONS = [
+ { value: 'open', label: 'Ανοιχτά' },
+ { value: 'partially_paid', label: 'Μερική πληρωμή' },
+ { value: 'paid', label: 'Πλήρως πληρωμένο' },
+ { value: 'free', label: 'Ελεύθερα' },
+]
+
+const COLORS = {
+ open: {
+ label: 'Ανοιχτό',
+ tint: '#eef7f0', tintStrong: '#d7ecdc',
+ accent: '#2f9e5e', ink: '#1f7042',
+ },
+ partially_paid: {
+ label: 'Μερική πληρ.',
+ tint: '#f4eefb', tintStrong: '#e3d4f3',
+ accent: '#7a44c9', ink: '#57309a',
+ },
+ paid: {
+ label: 'Πλήρως πληρωμένο',
+ tint: '#eff6ff', tintStrong: '#dbeafe',
+ accent: '#2563eb', ink: '#1d4ed8',
+ },
+ free: {
+ label: 'Ελεύθερο',
+ tint: '#f4f4f2', tintStrong: '#dfe2e6',
+ accent: '#8a9099', ink: '#5a6169',
+ },
+}
+
+function formatEuro(n) {
+ return '€' + parseFloat(n).toFixed(2)
+}
+
+function formatDuration(openedAt) {
+ const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
+ if (mins < 60) return `${mins}m`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m === 0 ? `${h}h` : `${h}h ${m}m`
+}
+
+function occupiedMinsFromDate(openedAt) {
+ return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000)
+}
+
+function orderTotal(items = []) {
+ return items
+ .filter(i => i.status !== 'cancelled')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+}
+
+function avatarColor(name) {
+ const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
+ let h = 0
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
+ return palette[h % palette.length]
+}
+
+function WaiterBubble({ waiter, size = 26 }) {
+ if (waiter.avatarUrl) {
+ return (
+
+ )
+ }
+ const parts = waiter.name.trim().split(' ')
+ const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
+ return (
+ {initials}
+ )
+}
+
+// ─── Quick action modal ────────────────────────────────────────────────────────
+
+function QuickActionModal({ table, order, flagDefs, currentFlags, waiters, templates, onClose, onDone }) {
+ const qc = useQueryClient()
+ const [selectedFlagIds, setSelectedFlagIds] = useState((currentFlags || []).map(f => f.id))
+ const [notifyMsg, setNotifyMsg] = useState('')
+ const [notifyAll, setNotifyAll] = useState(true)
+ const [notifyWaiters, setNotifyWaiters] = useState(false)
+ const [sending, setSending] = useState(false)
+
+ const setFlagsMut = useMutation({
+ mutationFn: () => client.put(`/api/flags/table/${table.id}`, { flag_ids: selectedFlagIds }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['flag-assignments'] }),
+ })
+
+ function toggleFlag(id) {
+ setSelectedFlagIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
+ }
+
+ async function save() {
+ setSending(true)
+ try {
+ await setFlagsMut.mutateAsync()
+ if (notifyWaiters && notifyMsg.trim() && order) {
+ const waiterIds = notifyAll ? [] : order.waiters.map(w => w.waiter_id)
+ await client.post('/api/messages/send', {
+ body: notifyMsg.trim(),
+ target_waiter_ids: waiterIds,
+ table_ids: [table.id],
+ })
+ toast.success('Σημαίες + ειδοποίηση εστάλη!')
+ } else {
+ toast.success('Σημαίες ενημερώθηκαν')
+ }
+ onDone()
+ onClose()
+ } catch {
+ toast.error('Σφάλμα')
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
⚡ {table.label || `T${table.number}`}
+
✕
+
+
+
Σημαίες
+
+ {flagDefs.map(f => {
+ const sel = selectedFlagIds.includes(f.id)
+ return (
+ toggleFlag(f.id)} style={{
+ height: 30, padding: '0 12px', borderRadius: 999,
+ border: `1.5px solid ${sel ? f.color : '#dfe2e6'}`,
+ background: sel ? (f.color + '22') : 'white',
+ color: sel ? f.color : '#374151',
+ fontSize: 12, fontWeight: sel ? 700 : 500, cursor: 'pointer',
+ display: 'flex', alignItems: 'center', gap: 5,
+ }}>
+ {f.emoji && {f.emoji} }
+ {f.name}
+
+ )
+ })}
+
+
+ {order && (
+ <>
+
+ setNotifyWaiters(e.target.checked)} />
+ Ειδοποίησε τους σερβιτόρους
+
+
+ {notifyWaiters && (
+
+
+ {templates.slice(0, 4).map(t => (
+ setNotifyMsg(t.body)} style={{
+ height: 26, padding: '0 10px', borderRadius: 6,
+ border: `1px solid ${notifyMsg === t.body ? '#3758c9' : '#dfe2e6'}`,
+ background: notifyMsg === t.body ? '#eff3ff' : '#f9fafb',
+ color: notifyMsg === t.body ? '#3758c9' : '#374151',
+ fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
+ }}>{t.body}
+ ))}
+
+
setNotifyMsg(e.target.value)}
+ placeholder="Ή γράψτε μήνυμα…"
+ style={{ width: '100%', height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }}
+ />
+
+ )}
+ >
+ )}
+
+
+ Άκυρο
+
+ {sending ? 'Αποθήκευση…' : 'Αποθήκευση'}
+
+
+
+
+ )
+}
+
+
+function FlagPills({ flags, displayMode = 'both', onOverflowClick }) {
+ if (flags.length === 0) return null
+ const MAX_VISIBLE = 3
+ const visible = flags.slice(0, MAX_VISIBLE)
+ const overflow = flags.length - MAX_VISIBLE
+
+ return (
+
+ {visible.map(f => (
+
+ {(displayMode === 'icon' || displayMode === 'both') && f.emoji && {f.emoji} }
+ {(displayMode === 'text' || displayMode === 'both') && {f.name} }
+
+ ))}
+ {overflow > 0 && (
+ { e.stopPropagation(); onOverflowClick?.() }}
+ title={`+${overflow} ακόμα σημαίες`}
+ style={{
+ fontSize: 11, fontWeight: 700, borderRadius: 999, padding: '2px 7px',
+ background: '#f0f4ff', color: '#3758c9', border: '1px solid #c2cff0',
+ whiteSpace: 'nowrap', flexShrink: 0, cursor: 'pointer',
+ }}
+ >+{overflow}
+ )}
+
+ )
+}
+
+function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', onClick, onQuickAction, onFlagsClick }) {
+ const s = COLORS[status] || COLORS.free
+ const [hover, setHover] = useState(false)
+ const [pressed, setPressed] = useState(false)
+ const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null
+
+ return (
+ setHover(true)}
+ onMouseLeave={() => { setHover(false); setPressed(false) }}
+ onMouseDown={() => setPressed(true)}
+ onMouseUp={() => setPressed(false)}
+ style={{
+ '--cardBg': s.tint,
+ position: 'relative', width: '100%', minWidth: 330, height: 200,
+ padding: '16px 18px 16px 24px',
+ background: s.tint, border: '1px solid ' + s.tintStrong, borderRadius: 14,
+ boxShadow: pressed
+ ? 'inset 0 2px 4px rgba(16,20,24,0.08)'
+ : hover
+ ? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
+ : '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
+ transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
+ transition: 'transform 120ms ease, box-shadow 120ms ease',
+ cursor: onClick ? 'pointer' : 'default',
+ textAlign: 'left', font: 'inherit', color: 'inherit',
+ display: 'flex', flexDirection: 'column', outline: 'none', flexShrink: 0,
+ }}
+ >
+
+
+
{name}
+
+
+ {s.label}
+
+
+
+ {hasPendingPrint && (
+ ⏳
+ )}
+
+
+
+
+
Total
+
+ {amount != null ? formatEuro(amount) : — — }
+
+
+
+
Time
+
= 90 ? 700 : 500,
+ color: '#111315',
+ }}>
+ {openedAt ? formatDuration(openedAt) : — — }
+
+
+
+
+ {waiters.length === 0 ? (
+
Unassigned
+ ) : waiters.length >= 3 ? (
+ <>
+
+ {waiters.slice(0, 3).map((w, i) => (
+
+
+
+ ))}
+
+
Multiple ({waiters.length})
+ >
+ ) : (
+ waiters.map((w, i) => (
+
+
+ {w.shortName}
+
+ ))
+ )}
+ {onQuickAction && (
+
{ e.stopPropagation(); onQuickAction() }}
+ title="Γρήγορες ενέργειες"
+ style={{
+ marginLeft: 'auto', width: 28, height: 28, borderRadius: 8, flexShrink: 0,
+ border: '1px solid ' + s.tintStrong, background: 'white',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontSize: 14, cursor: 'pointer', color: '#5a6169',
+ }}
+ >⚡
+ )}
+
+
+ )
+}
+
+// ─── Multi-select dropdown ─────────────────────────────────────────────────────
+
+function MultiSelectDropdown({ label, options, selected, onChange, allLabel = 'Όλα' }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ function handleClick(e) {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [])
+
+ const allSelected = selected.length === 0
+ const displayLabel = allSelected
+ ? allLabel
+ : selected.length === 1
+ ? options.find(o => o.value === selected[0])?.label ?? selected[0]
+ : `${selected.length} επιλεγμένα`
+
+ function toggle(value) {
+ if (selected.includes(value)) {
+ onChange(selected.filter(v => v !== value))
+ } else {
+ onChange([...selected, value])
+ }
+ }
+
+ return (
+
+
setOpen(o => !o)}
+ style={{
+ height: 36, padding: '0 14px',
+ borderRadius: 8, border: '1px solid #dfe2e6',
+ background: allSelected ? 'white' : '#f0f4ff',
+ color: allSelected ? '#374151' : '#3758c9',
+ fontSize: 13, fontWeight: allSelected ? 500 : 600,
+ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
+ whiteSpace: 'nowrap', fontFamily: 'inherit',
+ boxShadow: open ? '0 0 0 2px #c2cff0' : 'none',
+ transition: 'box-shadow 100ms',
+ }}
+ >
+ {label}
+ {displayLabel}
+ {open ? '▲' : '▼'}
+
+
+ {open && (
+
+ {/* All option */}
+ { onChange([]); setOpen(false) }}
+ style={{
+ width: '100%', padding: '10px 14px',
+ display: 'flex', alignItems: 'center', gap: 10,
+ background: allSelected ? '#f0f4ff' : 'white',
+ border: 'none', cursor: 'pointer', textAlign: 'left',
+ fontSize: 13, fontWeight: allSelected ? 700 : 400,
+ color: allSelected ? '#3758c9' : '#374151',
+ fontFamily: 'inherit',
+ borderBottom: '1px solid #f4f4f2',
+ }}
+ >
+
+ {allSelected && ✓ }
+
+ {allLabel}
+
+ {options.map(opt => {
+ const checked = selected.includes(opt.value)
+ return (
+ toggle(opt.value)}
+ style={{
+ width: '100%', padding: '10px 14px',
+ display: 'flex', alignItems: 'center', gap: 10,
+ background: checked ? '#f9f5ff' : 'white',
+ border: 'none', cursor: 'pointer', textAlign: 'left',
+ fontSize: 13, fontWeight: checked ? 600 : 400,
+ color: checked ? '#57309a' : '#374151',
+ fontFamily: 'inherit',
+ borderBottom: '1px solid #f9f9f8',
+ }}
+ >
+
+ {checked && ✓ }
+
+ {opt.label}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+export default function TablesPage() {
+ const [statusFilter, setStatusFilter] = useState([])
+ const [zoneFilter, setZoneFilter] = useState([])
+ const [retryingId, setRetryingId] = useState(null)
+ const [quickActionTarget, setQuickActionTarget] = useState(null)
+ const [flagsDetail, setFlagsDetail] = useState(null) // { flags, tableName }
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const { data: tables = [], isLoading: tablesLoading } = useQuery({
+ queryKey: ['tables'],
+ queryFn: () => client.get('/api/tables/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const { data: orders = [], isLoading: ordersLoading } = useQuery({
+ queryKey: ['orders-active'],
+ queryFn: () => client.get('/api/orders/').then(r => r.data),
+ refetchInterval: 5_000,
+ })
+
+ const { data: flagDefs = [] } = useQuery({
+ queryKey: ['flag-defs'],
+ queryFn: () => client.get('/api/flags/defs').then(r => r.data),
+ staleTime: 30_000,
+ })
+
+ const { data: flagAssignments = [] } = useQuery({
+ queryKey: ['flag-assignments'],
+ queryFn: () => client.get('/api/flags/assignments').then(r => r.data),
+ refetchInterval: 10_000,
+ staleTime: 8_000,
+ })
+
+ // Build map: tableId -> { def, assigned_at }[]
+ const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
+ const tableFlagsMap = {}
+ flagAssignments.forEach(a => {
+ if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = []
+ const def = flagDefMap[a.flag_id]
+ if (def) tableFlagsMap[a.table_id].push({ ...def, assigned_at: a.assigned_at })
+ })
+
+ const { data: waiters = [] } = useQuery({
+ queryKey: ['waiters'],
+ queryFn: () => client.get('/api/waiters/').then(r => r.data),
+ staleTime: 60_000,
+ })
+
+ const { data: quickTemplates = [] } = useQuery({
+ queryKey: ['quick-templates'],
+ queryFn: () => client.get('/api/messages/templates').then(r => r.data),
+ staleTime: 60_000,
})
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
+ staleTime: 60_000,
})
- const invalidate = () => {
- qc.invalidateQueries({ queryKey: ['tables-all'] })
- qc.invalidateQueries({ queryKey: ['tables'] })
+ const { data: posSettings } = useQuery({
+ queryKey: ['pos-settings'],
+ queryFn: () => client.get('/api/settings/').then(r => r.data),
+ staleTime: 30_000,
+ })
+ const flagDisplayMode = posSettings?.['flags.display_mode']?.value ?? 'both'
+
+ const waiterMap = Object.fromEntries(waiters.map(w => {
+ const name = w.full_name || w.nickname || w.username
+ const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username)
+ const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null
+ return [w.id, { name, shortName, avatarUrl }]
+ }))
+
+ const tableCards = tables.map(table => {
+ const order = orders.find(o =>
+ o.table_id === table.id && ['open', 'partially_paid', 'paid'].includes(o.status)
+ )
+ const tableStatus = order ? order.status : 'free'
+ const hasPendingPrint = order ? order.items.some(i => i.status === 'active' && !i.printed) : false
+ const tableFlags = tableFlagsMap[table.id] || []
+ return { table, order, tableStatus, hasPendingPrint, tableFlags }
+ })
+
+ const pendingPrintOrders = tableCards.filter(c => c.hasPendingPrint)
+
+ async function retrySingleOrder(orderId) {
+ setRetryingId(orderId)
+ try {
+ const res = await client.post(`/api/orders/${orderId}/retry-print`)
+ const results = res.data.print_results ?? []
+ const allOk = results.length === 0 || results.every(r => r.success)
+ if (allOk) toast.success('Εκτυπώθηκε επιτυχώς')
+ else {
+ const failed = results.filter(r => !r.success).map(r => r.printer_name).join(', ')
+ toast.error(`Αποτυχία: ${failed}`)
+ }
+ queryClient.invalidateQueries({ queryKey: ['orders-active'] })
+ } catch {
+ toast.error('Σφάλμα επικοινωνίας')
+ } finally {
+ setRetryingId(null)
+ }
}
- const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
- const createTable = useMutation({
- mutationFn: (body) => client.post('/api/tables/', body),
- onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
- onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
+ async function retryAllOrders() {
+ for (const { order } of pendingPrintOrders) {
+ if (order) await retrySingleOrder(order.id)
+ }
+ }
+
+ // Build zone options from groups + "Χωρίς ζώνη"
+ const zoneOptions = [
+ ...groups.map(g => ({ value: String(g.id), label: g.name })),
+ { value: '__none__', label: 'Χωρίς ζώνη' },
+ ]
+
+ // Apply filters
+ const filtered = tableCards.filter(c => {
+ const statusOk = statusFilter.length === 0 || statusFilter.includes(c.tableStatus)
+ let zoneOk = true
+ if (zoneFilter.length > 0) {
+ const tableGroupId = c.table.group_id ? String(c.table.group_id) : null
+ if (zoneFilter.includes('__none__') && zoneFilter.length === 1) {
+ zoneOk = !tableGroupId
+ } else if (zoneFilter.includes('__none__')) {
+ zoneOk = !tableGroupId || zoneFilter.includes(tableGroupId)
+ } else {
+ zoneOk = tableGroupId !== null && zoneFilter.includes(tableGroupId)
+ }
+ }
+ return statusOk && zoneOk
})
- const batchCreate = useMutation({
- mutationFn: (body) => client.post('/api/tables/batch', body),
- onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
- onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
- })
-
- const updateTable = useMutation({
- mutationFn: ({ id, ...body }) => client.put(`/api/tables/${id}`, body),
- onSuccess: () => { toast.success('Αποθηκεύτηκε'); setEditModal(null); invalidate() },
- onError: () => toast.error('Σφάλμα'),
- })
-
- const deleteTable = useMutation({
- mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
- onSuccess: (_, vars) => {
- toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
- setConfirmDelete(null)
- invalidate()
- },
- onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
- })
-
- const saveGroup = useMutation({
- mutationFn: (body) => groupModal?.id
- ? client.put(`/api/tables/groups/${groupModal.id}`, body)
- : client.post('/api/tables/groups', body),
- onSuccess: () => { toast.success('Ζώνη αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
- onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
- })
-
- const deleteGroup = useMutation({
- mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
- onSuccess: () => { toast.success('Ζώνη διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
- onError: () => toast.error('Σφάλμα'),
- })
-
- // Filter tables for the active tab
- const visibleTables = activeTab === 'all'
- ? tables
- : activeTab === 'ungrouped'
- ? tables.filter(t => !t.group_id)
- : tables.filter(t => t.group_id === activeTab)
-
- if (isLoading) return Φόρτωση…
+ if (tablesLoading || ordersLoading) {
+ return Φόρτωση…
+ }
return (
-
- {/* Header */}
+
+ {flagsDetail && (
+
setFlagsDetail(null)}
+ />
+ )}
+ {quickActionTarget && (
+ setQuickActionTarget(null)}
+ onDone={() => queryClient.invalidateQueries({ queryKey: ['flag-assignments'] })}
+ />
+ )}
Τραπέζια
-
-
- setShowInactive(e.target.checked)} className="accent-primary-700" />
- Εμφάνιση ανενεργών
-
-
setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη
-
setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι
+
+
+
- {/* Zone tabs */}
-
- {[
- { id: 'all', label: 'Όλα', color: null },
- ...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} – ${g.name}` : g.name, color: g.color, group: g })),
- ...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
- ].map(tab => (
-
setActiveTab(tab.id)}
- className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
- activeTab === tab.id
- ? 'bg-white border border-b-white border-gray-200 -mb-px text-primary-700'
- : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
- }`}
- >
- {tab.color && }
- {tab.label}
-
- ({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
-
-
- ))}
+ {filtered.length === 0 && (
+
Δεν βρέθηκαν τραπέζια.
+ )}
+
+
+ {filtered.map(({ table, order, tableStatus, hasPendingPrint, tableFlags }) => {
+ const waiterNames = order
+ ? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
+ : []
+ const amount = order ? orderTotal(order.items) : null
+
+ return (
+
navigate(`/orders/${order.id}`) : undefined}
+ onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
+ onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}
+ />
+ )
+ })}
- {/* Zone header (when viewing a specific zone) */}
- {activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
- const g = groups.find(g => g.id === activeTab)
- if (!g) return null
- return (
-
-
-
{g.name}
- {g.prefix &&
{g.prefix} }
+ {pendingPrintOrders.length > 0 && (
+
+
+
+
⏳
+
+
Εκκρεμείς Εκτυπώσεις
+
+ {pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
+
+
-
setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης
-
setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη
-
- )
- })()}
-
- {/* Tables list */}
-
- {visibleTables.length === 0 && (
-
- {showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
-
- )}
- {visibleTables.map((t, idx) => (
-
-
{idx + 1}
-
{t.label || `Τραπέζι ${t.number}`}
- {t.group && (
-
- {t.group.prefix ? `${t.group.prefix}` : t.group.name}
-
- )}
- {!t.is_active &&
Ανενεργό }
-
setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία
- {t.is_active
- ?
!t.has_active_order && setConfirmDelete({ id: t.id, hard: false })}
- disabled={t.has_active_order}
- title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
- className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
- >Απενεργ.
- :
updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8 text-green-600 hover:bg-green-50">Ενεργοπ.
- }
!t.has_active_order && setConfirmDelete({ id: t.id, hard: true })}
- disabled={t.has_active_order}
- title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
- className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-8 disabled:opacity-40 disabled:cursor-not-allowed"
- >Διαγραφή
+ className="btn btn-primary text-sm"
+ style={{ background: '#c2410c', borderColor: '#c2410c' }}
+ onClick={retryAllOrders}
+ disabled={retryingId !== null}
+ >
+ {retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
+
- ))}
-
-
- {/* Add single table */}
- {addModal && (
-
createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
- onClose={() => setAddModal(false)}
- />
- )}
-
- {/* Edit table */}
- {editModal && (
- updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
- onClose={() => setEditModal(null)}
- />
- )}
-
- {/* Batch add */}
- {batchModal !== null && (
- batchCreate.mutate(body)}
- onClose={() => setBatchModal(null)}
- />
- )}
-
- {/* Group/Zone form */}
- {groupModal !== null && (
- saveGroup.mutate(data)}
- onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
- onClose={() => setGroupModal(null)}
- />
- )}
-
- {/* Delete confirmation */}
- {confirmDelete && (
- deleteTable.mutate(confirmDelete)}
- onCancel={() => setConfirmDelete(null)}
- />
+
+ {pendingPrintOrders.map(({ table, order }) => {
+ const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
+ const tableName = table.label || `T${table.number}`
+ return (
+
+
+ {tableName}
+
+
+
+ {unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
+
+
+ {unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
+
+
+
+ navigate(`/orders/${order.id}`)}>
+ Λεπτομέρειες
+
+ retrySingleOrder(order.id)}
+ disabled={retryingId === order.id}
+ >
+ {retryingId === order.id ? '…' : 'Εκτύπωση'}
+
+
+
+ )
+ })}
+
+
)}
)
}
-
-function TableModal({ title, initial, groups, onSave, onClose }) {
- const [form, setForm] = useState(initial)
- return (
-
-
-
{title}
-
-
Όνομα τραπεζιού
-
setForm(f => ({ ...f, label: e.target.value }))}
- autoFocus
- />
-
Αφήστε κενό για αυτόματη αρίθμηση.
-
-
- Ζώνη
- setForm(f => ({ ...f, group_id: e.target.value }))}>
- — Χωρίς ζώνη —
- {groups.map(g => {g.name}{g.prefix ? ` (${g.prefix})` : ''} )}
-
-
-
- Ακύρωση
- onSave(form)} className="flex-1 btn btn-primary">Αποθήκευση
-
-
-
- )
-}
-
-function BatchModal({ group, onSave, onClose }) {
- const [count, setCount] = useState(5)
- const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
- return (
-
-
-
Μαζική προσθήκη τραπεζιών
- {group &&
Ζώνη: {group.name}
}
-
-
Πρόθεμα ονόματος
-
setPrefix(e.target.value)}
- autoFocus
- />
-
Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.
-
-
- Πλήθος
- setCount(Number(e.target.value))} />
-
-
- Ακύρωση
- onSave({ group_id: group?.id ?? null, count, name_prefix: prefix })}
- disabled={count < 1 || !prefix.trim()}
- className="flex-1 btn btn-primary"
- >
- Δημιουργία {count > 0 && prefix.trim() ? `(${prefix.trim()}1 … ${prefix.trim()}${count})` : ''}
-
-
-
-
- )
-}
-
-function GroupModal({ group, onSave, onDelete, onClose }) {
- const [name, setName] = useState(group.name || '')
- const [prefix, setPrefix] = useState(group.prefix || '')
- const [color, setColor] = useState(group.color || null)
- return (
-
-
-
{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}
-
- Όνομα ζώνης *
- setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
-
-
-
Πρόθεμα (για μαζική δημιουργία)
-
setPrefix(e.target.value)} placeholder="π.χ. BS" />
-
Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.
-
-
- Χρώμα ζώνης
-
-
-
- {onDelete && Διαγραφή }
- Ακύρωση
- onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση
-
-
-
- )
-}
diff --git a/manager_dashboard/src/store/authStore.js b/manager_dashboard/src/store/authStore.js
index 552aa8c..d4095d1 100644
--- a/manager_dashboard/src/store/authStore.js
+++ b/manager_dashboard/src/store/authStore.js
@@ -2,16 +2,30 @@ import { create } from 'zustand'
const useAuthStore = create((set) => ({
user: null,
- token: localStorage.getItem('token') || null,
+ token: localStorage.getItem('manager_token') || null,
+ savedUsername: localStorage.getItem('manager_username') || null,
+ locked: false,
login(user, token) {
- localStorage.setItem('token', token)
- set({ user, token })
+ localStorage.setItem('manager_token', token)
+ localStorage.setItem('manager_username', user.username)
+ set({ user, token, savedUsername: user.username, locked: false })
},
logout() {
- localStorage.removeItem('token')
- set({ user: null, token: null })
+ localStorage.removeItem('manager_token')
+ localStorage.removeItem('manager_username')
+ localStorage.removeItem('manager_lock_timeout')
+ set({ user: null, token: null, savedUsername: null, locked: false })
+ },
+
+ lock() {
+ set({ locked: true })
+ },
+
+ unlock(user, token) {
+ localStorage.setItem('manager_token', token)
+ set({ user, token, locked: false })
},
}))
diff --git a/manager_dashboard/src/store/tableColourStore.js b/manager_dashboard/src/store/tableColourStore.js
new file mode 100644
index 0000000..c9ab234
--- /dev/null
+++ b/manager_dashboard/src/store/tableColourStore.js
@@ -0,0 +1,94 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+// Mirrors waiter_pwa/src/store/tableColourStore.js — same localStorage key so both apps share state.
+
+export const DEFAULT_COLOURS = {
+ light: {
+ free: {
+ cardBg: '#d6d6d6',
+ badgeBg: '#e3e3e3',
+ nameText: '#3b485e',
+ badgeText: '#adadad',
+ },
+ mine: {
+ cardBg: '#e83030',
+ badgeBg: 'rgba(255,255,255,0.40)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ open: {
+ cardBg: '#ffbb29',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ partially_paid: {
+ cardBg: '#e89230',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ paid: {
+ cardBg: '#79ad38',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ },
+ dark: {
+ free: {
+ cardBg: '#243044',
+ badgeBg: 'rgba(26,35,50,0.50)',
+ nameText: '#ffffff',
+ badgeText: '#adadad',
+ },
+ mine: {
+ cardBg: '#e83030',
+ badgeBg: 'rgba(255,255,255,0.40)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ open: {
+ cardBg: '#ffbb29',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ partially_paid: {
+ cardBg: '#e89230',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ paid: {
+ cardBg: '#79ad38',
+ badgeBg: 'rgba(255,255,255,0.25)',
+ nameText: '#ffffff',
+ badgeText: '#ffffff',
+ },
+ },
+}
+
+const useTableColourStore = create(persist(
+ (set) => ({
+ colours: DEFAULT_COLOURS,
+ setColour: (mode, status, slot, value) =>
+ set(s => ({
+ colours: {
+ ...s.colours,
+ [mode]: {
+ ...s.colours[mode],
+ [status]: {
+ ...s.colours[mode][status],
+ [slot]: value,
+ },
+ },
+ },
+ })),
+ resetAll: () => set({ colours: DEFAULT_COLOURS }),
+ }),
+ { name: 'pos-table-colours' }
+))
+
+export default useTableColourStore
diff --git a/sysadmin_panel/package-lock.json b/sysadmin_panel/package-lock.json
new file mode 100644
index 0000000..3ee4201
--- /dev/null
+++ b/sysadmin_panel/package-lock.json
@@ -0,0 +1,3080 @@
+{
+ "name": "sysadmin-panel",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "sysadmin-panel",
+ "version": "0.0.0",
+ "dependencies": {
+ "axios": "^1.7.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hot-toast": "^2.4.1",
+ "react-router-dom": "^6.28.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.16",
+ "vite": "^6.0.5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
+ "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
+ "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/goober": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+ "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-hot-toast": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+ "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/waiter_pwa/README.md b/waiter_pwa/README.md
new file mode 100644
index 0000000..a36934d
--- /dev/null
+++ b/waiter_pwa/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
diff --git a/waiter_pwa/dev-dist/registerSW.js b/waiter_pwa/dev-dist/registerSW.js
new file mode 100644
index 0000000..1d5625f
--- /dev/null
+++ b/waiter_pwa/dev-dist/registerSW.js
@@ -0,0 +1 @@
+if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
\ No newline at end of file
diff --git a/waiter_pwa/dev-dist/sw.js b/waiter_pwa/dev-dist/sw.js
new file mode 100644
index 0000000..9d827aa
--- /dev/null
+++ b/waiter_pwa/dev-dist/sw.js
@@ -0,0 +1,92 @@
+/**
+ * Copyright 2018 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// If the loader is already loaded, just stop.
+if (!self.define) {
+ let registry = {};
+
+ // Used for `eval` and `importScripts` where we can't get script URL by other means.
+ // In both cases, it's safe to use a global var because those functions are synchronous.
+ let nextDefineUri;
+
+ const singleRequire = (uri, parentUri) => {
+ uri = new URL(uri + ".js", parentUri).href;
+ return registry[uri] || (
+
+ new Promise(resolve => {
+ if ("document" in self) {
+ const script = document.createElement("script");
+ script.src = uri;
+ script.onload = resolve;
+ document.head.appendChild(script);
+ } else {
+ nextDefineUri = uri;
+ importScripts(uri);
+ resolve();
+ }
+ })
+
+ .then(() => {
+ let promise = registry[uri];
+ if (!promise) {
+ throw new Error(`Module ${uri} didn’t register its module`);
+ }
+ return promise;
+ })
+ );
+ };
+
+ self.define = (depsNames, factory) => {
+ const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
+ if (registry[uri]) {
+ // Module is already loading or loaded.
+ return;
+ }
+ let exports = {};
+ const require = depUri => singleRequire(depUri, uri);
+ const specialDeps = {
+ module: { uri },
+ exports,
+ require
+ };
+ registry[uri] = Promise.all(depsNames.map(
+ depName => specialDeps[depName] || require(depName)
+ )).then(deps => {
+ factory(...deps);
+ return exports;
+ });
+ };
+}
+define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
+
+ self.skipWaiting();
+ workbox.clientsClaim();
+
+ /**
+ * The precacheAndRoute() method efficiently caches and responds to
+ * requests for URLs in the manifest.
+ * See https://goo.gl/S9QRab
+ */
+ workbox.precacheAndRoute([{
+ "url": "registerSW.js",
+ "revision": "3ca0b8505b4bec776b69afdba2768812"
+ }, {
+ "url": "index.html",
+ "revision": "0.7tvu7c24jlg"
+ }], {});
+ workbox.cleanupOutdatedCaches();
+ workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
+ allowlist: [/^\/$/]
+ }));
+
+}));
diff --git a/waiter_pwa/dev-dist/workbox-5a5d9309.js b/waiter_pwa/dev-dist/workbox-5a5d9309.js
new file mode 100644
index 0000000..6f2a448
--- /dev/null
+++ b/waiter_pwa/dev-dist/workbox-5a5d9309.js
@@ -0,0 +1,3395 @@
+define(['exports'], (function (exports) { 'use strict';
+
+ // @ts-ignore
+ try {
+ self['workbox:core:7.3.0'] && _();
+ } catch (e) {}
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Claim any currently available clients once the service worker
+ * becomes active. This is normally used in conjunction with `skipWaiting()`.
+ *
+ * @memberof workbox-core
+ */
+ function clientsClaim() {
+ self.addEventListener('activate', () => self.clients.claim());
+ }
+
+ /*
+ Copyright 2019 Google LLC
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const logger = (() => {
+ // Don't overwrite this value if it's already set.
+ // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923
+ if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) {
+ self.__WB_DISABLE_DEV_LOGS = false;
+ }
+ let inGroup = false;
+ const methodToColorMap = {
+ debug: `#7f8c8d`,
+ log: `#2ecc71`,
+ warn: `#f39c12`,
+ error: `#c0392b`,
+ groupCollapsed: `#3498db`,
+ groupEnd: null // No colored prefix on groupEnd
+ };
+ const print = function (method, args) {
+ if (self.__WB_DISABLE_DEV_LOGS) {
+ return;
+ }
+ if (method === 'groupCollapsed') {
+ // Safari doesn't print all console.groupCollapsed() arguments:
+ // https://bugs.webkit.org/show_bug.cgi?id=182754
+ if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
+ console[method](...args);
+ return;
+ }
+ }
+ const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`];
+ // When in a group, the workbox prefix is not displayed.
+ const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')];
+ console[method](...logPrefix, ...args);
+ if (method === 'groupCollapsed') {
+ inGroup = true;
+ }
+ if (method === 'groupEnd') {
+ inGroup = false;
+ }
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const api = {};
+ const loggerMethods = Object.keys(methodToColorMap);
+ for (const key of loggerMethods) {
+ const method = key;
+ api[method] = (...args) => {
+ print(method, args);
+ };
+ }
+ return api;
+ })();
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const messages = {
+ 'invalid-value': ({
+ paramName,
+ validValueDescription,
+ value
+ }) => {
+ if (!paramName || !validValueDescription) {
+ throw new Error(`Unexpected input to 'invalid-value' error.`);
+ }
+ return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`;
+ },
+ 'not-an-array': ({
+ moduleName,
+ className,
+ funcName,
+ paramName
+ }) => {
+ if (!moduleName || !className || !funcName || !paramName) {
+ throw new Error(`Unexpected input to 'not-an-array' error.`);
+ }
+ return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`;
+ },
+ 'incorrect-type': ({
+ expectedType,
+ paramName,
+ moduleName,
+ className,
+ funcName
+ }) => {
+ if (!expectedType || !paramName || !moduleName || !funcName) {
+ throw new Error(`Unexpected input to 'incorrect-type' error.`);
+ }
+ const classNameStr = className ? `${className}.` : '';
+ return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`;
+ },
+ 'incorrect-class': ({
+ expectedClassName,
+ paramName,
+ moduleName,
+ className,
+ funcName,
+ isReturnValueProblem
+ }) => {
+ if (!expectedClassName || !moduleName || !funcName) {
+ throw new Error(`Unexpected input to 'incorrect-class' error.`);
+ }
+ const classNameStr = className ? `${className}.` : '';
+ if (isReturnValueProblem) {
+ return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`;
+ }
+ return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`;
+ },
+ 'missing-a-method': ({
+ expectedMethod,
+ paramName,
+ moduleName,
+ className,
+ funcName
+ }) => {
+ if (!expectedMethod || !paramName || !moduleName || !className || !funcName) {
+ throw new Error(`Unexpected input to 'missing-a-method' error.`);
+ }
+ return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`;
+ },
+ 'add-to-cache-list-unexpected-type': ({
+ entry
+ }) => {
+ return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`;
+ },
+ 'add-to-cache-list-conflicting-entries': ({
+ firstEntry,
+ secondEntry
+ }) => {
+ if (!firstEntry || !secondEntry) {
+ throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`);
+ }
+ return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`;
+ },
+ 'plugin-error-request-will-fetch': ({
+ thrownErrorMessage
+ }) => {
+ if (!thrownErrorMessage) {
+ throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`);
+ }
+ return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`;
+ },
+ 'invalid-cache-name': ({
+ cacheNameId,
+ value
+ }) => {
+ if (!cacheNameId) {
+ throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`);
+ }
+ return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`;
+ },
+ 'unregister-route-but-not-found-with-method': ({
+ method
+ }) => {
+ if (!method) {
+ throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`);
+ }
+ return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`;
+ },
+ 'unregister-route-route-not-registered': () => {
+ return `The route you're trying to unregister was not previously ` + `registered.`;
+ },
+ 'queue-replay-failed': ({
+ name
+ }) => {
+ return `Replaying the background sync queue '${name}' failed.`;
+ },
+ 'duplicate-queue-name': ({
+ name
+ }) => {
+ return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`;
+ },
+ 'expired-test-without-max-age': ({
+ methodName,
+ paramName
+ }) => {
+ return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`;
+ },
+ 'unsupported-route-type': ({
+ moduleName,
+ className,
+ funcName,
+ paramName
+ }) => {
+ return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`;
+ },
+ 'not-array-of-class': ({
+ value,
+ expectedClass,
+ moduleName,
+ className,
+ funcName,
+ paramName
+ }) => {
+ return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`;
+ },
+ 'max-entries-or-age-required': ({
+ moduleName,
+ className,
+ funcName
+ }) => {
+ return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`;
+ },
+ 'statuses-or-headers-required': ({
+ moduleName,
+ className,
+ funcName
+ }) => {
+ return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`;
+ },
+ 'invalid-string': ({
+ moduleName,
+ funcName,
+ paramName
+ }) => {
+ if (!paramName || !moduleName || !funcName) {
+ throw new Error(`Unexpected input to 'invalid-string' error.`);
+ }
+ return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`;
+ },
+ 'channel-name-required': () => {
+ return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`;
+ },
+ 'invalid-responses-are-same-args': () => {
+ return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`;
+ },
+ 'expire-custom-caches-only': () => {
+ return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`;
+ },
+ 'unit-must-be-bytes': ({
+ normalizedRangeHeader
+ }) => {
+ if (!normalizedRangeHeader) {
+ throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`);
+ }
+ return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`;
+ },
+ 'single-range-only': ({
+ normalizedRangeHeader
+ }) => {
+ if (!normalizedRangeHeader) {
+ throw new Error(`Unexpected input to 'single-range-only' error.`);
+ }
+ return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`;
+ },
+ 'invalid-range-values': ({
+ normalizedRangeHeader
+ }) => {
+ if (!normalizedRangeHeader) {
+ throw new Error(`Unexpected input to 'invalid-range-values' error.`);
+ }
+ return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`;
+ },
+ 'no-range-header': () => {
+ return `No Range header was found in the Request provided.`;
+ },
+ 'range-not-satisfiable': ({
+ size,
+ start,
+ end
+ }) => {
+ return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`;
+ },
+ 'attempt-to-cache-non-get-request': ({
+ url,
+ method
+ }) => {
+ return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`;
+ },
+ 'cache-put-with-no-response': ({
+ url
+ }) => {
+ return `There was an attempt to cache '${url}' but the response was not ` + `defined.`;
+ },
+ 'no-response': ({
+ url,
+ error
+ }) => {
+ let message = `The strategy could not generate a response for '${url}'.`;
+ if (error) {
+ message += ` The underlying error is ${error}.`;
+ }
+ return message;
+ },
+ 'bad-precaching-response': ({
+ url,
+ status
+ }) => {
+ return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`);
+ },
+ 'non-precached-url': ({
+ url
+ }) => {
+ return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`;
+ },
+ 'add-to-cache-list-conflicting-integrities': ({
+ url
+ }) => {
+ return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`;
+ },
+ 'missing-precache-entry': ({
+ cacheName,
+ url
+ }) => {
+ return `Unable to find a precached response in ${cacheName} for ${url}.`;
+ },
+ 'cross-origin-copy-response': ({
+ origin
+ }) => {
+ return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`;
+ },
+ 'opaque-streams-source': ({
+ type
+ }) => {
+ const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`;
+ if (type === 'opaqueredirect') {
+ return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`;
+ }
+ return `${message} Please ensure your sources are CORS-enabled.`;
+ }
+ };
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const generatorFunction = (code, details = {}) => {
+ const message = messages[code];
+ if (!message) {
+ throw new Error(`Unable to find message for code '${code}'.`);
+ }
+ return message(details);
+ };
+ const messageGenerator = generatorFunction;
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Workbox errors should be thrown with this class.
+ * This allows use to ensure the type easily in tests,
+ * helps developers identify errors from workbox
+ * easily and allows use to optimise error
+ * messages correctly.
+ *
+ * @private
+ */
+ class WorkboxError extends Error {
+ /**
+ *
+ * @param {string} errorCode The error code that
+ * identifies this particular error.
+ * @param {Object=} details Any relevant arguments
+ * that will help developers identify issues should
+ * be added as a key on the context object.
+ */
+ constructor(errorCode, details) {
+ const message = messageGenerator(errorCode, details);
+ super(message);
+ this.name = errorCode;
+ this.details = details;
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /*
+ * This method throws if the supplied value is not an array.
+ * The destructed values are required to produce a meaningful error for users.
+ * The destructed and restructured object is so it's clear what is
+ * needed.
+ */
+ const isArray = (value, details) => {
+ if (!Array.isArray(value)) {
+ throw new WorkboxError('not-an-array', details);
+ }
+ };
+ const hasMethod = (object, expectedMethod, details) => {
+ const type = typeof object[expectedMethod];
+ if (type !== 'function') {
+ details['expectedMethod'] = expectedMethod;
+ throw new WorkboxError('missing-a-method', details);
+ }
+ };
+ const isType = (object, expectedType, details) => {
+ if (typeof object !== expectedType) {
+ details['expectedType'] = expectedType;
+ throw new WorkboxError('incorrect-type', details);
+ }
+ };
+ const isInstance = (object,
+ // Need the general type to do the check later.
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ expectedClass, details) => {
+ if (!(object instanceof expectedClass)) {
+ details['expectedClassName'] = expectedClass.name;
+ throw new WorkboxError('incorrect-class', details);
+ }
+ };
+ const isOneOf = (value, validValues, details) => {
+ if (!validValues.includes(value)) {
+ details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`;
+ throw new WorkboxError('invalid-value', details);
+ }
+ };
+ const isArrayOfClass = (value,
+ // Need general type to do check later.
+ expectedClass,
+ // eslint-disable-line
+ details) => {
+ const error = new WorkboxError('not-array-of-class', details);
+ if (!Array.isArray(value)) {
+ throw error;
+ }
+ for (const item of value) {
+ if (!(item instanceof expectedClass)) {
+ throw error;
+ }
+ }
+ };
+ const finalAssertExports = {
+ hasMethod,
+ isArray,
+ isInstance,
+ isOneOf,
+ isType,
+ isArrayOfClass
+ };
+
+ // @ts-ignore
+ try {
+ self['workbox:routing:7.3.0'] && _();
+ } catch (e) {}
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * The default HTTP method, 'GET', used when there's no specific method
+ * configured for a route.
+ *
+ * @type {string}
+ *
+ * @private
+ */
+ const defaultMethod = 'GET';
+ /**
+ * The list of valid HTTP methods associated with requests that could be routed.
+ *
+ * @type {Array
}
+ *
+ * @private
+ */
+ const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * @param {function()|Object} handler Either a function, or an object with a
+ * 'handle' method.
+ * @return {Object} An object with a handle method.
+ *
+ * @private
+ */
+ const normalizeHandler = handler => {
+ if (handler && typeof handler === 'object') {
+ {
+ finalAssertExports.hasMethod(handler, 'handle', {
+ moduleName: 'workbox-routing',
+ className: 'Route',
+ funcName: 'constructor',
+ paramName: 'handler'
+ });
+ }
+ return handler;
+ } else {
+ {
+ finalAssertExports.isType(handler, 'function', {
+ moduleName: 'workbox-routing',
+ className: 'Route',
+ funcName: 'constructor',
+ paramName: 'handler'
+ });
+ }
+ return {
+ handle: handler
+ };
+ }
+ };
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A `Route` consists of a pair of callback functions, "match" and "handler".
+ * The "match" callback determine if a route should be used to "handle" a
+ * request by returning a non-falsy value if it can. The "handler" callback
+ * is called when there is a match and should return a Promise that resolves
+ * to a `Response`.
+ *
+ * @memberof workbox-routing
+ */
+ class Route {
+ /**
+ * Constructor for Route class.
+ *
+ * @param {workbox-routing~matchCallback} match
+ * A callback function that determines whether the route matches a given
+ * `fetch` event by returning a non-falsy value.
+ * @param {workbox-routing~handlerCallback} handler A callback
+ * function that returns a Promise resolving to a Response.
+ * @param {string} [method='GET'] The HTTP method to match the Route
+ * against.
+ */
+ constructor(match, handler, method = defaultMethod) {
+ {
+ finalAssertExports.isType(match, 'function', {
+ moduleName: 'workbox-routing',
+ className: 'Route',
+ funcName: 'constructor',
+ paramName: 'match'
+ });
+ if (method) {
+ finalAssertExports.isOneOf(method, validMethods, {
+ paramName: 'method'
+ });
+ }
+ }
+ // These values are referenced directly by Router so cannot be
+ // altered by minificaton.
+ this.handler = normalizeHandler(handler);
+ this.match = match;
+ this.method = method;
+ }
+ /**
+ *
+ * @param {workbox-routing-handlerCallback} handler A callback
+ * function that returns a Promise resolving to a Response
+ */
+ setCatchHandler(handler) {
+ this.catchHandler = normalizeHandler(handler);
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * RegExpRoute makes it easy to create a regular expression based
+ * {@link workbox-routing.Route}.
+ *
+ * For same-origin requests the RegExp only needs to match part of the URL. For
+ * requests against third-party servers, you must define a RegExp that matches
+ * the start of the URL.
+ *
+ * @memberof workbox-routing
+ * @extends workbox-routing.Route
+ */
+ class RegExpRoute extends Route {
+ /**
+ * If the regular expression contains
+ * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references},
+ * the captured values will be passed to the
+ * {@link workbox-routing~handlerCallback} `params`
+ * argument.
+ *
+ * @param {RegExp} regExp The regular expression to match against URLs.
+ * @param {workbox-routing~handlerCallback} handler A callback
+ * function that returns a Promise resulting in a Response.
+ * @param {string} [method='GET'] The HTTP method to match the Route
+ * against.
+ */
+ constructor(regExp, handler, method) {
+ {
+ finalAssertExports.isInstance(regExp, RegExp, {
+ moduleName: 'workbox-routing',
+ className: 'RegExpRoute',
+ funcName: 'constructor',
+ paramName: 'pattern'
+ });
+ }
+ const match = ({
+ url
+ }) => {
+ const result = regExp.exec(url.href);
+ // Return immediately if there's no match.
+ if (!result) {
+ return;
+ }
+ // Require that the match start at the first character in the URL string
+ // if it's a cross-origin request.
+ // See https://github.com/GoogleChrome/workbox/issues/281 for the context
+ // behind this behavior.
+ if (url.origin !== location.origin && result.index !== 0) {
+ {
+ logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`);
+ }
+ return;
+ }
+ // If the route matches, but there aren't any capture groups defined, then
+ // this will return [], which is truthy and therefore sufficient to
+ // indicate a match.
+ // If there are capture groups, then it will return their values.
+ return result.slice(1);
+ };
+ super(match, handler, method);
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const getFriendlyURL = url => {
+ const urlObj = new URL(String(url), location.href);
+ // See https://github.com/GoogleChrome/workbox/issues/2323
+ // We want to include everything, except for the origin if it's same-origin.
+ return urlObj.href.replace(new RegExp(`^${location.origin}`), '');
+ };
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * The Router can be used to process a `FetchEvent` using one or more
+ * {@link workbox-routing.Route}, responding with a `Response` if
+ * a matching route exists.
+ *
+ * If no route matches a given a request, the Router will use a "default"
+ * handler if one is defined.
+ *
+ * Should the matching Route throw an error, the Router will use a "catch"
+ * handler if one is defined to gracefully deal with issues and respond with a
+ * Request.
+ *
+ * If a request matches multiple routes, the **earliest** registered route will
+ * be used to respond to the request.
+ *
+ * @memberof workbox-routing
+ */
+ class Router {
+ /**
+ * Initializes a new Router.
+ */
+ constructor() {
+ this._routes = new Map();
+ this._defaultHandlerMap = new Map();
+ }
+ /**
+ * @return {Map>} routes A `Map` of HTTP
+ * method name ('GET', etc.) to an array of all the corresponding `Route`
+ * instances that are registered.
+ */
+ get routes() {
+ return this._routes;
+ }
+ /**
+ * Adds a fetch event listener to respond to events when a route matches
+ * the event's request.
+ */
+ addFetchListener() {
+ // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705
+ self.addEventListener('fetch', event => {
+ const {
+ request
+ } = event;
+ const responsePromise = this.handleRequest({
+ request,
+ event
+ });
+ if (responsePromise) {
+ event.respondWith(responsePromise);
+ }
+ });
+ }
+ /**
+ * Adds a message event listener for URLs to cache from the window.
+ * This is useful to cache resources loaded on the page prior to when the
+ * service worker started controlling it.
+ *
+ * The format of the message data sent from the window should be as follows.
+ * Where the `urlsToCache` array may consist of URL strings or an array of
+ * URL string + `requestInit` object (the same as you'd pass to `fetch()`).
+ *
+ * ```
+ * {
+ * type: 'CACHE_URLS',
+ * payload: {
+ * urlsToCache: [
+ * './script1.js',
+ * './script2.js',
+ * ['./script3.js', {mode: 'no-cors'}],
+ * ],
+ * },
+ * }
+ * ```
+ */
+ addCacheListener() {
+ // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705
+ self.addEventListener('message', event => {
+ // event.data is type 'any'
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (event.data && event.data.type === 'CACHE_URLS') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const {
+ payload
+ } = event.data;
+ {
+ logger.debug(`Caching URLs from the window`, payload.urlsToCache);
+ }
+ const requestPromises = Promise.all(payload.urlsToCache.map(entry => {
+ if (typeof entry === 'string') {
+ entry = [entry];
+ }
+ const request = new Request(...entry);
+ return this.handleRequest({
+ request,
+ event
+ });
+ // TODO(philipwalton): TypeScript errors without this typecast for
+ // some reason (probably a bug). The real type here should work but
+ // doesn't: `Array | undefined>`.
+ })); // TypeScript
+ event.waitUntil(requestPromises);
+ // If a MessageChannel was used, reply to the message on success.
+ if (event.ports && event.ports[0]) {
+ void requestPromises.then(() => event.ports[0].postMessage(true));
+ }
+ }
+ });
+ }
+ /**
+ * Apply the routing rules to a FetchEvent object to get a Response from an
+ * appropriate Route's handler.
+ *
+ * @param {Object} options
+ * @param {Request} options.request The request to handle.
+ * @param {ExtendableEvent} options.event The event that triggered the
+ * request.
+ * @return {Promise|undefined} A promise is returned if a
+ * registered route can handle the request. If there is no matching
+ * route and there's no `defaultHandler`, `undefined` is returned.
+ */
+ handleRequest({
+ request,
+ event
+ }) {
+ {
+ finalAssertExports.isInstance(request, Request, {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'handleRequest',
+ paramName: 'options.request'
+ });
+ }
+ const url = new URL(request.url, location.href);
+ if (!url.protocol.startsWith('http')) {
+ {
+ logger.debug(`Workbox Router only supports URLs that start with 'http'.`);
+ }
+ return;
+ }
+ const sameOrigin = url.origin === location.origin;
+ const {
+ params,
+ route
+ } = this.findMatchingRoute({
+ event,
+ request,
+ sameOrigin,
+ url
+ });
+ let handler = route && route.handler;
+ const debugMessages = [];
+ {
+ if (handler) {
+ debugMessages.push([`Found a route to handle this request:`, route]);
+ if (params) {
+ debugMessages.push([`Passing the following params to the route's handler:`, params]);
+ }
+ }
+ }
+ // If we don't have a handler because there was no matching route, then
+ // fall back to defaultHandler if that's defined.
+ const method = request.method;
+ if (!handler && this._defaultHandlerMap.has(method)) {
+ {
+ debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`);
+ }
+ handler = this._defaultHandlerMap.get(method);
+ }
+ if (!handler) {
+ {
+ // No handler so Workbox will do nothing. If logs is set of debug
+ // i.e. verbose, we should print out this information.
+ logger.debug(`No route found for: ${getFriendlyURL(url)}`);
+ }
+ return;
+ }
+ {
+ // We have a handler, meaning Workbox is going to handle the route.
+ // print the routing details to the console.
+ logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`);
+ debugMessages.forEach(msg => {
+ if (Array.isArray(msg)) {
+ logger.log(...msg);
+ } else {
+ logger.log(msg);
+ }
+ });
+ logger.groupEnd();
+ }
+ // Wrap in try and catch in case the handle method throws a synchronous
+ // error. It should still callback to the catch handler.
+ let responsePromise;
+ try {
+ responsePromise = handler.handle({
+ url,
+ request,
+ event,
+ params
+ });
+ } catch (err) {
+ responsePromise = Promise.reject(err);
+ }
+ // Get route's catch handler, if it exists
+ const catchHandler = route && route.catchHandler;
+ if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) {
+ responsePromise = responsePromise.catch(async err => {
+ // If there's a route catch handler, process that first
+ if (catchHandler) {
+ {
+ // Still include URL here as it will be async from the console group
+ // and may not make sense without the URL
+ logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`);
+ logger.error(`Error thrown by:`, route);
+ logger.error(err);
+ logger.groupEnd();
+ }
+ try {
+ return await catchHandler.handle({
+ url,
+ request,
+ event,
+ params
+ });
+ } catch (catchErr) {
+ if (catchErr instanceof Error) {
+ err = catchErr;
+ }
+ }
+ }
+ if (this._catchHandler) {
+ {
+ // Still include URL here as it will be async from the console group
+ // and may not make sense without the URL
+ logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`);
+ logger.error(`Error thrown by:`, route);
+ logger.error(err);
+ logger.groupEnd();
+ }
+ return this._catchHandler.handle({
+ url,
+ request,
+ event
+ });
+ }
+ throw err;
+ });
+ }
+ return responsePromise;
+ }
+ /**
+ * Checks a request and URL (and optionally an event) against the list of
+ * registered routes, and if there's a match, returns the corresponding
+ * route along with any params generated by the match.
+ *
+ * @param {Object} options
+ * @param {URL} options.url
+ * @param {boolean} options.sameOrigin The result of comparing `url.origin`
+ * against the current origin.
+ * @param {Request} options.request The request to match.
+ * @param {Event} options.event The corresponding event.
+ * @return {Object} An object with `route` and `params` properties.
+ * They are populated if a matching route was found or `undefined`
+ * otherwise.
+ */
+ findMatchingRoute({
+ url,
+ sameOrigin,
+ request,
+ event
+ }) {
+ const routes = this._routes.get(request.method) || [];
+ for (const route of routes) {
+ let params;
+ // route.match returns type any, not possible to change right now.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const matchResult = route.match({
+ url,
+ sameOrigin,
+ request,
+ event
+ });
+ if (matchResult) {
+ {
+ // Warn developers that using an async matchCallback is almost always
+ // not the right thing to do.
+ if (matchResult instanceof Promise) {
+ logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route);
+ }
+ }
+ // See https://github.com/GoogleChrome/workbox/issues/2079
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ params = matchResult;
+ if (Array.isArray(params) && params.length === 0) {
+ // Instead of passing an empty array in as params, use undefined.
+ params = undefined;
+ } else if (matchResult.constructor === Object &&
+ // eslint-disable-line
+ Object.keys(matchResult).length === 0) {
+ // Instead of passing an empty object in as params, use undefined.
+ params = undefined;
+ } else if (typeof matchResult === 'boolean') {
+ // For the boolean value true (rather than just something truth-y),
+ // don't set params.
+ // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353
+ params = undefined;
+ }
+ // Return early if have a match.
+ return {
+ route,
+ params
+ };
+ }
+ }
+ // If no match was found above, return and empty object.
+ return {};
+ }
+ /**
+ * Define a default `handler` that's called when no routes explicitly
+ * match the incoming request.
+ *
+ * Each HTTP method ('GET', 'POST', etc.) gets its own default handler.
+ *
+ * Without a default handler, unmatched requests will go against the
+ * network as if there were no service worker present.
+ *
+ * @param {workbox-routing~handlerCallback} handler A callback
+ * function that returns a Promise resulting in a Response.
+ * @param {string} [method='GET'] The HTTP method to associate with this
+ * default handler. Each method has its own default.
+ */
+ setDefaultHandler(handler, method = defaultMethod) {
+ this._defaultHandlerMap.set(method, normalizeHandler(handler));
+ }
+ /**
+ * If a Route throws an error while handling a request, this `handler`
+ * will be called and given a chance to provide a response.
+ *
+ * @param {workbox-routing~handlerCallback} handler A callback
+ * function that returns a Promise resulting in a Response.
+ */
+ setCatchHandler(handler) {
+ this._catchHandler = normalizeHandler(handler);
+ }
+ /**
+ * Registers a route with the router.
+ *
+ * @param {workbox-routing.Route} route The route to register.
+ */
+ registerRoute(route) {
+ {
+ finalAssertExports.isType(route, 'object', {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'registerRoute',
+ paramName: 'route'
+ });
+ finalAssertExports.hasMethod(route, 'match', {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'registerRoute',
+ paramName: 'route'
+ });
+ finalAssertExports.isType(route.handler, 'object', {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'registerRoute',
+ paramName: 'route'
+ });
+ finalAssertExports.hasMethod(route.handler, 'handle', {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'registerRoute',
+ paramName: 'route.handler'
+ });
+ finalAssertExports.isType(route.method, 'string', {
+ moduleName: 'workbox-routing',
+ className: 'Router',
+ funcName: 'registerRoute',
+ paramName: 'route.method'
+ });
+ }
+ if (!this._routes.has(route.method)) {
+ this._routes.set(route.method, []);
+ }
+ // Give precedence to all of the earlier routes by adding this additional
+ // route to the end of the array.
+ this._routes.get(route.method).push(route);
+ }
+ /**
+ * Unregisters a route with the router.
+ *
+ * @param {workbox-routing.Route} route The route to unregister.
+ */
+ unregisterRoute(route) {
+ if (!this._routes.has(route.method)) {
+ throw new WorkboxError('unregister-route-but-not-found-with-method', {
+ method: route.method
+ });
+ }
+ const routeIndex = this._routes.get(route.method).indexOf(route);
+ if (routeIndex > -1) {
+ this._routes.get(route.method).splice(routeIndex, 1);
+ } else {
+ throw new WorkboxError('unregister-route-route-not-registered');
+ }
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ let defaultRouter;
+ /**
+ * Creates a new, singleton Router instance if one does not exist. If one
+ * does already exist, that instance is returned.
+ *
+ * @private
+ * @return {Router}
+ */
+ const getOrCreateDefaultRouter = () => {
+ if (!defaultRouter) {
+ defaultRouter = new Router();
+ // The helpers that use the default Router assume these listeners exist.
+ defaultRouter.addFetchListener();
+ defaultRouter.addCacheListener();
+ }
+ return defaultRouter;
+ };
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Easily register a RegExp, string, or function with a caching
+ * strategy to a singleton Router instance.
+ *
+ * This method will generate a Route for you if needed and
+ * call {@link workbox-routing.Router#registerRoute}.
+ *
+ * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture
+ * If the capture param is a `Route`, all other arguments will be ignored.
+ * @param {workbox-routing~handlerCallback} [handler] A callback
+ * function that returns a Promise resulting in a Response. This parameter
+ * is required if `capture` is not a `Route` object.
+ * @param {string} [method='GET'] The HTTP method to match the Route
+ * against.
+ * @return {workbox-routing.Route} The generated `Route`.
+ *
+ * @memberof workbox-routing
+ */
+ function registerRoute(capture, handler, method) {
+ let route;
+ if (typeof capture === 'string') {
+ const captureUrl = new URL(capture, location.href);
+ {
+ if (!(capture.startsWith('/') || capture.startsWith('http'))) {
+ throw new WorkboxError('invalid-string', {
+ moduleName: 'workbox-routing',
+ funcName: 'registerRoute',
+ paramName: 'capture'
+ });
+ }
+ // We want to check if Express-style wildcards are in the pathname only.
+ // TODO: Remove this log message in v4.
+ const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture;
+ // See https://github.com/pillarjs/path-to-regexp#parameters
+ const wildcards = '[*:?+]';
+ if (new RegExp(`${wildcards}`).exec(valueToCheck)) {
+ logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`);
+ }
+ }
+ const matchCallback = ({
+ url
+ }) => {
+ {
+ if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) {
+ logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`);
+ }
+ }
+ return url.href === captureUrl.href;
+ };
+ // If `capture` is a string then `handler` and `method` must be present.
+ route = new Route(matchCallback, handler, method);
+ } else if (capture instanceof RegExp) {
+ // If `capture` is a `RegExp` then `handler` and `method` must be present.
+ route = new RegExpRoute(capture, handler, method);
+ } else if (typeof capture === 'function') {
+ // If `capture` is a function then `handler` and `method` must be present.
+ route = new Route(capture, handler, method);
+ } else if (capture instanceof Route) {
+ route = capture;
+ } else {
+ throw new WorkboxError('unsupported-route-type', {
+ moduleName: 'workbox-routing',
+ funcName: 'registerRoute',
+ paramName: 'capture'
+ });
+ }
+ const defaultRouter = getOrCreateDefaultRouter();
+ defaultRouter.registerRoute(route);
+ return route;
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const _cacheNameDetails = {
+ googleAnalytics: 'googleAnalytics',
+ precache: 'precache-v2',
+ prefix: 'workbox',
+ runtime: 'runtime',
+ suffix: typeof registration !== 'undefined' ? registration.scope : ''
+ };
+ const _createCacheName = cacheName => {
+ return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-');
+ };
+ const eachCacheNameDetail = fn => {
+ for (const key of Object.keys(_cacheNameDetails)) {
+ fn(key);
+ }
+ };
+ const cacheNames = {
+ updateDetails: details => {
+ eachCacheNameDetail(key => {
+ if (typeof details[key] === 'string') {
+ _cacheNameDetails[key] = details[key];
+ }
+ });
+ },
+ getGoogleAnalyticsName: userCacheName => {
+ return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics);
+ },
+ getPrecacheName: userCacheName => {
+ return userCacheName || _createCacheName(_cacheNameDetails.precache);
+ },
+ getPrefix: () => {
+ return _cacheNameDetails.prefix;
+ },
+ getRuntimeName: userCacheName => {
+ return userCacheName || _createCacheName(_cacheNameDetails.runtime);
+ },
+ getSuffix: () => {
+ return _cacheNameDetails.suffix;
+ }
+ };
+
+ /*
+ Copyright 2020 Google LLC
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A utility method that makes it easier to use `event.waitUntil` with
+ * async functions and return the result.
+ *
+ * @param {ExtendableEvent} event
+ * @param {Function} asyncFn
+ * @return {Function}
+ * @private
+ */
+ function waitUntil(event, asyncFn) {
+ const returnPromise = asyncFn();
+ event.waitUntil(returnPromise);
+ return returnPromise;
+ }
+
+ // @ts-ignore
+ try {
+ self['workbox:precaching:7.3.0'] && _();
+ } catch (e) {}
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ // Name of the search parameter used to store revision info.
+ const REVISION_SEARCH_PARAM = '__WB_REVISION__';
+ /**
+ * Converts a manifest entry into a versioned URL suitable for precaching.
+ *
+ * @param {Object|string} entry
+ * @return {string} A URL with versioning info.
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ function createCacheKey(entry) {
+ if (!entry) {
+ throw new WorkboxError('add-to-cache-list-unexpected-type', {
+ entry
+ });
+ }
+ // If a precache manifest entry is a string, it's assumed to be a versioned
+ // URL, like '/app.abcd1234.js'. Return as-is.
+ if (typeof entry === 'string') {
+ const urlObject = new URL(entry, location.href);
+ return {
+ cacheKey: urlObject.href,
+ url: urlObject.href
+ };
+ }
+ const {
+ revision,
+ url
+ } = entry;
+ if (!url) {
+ throw new WorkboxError('add-to-cache-list-unexpected-type', {
+ entry
+ });
+ }
+ // If there's just a URL and no revision, then it's also assumed to be a
+ // versioned URL.
+ if (!revision) {
+ const urlObject = new URL(url, location.href);
+ return {
+ cacheKey: urlObject.href,
+ url: urlObject.href
+ };
+ }
+ // Otherwise, construct a properly versioned URL using the custom Workbox
+ // search parameter along with the revision info.
+ const cacheKeyURL = new URL(url, location.href);
+ const originalURL = new URL(url, location.href);
+ cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision);
+ return {
+ cacheKey: cacheKeyURL.href,
+ url: originalURL.href
+ };
+ }
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A plugin, designed to be used with PrecacheController, to determine the
+ * of assets that were updated (or not updated) during the install event.
+ *
+ * @private
+ */
+ class PrecacheInstallReportPlugin {
+ constructor() {
+ this.updatedURLs = [];
+ this.notUpdatedURLs = [];
+ this.handlerWillStart = async ({
+ request,
+ state
+ }) => {
+ // TODO: `state` should never be undefined...
+ if (state) {
+ state.originalRequest = request;
+ }
+ };
+ this.cachedResponseWillBeUsed = async ({
+ event,
+ state,
+ cachedResponse
+ }) => {
+ if (event.type === 'install') {
+ if (state && state.originalRequest && state.originalRequest instanceof Request) {
+ // TODO: `state` should never be undefined...
+ const url = state.originalRequest.url;
+ if (cachedResponse) {
+ this.notUpdatedURLs.push(url);
+ } else {
+ this.updatedURLs.push(url);
+ }
+ }
+ }
+ return cachedResponse;
+ };
+ }
+ }
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A plugin, designed to be used with PrecacheController, to translate URLs into
+ * the corresponding cache key, based on the current revision info.
+ *
+ * @private
+ */
+ class PrecacheCacheKeyPlugin {
+ constructor({
+ precacheController
+ }) {
+ this.cacheKeyWillBeUsed = async ({
+ request,
+ params
+ }) => {
+ // Params is type any, can't change right now.
+ /* eslint-disable */
+ const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url);
+ /* eslint-enable */
+ return cacheKey ? new Request(cacheKey, {
+ headers: request.headers
+ }) : request;
+ };
+ this._precacheController = precacheController;
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * @param {string} groupTitle
+ * @param {Array} deletedURLs
+ *
+ * @private
+ */
+ const logGroup = (groupTitle, deletedURLs) => {
+ logger.groupCollapsed(groupTitle);
+ for (const url of deletedURLs) {
+ logger.log(url);
+ }
+ logger.groupEnd();
+ };
+ /**
+ * @param {Array} deletedURLs
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ function printCleanupDetails(deletedURLs) {
+ const deletionCount = deletedURLs.length;
+ if (deletionCount > 0) {
+ logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`);
+ logGroup('Deleted Cache Requests', deletedURLs);
+ logger.groupEnd();
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * @param {string} groupTitle
+ * @param {Array} urls
+ *
+ * @private
+ */
+ function _nestedGroup(groupTitle, urls) {
+ if (urls.length === 0) {
+ return;
+ }
+ logger.groupCollapsed(groupTitle);
+ for (const url of urls) {
+ logger.log(url);
+ }
+ logger.groupEnd();
+ }
+ /**
+ * @param {Array} urlsToPrecache
+ * @param {Array} urlsAlreadyPrecached
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) {
+ const precachedCount = urlsToPrecache.length;
+ const alreadyPrecachedCount = urlsAlreadyPrecached.length;
+ if (precachedCount || alreadyPrecachedCount) {
+ let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`;
+ if (alreadyPrecachedCount > 0) {
+ message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`;
+ }
+ logger.groupCollapsed(message);
+ _nestedGroup(`View newly precached URLs.`, urlsToPrecache);
+ _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached);
+ logger.groupEnd();
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ let supportStatus;
+ /**
+ * A utility function that determines whether the current browser supports
+ * constructing a new `Response` from a `response.body` stream.
+ *
+ * @return {boolean} `true`, if the current browser can successfully
+ * construct a `Response` from a `response.body` stream, `false` otherwise.
+ *
+ * @private
+ */
+ function canConstructResponseFromBodyStream() {
+ if (supportStatus === undefined) {
+ const testResponse = new Response('');
+ if ('body' in testResponse) {
+ try {
+ new Response(testResponse.body);
+ supportStatus = true;
+ } catch (error) {
+ supportStatus = false;
+ }
+ }
+ supportStatus = false;
+ }
+ return supportStatus;
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Allows developers to copy a response and modify its `headers`, `status`,
+ * or `statusText` values (the values settable via a
+ * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax}
+ * object in the constructor).
+ * To modify these values, pass a function as the second argument. That
+ * function will be invoked with a single object with the response properties
+ * `{headers, status, statusText}`. The return value of this function will
+ * be used as the `ResponseInit` for the new `Response`. To change the values
+ * either modify the passed parameter(s) and return it, or return a totally
+ * new object.
+ *
+ * This method is intentionally limited to same-origin responses, regardless of
+ * whether CORS was used or not.
+ *
+ * @param {Response} response
+ * @param {Function} modifier
+ * @memberof workbox-core
+ */
+ async function copyResponse(response, modifier) {
+ let origin = null;
+ // If response.url isn't set, assume it's cross-origin and keep origin null.
+ if (response.url) {
+ const responseURL = new URL(response.url);
+ origin = responseURL.origin;
+ }
+ if (origin !== self.location.origin) {
+ throw new WorkboxError('cross-origin-copy-response', {
+ origin
+ });
+ }
+ const clonedResponse = response.clone();
+ // Create a fresh `ResponseInit` object by cloning the headers.
+ const responseInit = {
+ headers: new Headers(clonedResponse.headers),
+ status: clonedResponse.status,
+ statusText: clonedResponse.statusText
+ };
+ // Apply any user modifications.
+ const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit;
+ // Create the new response from the body stream and `ResponseInit`
+ // modifications. Note: not all browsers support the Response.body stream,
+ // so fall back to reading the entire body into memory as a blob.
+ const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob();
+ return new Response(body, modifiedResponseInit);
+ }
+
+ /*
+ Copyright 2020 Google LLC
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ function stripParams(fullURL, ignoreParams) {
+ const strippedURL = new URL(fullURL);
+ for (const param of ignoreParams) {
+ strippedURL.searchParams.delete(param);
+ }
+ return strippedURL.href;
+ }
+ /**
+ * Matches an item in the cache, ignoring specific URL params. This is similar
+ * to the `ignoreSearch` option, but it allows you to ignore just specific
+ * params (while continuing to match on the others).
+ *
+ * @private
+ * @param {Cache} cache
+ * @param {Request} request
+ * @param {Object} matchOptions
+ * @param {Array} ignoreParams
+ * @return {Promise}
+ */
+ async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) {
+ const strippedRequestURL = stripParams(request.url, ignoreParams);
+ // If the request doesn't include any ignored params, match as normal.
+ if (request.url === strippedRequestURL) {
+ return cache.match(request, matchOptions);
+ }
+ // Otherwise, match by comparing keys
+ const keysOptions = Object.assign(Object.assign({}, matchOptions), {
+ ignoreSearch: true
+ });
+ const cacheKeys = await cache.keys(request, keysOptions);
+ for (const cacheKey of cacheKeys) {
+ const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams);
+ if (strippedRequestURL === strippedCacheKeyURL) {
+ return cache.match(cacheKey, matchOptions);
+ }
+ }
+ return;
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * The Deferred class composes Promises in a way that allows for them to be
+ * resolved or rejected from outside the constructor. In most cases promises
+ * should be used directly, but Deferreds can be necessary when the logic to
+ * resolve a promise must be separate.
+ *
+ * @private
+ */
+ class Deferred {
+ /**
+ * Creates a promise and exposes its resolve and reject functions as methods.
+ */
+ constructor() {
+ this.promise = new Promise((resolve, reject) => {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+ }
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ // Callbacks to be executed whenever there's a quota error.
+ // Can't change Function type right now.
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const quotaErrorCallbacks = new Set();
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Runs all of the callback functions, one at a time sequentially, in the order
+ * in which they were registered.
+ *
+ * @memberof workbox-core
+ * @private
+ */
+ async function executeQuotaErrorCallbacks() {
+ {
+ logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`);
+ }
+ for (const callback of quotaErrorCallbacks) {
+ await callback();
+ {
+ logger.log(callback, 'is complete.');
+ }
+ }
+ {
+ logger.log('Finished running callbacks.');
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Returns a promise that resolves and the passed number of milliseconds.
+ * This utility is an async/await-friendly version of `setTimeout`.
+ *
+ * @param {number} ms
+ * @return {Promise}
+ * @private
+ */
+ function timeout(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ // @ts-ignore
+ try {
+ self['workbox:strategies:7.3.0'] && _();
+ } catch (e) {}
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ function toRequest(input) {
+ return typeof input === 'string' ? new Request(input) : input;
+ }
+ /**
+ * A class created every time a Strategy instance calls
+ * {@link workbox-strategies.Strategy~handle} or
+ * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and
+ * cache actions around plugin callbacks and keeps track of when the strategy
+ * is "done" (i.e. all added `event.waitUntil()` promises have resolved).
+ *
+ * @memberof workbox-strategies
+ */
+ class StrategyHandler {
+ /**
+ * Creates a new instance associated with the passed strategy and event
+ * that's handling the request.
+ *
+ * The constructor also initializes the state that will be passed to each of
+ * the plugins handling this request.
+ *
+ * @param {workbox-strategies.Strategy} strategy
+ * @param {Object} options
+ * @param {Request|string} options.request A request to run this strategy for.
+ * @param {ExtendableEvent} options.event The event associated with the
+ * request.
+ * @param {URL} [options.url]
+ * @param {*} [options.params] The return value from the
+ * {@link workbox-routing~matchCallback} (if applicable).
+ */
+ constructor(strategy, options) {
+ this._cacheKeys = {};
+ /**
+ * The request the strategy is performing (passed to the strategy's
+ * `handle()` or `handleAll()` method).
+ * @name request
+ * @instance
+ * @type {Request}
+ * @memberof workbox-strategies.StrategyHandler
+ */
+ /**
+ * The event associated with this request.
+ * @name event
+ * @instance
+ * @type {ExtendableEvent}
+ * @memberof workbox-strategies.StrategyHandler
+ */
+ /**
+ * A `URL` instance of `request.url` (if passed to the strategy's
+ * `handle()` or `handleAll()` method).
+ * Note: the `url` param will be present if the strategy was invoked
+ * from a workbox `Route` object.
+ * @name url
+ * @instance
+ * @type {URL|undefined}
+ * @memberof workbox-strategies.StrategyHandler
+ */
+ /**
+ * A `param` value (if passed to the strategy's
+ * `handle()` or `handleAll()` method).
+ * Note: the `param` param will be present if the strategy was invoked
+ * from a workbox `Route` object and the
+ * {@link workbox-routing~matchCallback} returned
+ * a truthy value (it will be that value).
+ * @name params
+ * @instance
+ * @type {*|undefined}
+ * @memberof workbox-strategies.StrategyHandler
+ */
+ {
+ finalAssertExports.isInstance(options.event, ExtendableEvent, {
+ moduleName: 'workbox-strategies',
+ className: 'StrategyHandler',
+ funcName: 'constructor',
+ paramName: 'options.event'
+ });
+ }
+ Object.assign(this, options);
+ this.event = options.event;
+ this._strategy = strategy;
+ this._handlerDeferred = new Deferred();
+ this._extendLifetimePromises = [];
+ // Copy the plugins list (since it's mutable on the strategy),
+ // so any mutations don't affect this handler instance.
+ this._plugins = [...strategy.plugins];
+ this._pluginStateMap = new Map();
+ for (const plugin of this._plugins) {
+ this._pluginStateMap.set(plugin, {});
+ }
+ this.event.waitUntil(this._handlerDeferred.promise);
+ }
+ /**
+ * Fetches a given request (and invokes any applicable plugin callback
+ * methods) using the `fetchOptions` (for non-navigation requests) and
+ * `plugins` defined on the `Strategy` object.
+ *
+ * The following plugin lifecycle methods are invoked when using this method:
+ * - `requestWillFetch()`
+ * - `fetchDidSucceed()`
+ * - `fetchDidFail()`
+ *
+ * @param {Request|string} input The URL or request to fetch.
+ * @return {Promise}
+ */
+ async fetch(input) {
+ const {
+ event
+ } = this;
+ let request = toRequest(input);
+ if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) {
+ const possiblePreloadResponse = await event.preloadResponse;
+ if (possiblePreloadResponse) {
+ {
+ logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`);
+ }
+ return possiblePreloadResponse;
+ }
+ }
+ // If there is a fetchDidFail plugin, we need to save a clone of the
+ // original request before it's either modified by a requestWillFetch
+ // plugin or before the original request's body is consumed via fetch().
+ const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null;
+ try {
+ for (const cb of this.iterateCallbacks('requestWillFetch')) {
+ request = await cb({
+ request: request.clone(),
+ event
+ });
+ }
+ } catch (err) {
+ if (err instanceof Error) {
+ throw new WorkboxError('plugin-error-request-will-fetch', {
+ thrownErrorMessage: err.message
+ });
+ }
+ }
+ // The request can be altered by plugins with `requestWillFetch` making
+ // the original request (most likely from a `fetch` event) different
+ // from the Request we make. Pass both to `fetchDidFail` to aid debugging.
+ const pluginFilteredRequest = request.clone();
+ try {
+ let fetchResponse;
+ // See https://github.com/GoogleChrome/workbox/issues/1796
+ fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions);
+ if ("development" !== 'production') {
+ logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`);
+ }
+ for (const callback of this.iterateCallbacks('fetchDidSucceed')) {
+ fetchResponse = await callback({
+ event,
+ request: pluginFilteredRequest,
+ response: fetchResponse
+ });
+ }
+ return fetchResponse;
+ } catch (error) {
+ {
+ logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error);
+ }
+ // `originalRequest` will only exist if a `fetchDidFail` callback
+ // is being used (see above).
+ if (originalRequest) {
+ await this.runCallbacks('fetchDidFail', {
+ error: error,
+ event,
+ originalRequest: originalRequest.clone(),
+ request: pluginFilteredRequest.clone()
+ });
+ }
+ throw error;
+ }
+ }
+ /**
+ * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on
+ * the response generated by `this.fetch()`.
+ *
+ * The call to `this.cachePut()` automatically invokes `this.waitUntil()`,
+ * so you do not have to manually call `waitUntil()` on the event.
+ *
+ * @param {Request|string} input The request or URL to fetch and cache.
+ * @return {Promise}
+ */
+ async fetchAndCachePut(input) {
+ const response = await this.fetch(input);
+ const responseClone = response.clone();
+ void this.waitUntil(this.cachePut(input, responseClone));
+ return response;
+ }
+ /**
+ * Matches a request from the cache (and invokes any applicable plugin
+ * callback methods) using the `cacheName`, `matchOptions`, and `plugins`
+ * defined on the strategy object.
+ *
+ * The following plugin lifecycle methods are invoked when using this method:
+ * - cacheKeyWillBeUsed()
+ * - cachedResponseWillBeUsed()
+ *
+ * @param {Request|string} key The Request or URL to use as the cache key.
+ * @return {Promise} A matching response, if found.
+ */
+ async cacheMatch(key) {
+ const request = toRequest(key);
+ let cachedResponse;
+ const {
+ cacheName,
+ matchOptions
+ } = this._strategy;
+ const effectiveRequest = await this.getCacheKey(request, 'read');
+ const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), {
+ cacheName
+ });
+ cachedResponse = await caches.match(effectiveRequest, multiMatchOptions);
+ {
+ if (cachedResponse) {
+ logger.debug(`Found a cached response in '${cacheName}'.`);
+ } else {
+ logger.debug(`No cached response found in '${cacheName}'.`);
+ }
+ }
+ for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) {
+ cachedResponse = (await callback({
+ cacheName,
+ matchOptions,
+ cachedResponse,
+ request: effectiveRequest,
+ event: this.event
+ })) || undefined;
+ }
+ return cachedResponse;
+ }
+ /**
+ * Puts a request/response pair in the cache (and invokes any applicable
+ * plugin callback methods) using the `cacheName` and `plugins` defined on
+ * the strategy object.
+ *
+ * The following plugin lifecycle methods are invoked when using this method:
+ * - cacheKeyWillBeUsed()
+ * - cacheWillUpdate()
+ * - cacheDidUpdate()
+ *
+ * @param {Request|string} key The request or URL to use as the cache key.
+ * @param {Response} response The response to cache.
+ * @return {Promise} `false` if a cacheWillUpdate caused the response
+ * not be cached, and `true` otherwise.
+ */
+ async cachePut(key, response) {
+ const request = toRequest(key);
+ // Run in the next task to avoid blocking other cache reads.
+ // https://github.com/w3c/ServiceWorker/issues/1397
+ await timeout(0);
+ const effectiveRequest = await this.getCacheKey(request, 'write');
+ {
+ if (effectiveRequest.method && effectiveRequest.method !== 'GET') {
+ throw new WorkboxError('attempt-to-cache-non-get-request', {
+ url: getFriendlyURL(effectiveRequest.url),
+ method: effectiveRequest.method
+ });
+ }
+ // See https://github.com/GoogleChrome/workbox/issues/2818
+ const vary = response.headers.get('Vary');
+ if (vary) {
+ logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`);
+ }
+ }
+ if (!response) {
+ {
+ logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`);
+ }
+ throw new WorkboxError('cache-put-with-no-response', {
+ url: getFriendlyURL(effectiveRequest.url)
+ });
+ }
+ const responseToCache = await this._ensureResponseSafeToCache(response);
+ if (!responseToCache) {
+ {
+ logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache);
+ }
+ return false;
+ }
+ const {
+ cacheName,
+ matchOptions
+ } = this._strategy;
+ const cache = await self.caches.open(cacheName);
+ const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate');
+ const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams(
+ // TODO(philipwalton): the `__WB_REVISION__` param is a precaching
+ // feature. Consider into ways to only add this behavior if using
+ // precaching.
+ cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null;
+ {
+ logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`);
+ }
+ try {
+ await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache);
+ } catch (error) {
+ if (error instanceof Error) {
+ // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError
+ if (error.name === 'QuotaExceededError') {
+ await executeQuotaErrorCallbacks();
+ }
+ throw error;
+ }
+ }
+ for (const callback of this.iterateCallbacks('cacheDidUpdate')) {
+ await callback({
+ cacheName,
+ oldResponse,
+ newResponse: responseToCache.clone(),
+ request: effectiveRequest,
+ event: this.event
+ });
+ }
+ return true;
+ }
+ /**
+ * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and
+ * executes any of those callbacks found in sequence. The final `Request`
+ * object returned by the last plugin is treated as the cache key for cache
+ * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have
+ * been registered, the passed request is returned unmodified
+ *
+ * @param {Request} request
+ * @param {string} mode
+ * @return {Promise}
+ */
+ async getCacheKey(request, mode) {
+ const key = `${request.url} | ${mode}`;
+ if (!this._cacheKeys[key]) {
+ let effectiveRequest = request;
+ for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) {
+ effectiveRequest = toRequest(await callback({
+ mode,
+ request: effectiveRequest,
+ event: this.event,
+ // params has a type any can't change right now.
+ params: this.params // eslint-disable-line
+ }));
+ }
+ this._cacheKeys[key] = effectiveRequest;
+ }
+ return this._cacheKeys[key];
+ }
+ /**
+ * Returns true if the strategy has at least one plugin with the given
+ * callback.
+ *
+ * @param {string} name The name of the callback to check for.
+ * @return {boolean}
+ */
+ hasCallback(name) {
+ for (const plugin of this._strategy.plugins) {
+ if (name in plugin) {
+ return true;
+ }
+ }
+ return false;
+ }
+ /**
+ * Runs all plugin callbacks matching the given name, in order, passing the
+ * given param object (merged ith the current plugin state) as the only
+ * argument.
+ *
+ * Note: since this method runs all plugins, it's not suitable for cases
+ * where the return value of a callback needs to be applied prior to calling
+ * the next callback. See
+ * {@link workbox-strategies.StrategyHandler#iterateCallbacks}
+ * below for how to handle that case.
+ *
+ * @param {string} name The name of the callback to run within each plugin.
+ * @param {Object} param The object to pass as the first (and only) param
+ * when executing each callback. This object will be merged with the
+ * current plugin state prior to callback execution.
+ */
+ async runCallbacks(name, param) {
+ for (const callback of this.iterateCallbacks(name)) {
+ // TODO(philipwalton): not sure why `any` is needed. It seems like
+ // this should work with `as WorkboxPluginCallbackParam[C]`.
+ await callback(param);
+ }
+ }
+ /**
+ * Accepts a callback and returns an iterable of matching plugin callbacks,
+ * where each callback is wrapped with the current handler state (i.e. when
+ * you call each callback, whatever object parameter you pass it will
+ * be merged with the plugin's current state).
+ *
+ * @param {string} name The name fo the callback to run
+ * @return {Array}
+ */
+ *iterateCallbacks(name) {
+ for (const plugin of this._strategy.plugins) {
+ if (typeof plugin[name] === 'function') {
+ const state = this._pluginStateMap.get(plugin);
+ const statefulCallback = param => {
+ const statefulParam = Object.assign(Object.assign({}, param), {
+ state
+ });
+ // TODO(philipwalton): not sure why `any` is needed. It seems like
+ // this should work with `as WorkboxPluginCallbackParam[C]`.
+ return plugin[name](statefulParam);
+ };
+ yield statefulCallback;
+ }
+ }
+ }
+ /**
+ * Adds a promise to the
+ * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises}
+ * of the event associated with the request being handled (usually a
+ * `FetchEvent`).
+ *
+ * Note: you can await
+ * {@link workbox-strategies.StrategyHandler~doneWaiting}
+ * to know when all added promises have settled.
+ *
+ * @param {Promise} promise A promise to add to the extend lifetime promises
+ * of the event that triggered the request.
+ */
+ waitUntil(promise) {
+ this._extendLifetimePromises.push(promise);
+ return promise;
+ }
+ /**
+ * Returns a promise that resolves once all promises passed to
+ * {@link workbox-strategies.StrategyHandler~waitUntil}
+ * have settled.
+ *
+ * Note: any work done after `doneWaiting()` settles should be manually
+ * passed to an event's `waitUntil()` method (not this handler's
+ * `waitUntil()` method), otherwise the service worker thread may be killed
+ * prior to your work completing.
+ */
+ async doneWaiting() {
+ while (this._extendLifetimePromises.length) {
+ const promises = this._extendLifetimePromises.splice(0);
+ const result = await Promise.allSettled(promises);
+ const firstRejection = result.find(i => i.status === 'rejected');
+ if (firstRejection) {
+ throw firstRejection.reason;
+ }
+ }
+ }
+ /**
+ * Stops running the strategy and immediately resolves any pending
+ * `waitUntil()` promises.
+ */
+ destroy() {
+ this._handlerDeferred.resolve(null);
+ }
+ /**
+ * This method will call cacheWillUpdate on the available plugins (or use
+ * status === 200) to determine if the Response is safe and valid to cache.
+ *
+ * @param {Request} options.request
+ * @param {Response} options.response
+ * @return {Promise}
+ *
+ * @private
+ */
+ async _ensureResponseSafeToCache(response) {
+ let responseToCache = response;
+ let pluginsUsed = false;
+ for (const callback of this.iterateCallbacks('cacheWillUpdate')) {
+ responseToCache = (await callback({
+ request: this.request,
+ response: responseToCache,
+ event: this.event
+ })) || undefined;
+ pluginsUsed = true;
+ if (!responseToCache) {
+ break;
+ }
+ }
+ if (!pluginsUsed) {
+ if (responseToCache && responseToCache.status !== 200) {
+ responseToCache = undefined;
+ }
+ {
+ if (responseToCache) {
+ if (responseToCache.status !== 200) {
+ if (responseToCache.status === 0) {
+ logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`);
+ } else {
+ logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`);
+ }
+ }
+ }
+ }
+ }
+ return responseToCache;
+ }
+ }
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * An abstract base class that all other strategy classes must extend from:
+ *
+ * @memberof workbox-strategies
+ */
+ class Strategy {
+ /**
+ * Creates a new instance of the strategy and sets all documented option
+ * properties as public instance properties.
+ *
+ * Note: if a custom strategy class extends the base Strategy class and does
+ * not need more than these properties, it does not need to define its own
+ * constructor.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.cacheName] Cache name to store and retrieve
+ * requests. Defaults to the cache names provided by
+ * {@link workbox-core.cacheNames}.
+ * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}
+ * to use in conjunction with this caching strategy.
+ * @param {Object} [options.fetchOptions] Values passed along to the
+ * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)
+ * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796)
+ * `fetch()` requests made by this strategy.
+ * @param {Object} [options.matchOptions] The
+ * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions}
+ * for any `cache.match()` or `cache.put()` calls made by this strategy.
+ */
+ constructor(options = {}) {
+ /**
+ * Cache name to store and retrieve
+ * requests. Defaults to the cache names provided by
+ * {@link workbox-core.cacheNames}.
+ *
+ * @type {string}
+ */
+ this.cacheName = cacheNames.getRuntimeName(options.cacheName);
+ /**
+ * The list
+ * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}
+ * used by this strategy.
+ *
+ * @type {Array}
+ */
+ this.plugins = options.plugins || [];
+ /**
+ * Values passed along to the
+ * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters}
+ * of all fetch() requests made by this strategy.
+ *
+ * @type {Object}
+ */
+ this.fetchOptions = options.fetchOptions;
+ /**
+ * The
+ * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions}
+ * for any `cache.match()` or `cache.put()` calls made by this strategy.
+ *
+ * @type {Object}
+ */
+ this.matchOptions = options.matchOptions;
+ }
+ /**
+ * Perform a request strategy and returns a `Promise` that will resolve with
+ * a `Response`, invoking all relevant plugin callbacks.
+ *
+ * When a strategy instance is registered with a Workbox
+ * {@link workbox-routing.Route}, this method is automatically
+ * called when the route matches.
+ *
+ * Alternatively, this method can be used in a standalone `FetchEvent`
+ * listener by passing it to `event.respondWith()`.
+ *
+ * @param {FetchEvent|Object} options A `FetchEvent` or an object with the
+ * properties listed below.
+ * @param {Request|string} options.request A request to run this strategy for.
+ * @param {ExtendableEvent} options.event The event associated with the
+ * request.
+ * @param {URL} [options.url]
+ * @param {*} [options.params]
+ */
+ handle(options) {
+ const [responseDone] = this.handleAll(options);
+ return responseDone;
+ }
+ /**
+ * Similar to {@link workbox-strategies.Strategy~handle}, but
+ * instead of just returning a `Promise` that resolves to a `Response` it
+ * it will return an tuple of `[response, done]` promises, where the former
+ * (`response`) is equivalent to what `handle()` returns, and the latter is a
+ * Promise that will resolve once any promises that were added to
+ * `event.waitUntil()` as part of performing the strategy have completed.
+ *
+ * You can await the `done` promise to ensure any extra work performed by
+ * the strategy (usually caching responses) completes successfully.
+ *
+ * @param {FetchEvent|Object} options A `FetchEvent` or an object with the
+ * properties listed below.
+ * @param {Request|string} options.request A request to run this strategy for.
+ * @param {ExtendableEvent} options.event The event associated with the
+ * request.
+ * @param {URL} [options.url]
+ * @param {*} [options.params]
+ * @return {Array} A tuple of [response, done]
+ * promises that can be used to determine when the response resolves as
+ * well as when the handler has completed all its work.
+ */
+ handleAll(options) {
+ // Allow for flexible options to be passed.
+ if (options instanceof FetchEvent) {
+ options = {
+ event: options,
+ request: options.request
+ };
+ }
+ const event = options.event;
+ const request = typeof options.request === 'string' ? new Request(options.request) : options.request;
+ const params = 'params' in options ? options.params : undefined;
+ const handler = new StrategyHandler(this, {
+ event,
+ request,
+ params
+ });
+ const responseDone = this._getResponse(handler, request, event);
+ const handlerDone = this._awaitComplete(responseDone, handler, request, event);
+ // Return an array of promises, suitable for use with Promise.all().
+ return [responseDone, handlerDone];
+ }
+ async _getResponse(handler, request, event) {
+ await handler.runCallbacks('handlerWillStart', {
+ event,
+ request
+ });
+ let response = undefined;
+ try {
+ response = await this._handle(request, handler);
+ // The "official" Strategy subclasses all throw this error automatically,
+ // but in case a third-party Strategy doesn't, ensure that we have a
+ // consistent failure when there's no response or an error response.
+ if (!response || response.type === 'error') {
+ throw new WorkboxError('no-response', {
+ url: request.url
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ for (const callback of handler.iterateCallbacks('handlerDidError')) {
+ response = await callback({
+ error,
+ event,
+ request
+ });
+ if (response) {
+ break;
+ }
+ }
+ }
+ if (!response) {
+ throw error;
+ } else {
+ logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`);
+ }
+ }
+ for (const callback of handler.iterateCallbacks('handlerWillRespond')) {
+ response = await callback({
+ event,
+ request,
+ response
+ });
+ }
+ return response;
+ }
+ async _awaitComplete(responseDone, handler, request, event) {
+ let response;
+ let error;
+ try {
+ response = await responseDone;
+ } catch (error) {
+ // Ignore errors, as response errors should be caught via the `response`
+ // promise above. The `done` promise will only throw for errors in
+ // promises passed to `handler.waitUntil()`.
+ }
+ try {
+ await handler.runCallbacks('handlerDidRespond', {
+ event,
+ request,
+ response
+ });
+ await handler.doneWaiting();
+ } catch (waitUntilError) {
+ if (waitUntilError instanceof Error) {
+ error = waitUntilError;
+ }
+ }
+ await handler.runCallbacks('handlerDidComplete', {
+ event,
+ request,
+ response,
+ error: error
+ });
+ handler.destroy();
+ if (error) {
+ throw error;
+ }
+ }
+ }
+ /**
+ * Classes extending the `Strategy` based class should implement this method,
+ * and leverage the {@link workbox-strategies.StrategyHandler}
+ * arg to perform all fetching and cache logic, which will ensure all relevant
+ * cache, cache options, fetch options and plugins are used (per the current
+ * strategy instance).
+ *
+ * @name _handle
+ * @instance
+ * @abstract
+ * @function
+ * @param {Request} request
+ * @param {workbox-strategies.StrategyHandler} handler
+ * @return {Promise}
+ *
+ * @memberof workbox-strategies.Strategy
+ */
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A {@link workbox-strategies.Strategy} implementation
+ * specifically designed to work with
+ * {@link workbox-precaching.PrecacheController}
+ * to both cache and fetch precached assets.
+ *
+ * Note: an instance of this class is created automatically when creating a
+ * `PrecacheController`; it's generally not necessary to create this yourself.
+ *
+ * @extends workbox-strategies.Strategy
+ * @memberof workbox-precaching
+ */
+ class PrecacheStrategy extends Strategy {
+ /**
+ *
+ * @param {Object} [options]
+ * @param {string} [options.cacheName] Cache name to store and retrieve
+ * requests. Defaults to the cache names provided by
+ * {@link workbox-core.cacheNames}.
+ * @param {Array} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins}
+ * to use in conjunction with this caching strategy.
+ * @param {Object} [options.fetchOptions] Values passed along to the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init}
+ * of all fetch() requests made by this strategy.
+ * @param {Object} [options.matchOptions] The
+ * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions}
+ * for any `cache.match()` or `cache.put()` calls made by this strategy.
+ * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to
+ * get the response from the network if there's a precache miss.
+ */
+ constructor(options = {}) {
+ options.cacheName = cacheNames.getPrecacheName(options.cacheName);
+ super(options);
+ this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true;
+ // Redirected responses cannot be used to satisfy a navigation request, so
+ // any redirected response must be "copied" rather than cloned, so the new
+ // response doesn't contain the `redirected` flag. See:
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1
+ this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin);
+ }
+ /**
+ * @private
+ * @param {Request|string} request A request to run this strategy for.
+ * @param {workbox-strategies.StrategyHandler} handler The event that
+ * triggered the request.
+ * @return {Promise}
+ */
+ async _handle(request, handler) {
+ const response = await handler.cacheMatch(request);
+ if (response) {
+ return response;
+ }
+ // If this is an `install` event for an entry that isn't already cached,
+ // then populate the cache.
+ if (handler.event && handler.event.type === 'install') {
+ return await this._handleInstall(request, handler);
+ }
+ // Getting here means something went wrong. An entry that should have been
+ // precached wasn't found in the cache.
+ return await this._handleFetch(request, handler);
+ }
+ async _handleFetch(request, handler) {
+ let response;
+ const params = handler.params || {};
+ // Fall back to the network if we're configured to do so.
+ if (this._fallbackToNetwork) {
+ {
+ logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`);
+ }
+ const integrityInManifest = params.integrity;
+ const integrityInRequest = request.integrity;
+ const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest;
+ // Do not add integrity if the original request is no-cors
+ // See https://github.com/GoogleChrome/workbox/issues/3096
+ response = await handler.fetch(new Request(request, {
+ integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined
+ }));
+ // It's only "safe" to repair the cache if we're using SRI to guarantee
+ // that the response matches the precache manifest's expectations,
+ // and there's either a) no integrity property in the incoming request
+ // or b) there is an integrity, and it matches the precache manifest.
+ // See https://github.com/GoogleChrome/workbox/issues/2858
+ // Also if the original request users no-cors we don't use integrity.
+ // See https://github.com/GoogleChrome/workbox/issues/3096
+ if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') {
+ this._useDefaultCacheabilityPluginIfNeeded();
+ const wasCached = await handler.cachePut(request, response.clone());
+ {
+ if (wasCached) {
+ logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`);
+ }
+ }
+ }
+ } else {
+ // This shouldn't normally happen, but there are edge cases:
+ // https://github.com/GoogleChrome/workbox/issues/1441
+ throw new WorkboxError('missing-precache-entry', {
+ cacheName: this.cacheName,
+ url: request.url
+ });
+ }
+ {
+ const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read'));
+ // Workbox is going to handle the route.
+ // print the routing details to the console.
+ logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url));
+ logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`);
+ logger.groupCollapsed(`View request details here.`);
+ logger.log(request);
+ logger.groupEnd();
+ logger.groupCollapsed(`View response details here.`);
+ logger.log(response);
+ logger.groupEnd();
+ logger.groupEnd();
+ }
+ return response;
+ }
+ async _handleInstall(request, handler) {
+ this._useDefaultCacheabilityPluginIfNeeded();
+ const response = await handler.fetch(request);
+ // Make sure we defer cachePut() until after we know the response
+ // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737
+ const wasCached = await handler.cachePut(request, response.clone());
+ if (!wasCached) {
+ // Throwing here will lead to the `install` handler failing, which
+ // we want to do if *any* of the responses aren't safe to cache.
+ throw new WorkboxError('bad-precaching-response', {
+ url: request.url,
+ status: response.status
+ });
+ }
+ return response;
+ }
+ /**
+ * This method is complex, as there a number of things to account for:
+ *
+ * The `plugins` array can be set at construction, and/or it might be added to
+ * to at any time before the strategy is used.
+ *
+ * At the time the strategy is used (i.e. during an `install` event), there
+ * needs to be at least one plugin that implements `cacheWillUpdate` in the
+ * array, other than `copyRedirectedCacheableResponsesPlugin`.
+ *
+ * - If this method is called and there are no suitable `cacheWillUpdate`
+ * plugins, we need to add `defaultPrecacheCacheabilityPlugin`.
+ *
+ * - If this method is called and there is exactly one `cacheWillUpdate`, then
+ * we don't have to do anything (this might be a previously added
+ * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin).
+ *
+ * - If this method is called and there is more than one `cacheWillUpdate`,
+ * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so,
+ * we need to remove it. (This situation is unlikely, but it could happen if
+ * the strategy is used multiple times, the first without a `cacheWillUpdate`,
+ * and then later on after manually adding a custom `cacheWillUpdate`.)
+ *
+ * See https://github.com/GoogleChrome/workbox/issues/2737 for more context.
+ *
+ * @private
+ */
+ _useDefaultCacheabilityPluginIfNeeded() {
+ let defaultPluginIndex = null;
+ let cacheWillUpdatePluginCount = 0;
+ for (const [index, plugin] of this.plugins.entries()) {
+ // Ignore the copy redirected plugin when determining what to do.
+ if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) {
+ continue;
+ }
+ // Save the default plugin's index, in case it needs to be removed.
+ if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) {
+ defaultPluginIndex = index;
+ }
+ if (plugin.cacheWillUpdate) {
+ cacheWillUpdatePluginCount++;
+ }
+ }
+ if (cacheWillUpdatePluginCount === 0) {
+ this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin);
+ } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) {
+ // Only remove the default plugin; multiple custom plugins are allowed.
+ this.plugins.splice(defaultPluginIndex, 1);
+ }
+ // Nothing needs to be done if cacheWillUpdatePluginCount is 1
+ }
+ }
+ PrecacheStrategy.defaultPrecacheCacheabilityPlugin = {
+ async cacheWillUpdate({
+ response
+ }) {
+ if (!response || response.status >= 400) {
+ return null;
+ }
+ return response;
+ }
+ };
+ PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = {
+ async cacheWillUpdate({
+ response
+ }) {
+ return response.redirected ? await copyResponse(response) : response;
+ }
+ };
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Performs efficient precaching of assets.
+ *
+ * @memberof workbox-precaching
+ */
+ class PrecacheController {
+ /**
+ * Create a new PrecacheController.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.cacheName] The cache to use for precaching.
+ * @param {string} [options.plugins] Plugins to use when precaching as well
+ * as responding to fetch events for precached assets.
+ * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to
+ * get the response from the network if there's a precache miss.
+ */
+ constructor({
+ cacheName,
+ plugins = [],
+ fallbackToNetwork = true
+ } = {}) {
+ this._urlsToCacheKeys = new Map();
+ this._urlsToCacheModes = new Map();
+ this._cacheKeysToIntegrities = new Map();
+ this._strategy = new PrecacheStrategy({
+ cacheName: cacheNames.getPrecacheName(cacheName),
+ plugins: [...plugins, new PrecacheCacheKeyPlugin({
+ precacheController: this
+ })],
+ fallbackToNetwork
+ });
+ // Bind the install and activate methods to the instance.
+ this.install = this.install.bind(this);
+ this.activate = this.activate.bind(this);
+ }
+ /**
+ * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and
+ * used to cache assets and respond to fetch events.
+ */
+ get strategy() {
+ return this._strategy;
+ }
+ /**
+ * Adds items to the precache list, removing any duplicates and
+ * stores the files in the
+ * {@link workbox-core.cacheNames|"precache cache"} when the service
+ * worker installs.
+ *
+ * This method can be called multiple times.
+ *
+ * @param {Array} [entries=[]] Array of entries to precache.
+ */
+ precache(entries) {
+ this.addToCacheList(entries);
+ if (!this._installAndActiveListenersAdded) {
+ self.addEventListener('install', this.install);
+ self.addEventListener('activate', this.activate);
+ this._installAndActiveListenersAdded = true;
+ }
+ }
+ /**
+ * This method will add items to the precache list, removing duplicates
+ * and ensuring the information is valid.
+ *
+ * @param {Array} entries
+ * Array of entries to precache.
+ */
+ addToCacheList(entries) {
+ {
+ finalAssertExports.isArray(entries, {
+ moduleName: 'workbox-precaching',
+ className: 'PrecacheController',
+ funcName: 'addToCacheList',
+ paramName: 'entries'
+ });
+ }
+ const urlsToWarnAbout = [];
+ for (const entry of entries) {
+ // See https://github.com/GoogleChrome/workbox/issues/2259
+ if (typeof entry === 'string') {
+ urlsToWarnAbout.push(entry);
+ } else if (entry && entry.revision === undefined) {
+ urlsToWarnAbout.push(entry.url);
+ }
+ const {
+ cacheKey,
+ url
+ } = createCacheKey(entry);
+ const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default';
+ if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) {
+ throw new WorkboxError('add-to-cache-list-conflicting-entries', {
+ firstEntry: this._urlsToCacheKeys.get(url),
+ secondEntry: cacheKey
+ });
+ }
+ if (typeof entry !== 'string' && entry.integrity) {
+ if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) {
+ throw new WorkboxError('add-to-cache-list-conflicting-integrities', {
+ url
+ });
+ }
+ this._cacheKeysToIntegrities.set(cacheKey, entry.integrity);
+ }
+ this._urlsToCacheKeys.set(url, cacheKey);
+ this._urlsToCacheModes.set(url, cacheMode);
+ if (urlsToWarnAbout.length > 0) {
+ const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`;
+ {
+ logger.warn(warningMessage);
+ }
+ }
+ }
+ }
+ /**
+ * Precaches new and updated assets. Call this method from the service worker
+ * install event.
+ *
+ * Note: this method calls `event.waitUntil()` for you, so you do not need
+ * to call it yourself in your event handlers.
+ *
+ * @param {ExtendableEvent} event
+ * @return {Promise}
+ */
+ install(event) {
+ // waitUntil returns Promise
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return waitUntil(event, async () => {
+ const installReportPlugin = new PrecacheInstallReportPlugin();
+ this.strategy.plugins.push(installReportPlugin);
+ // Cache entries one at a time.
+ // See https://github.com/GoogleChrome/workbox/issues/2528
+ for (const [url, cacheKey] of this._urlsToCacheKeys) {
+ const integrity = this._cacheKeysToIntegrities.get(cacheKey);
+ const cacheMode = this._urlsToCacheModes.get(url);
+ const request = new Request(url, {
+ integrity,
+ cache: cacheMode,
+ credentials: 'same-origin'
+ });
+ await Promise.all(this.strategy.handleAll({
+ params: {
+ cacheKey
+ },
+ request,
+ event
+ }));
+ }
+ const {
+ updatedURLs,
+ notUpdatedURLs
+ } = installReportPlugin;
+ {
+ printInstallDetails(updatedURLs, notUpdatedURLs);
+ }
+ return {
+ updatedURLs,
+ notUpdatedURLs
+ };
+ });
+ }
+ /**
+ * Deletes assets that are no longer present in the current precache manifest.
+ * Call this method from the service worker activate event.
+ *
+ * Note: this method calls `event.waitUntil()` for you, so you do not need
+ * to call it yourself in your event handlers.
+ *
+ * @param {ExtendableEvent} event
+ * @return {Promise}
+ */
+ activate(event) {
+ // waitUntil returns Promise
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return waitUntil(event, async () => {
+ const cache = await self.caches.open(this.strategy.cacheName);
+ const currentlyCachedRequests = await cache.keys();
+ const expectedCacheKeys = new Set(this._urlsToCacheKeys.values());
+ const deletedURLs = [];
+ for (const request of currentlyCachedRequests) {
+ if (!expectedCacheKeys.has(request.url)) {
+ await cache.delete(request);
+ deletedURLs.push(request.url);
+ }
+ }
+ {
+ printCleanupDetails(deletedURLs);
+ }
+ return {
+ deletedURLs
+ };
+ });
+ }
+ /**
+ * Returns a mapping of a precached URL to the corresponding cache key, taking
+ * into account the revision information for the URL.
+ *
+ * @return {Map} A URL to cache key mapping.
+ */
+ getURLsToCacheKeys() {
+ return this._urlsToCacheKeys;
+ }
+ /**
+ * Returns a list of all the URLs that have been precached by the current
+ * service worker.
+ *
+ * @return {Array} The precached URLs.
+ */
+ getCachedURLs() {
+ return [...this._urlsToCacheKeys.keys()];
+ }
+ /**
+ * Returns the cache key used for storing a given URL. If that URL is
+ * unversioned, like `/index.html', then the cache key will be the original
+ * URL with a search parameter appended to it.
+ *
+ * @param {string} url A URL whose cache key you want to look up.
+ * @return {string} The versioned URL that corresponds to a cache key
+ * for the original URL, or undefined if that URL isn't precached.
+ */
+ getCacheKeyForURL(url) {
+ const urlObject = new URL(url, location.href);
+ return this._urlsToCacheKeys.get(urlObject.href);
+ }
+ /**
+ * @param {string} url A cache key whose SRI you want to look up.
+ * @return {string} The subresource integrity associated with the cache key,
+ * or undefined if it's not set.
+ */
+ getIntegrityForCacheKey(cacheKey) {
+ return this._cacheKeysToIntegrities.get(cacheKey);
+ }
+ /**
+ * This acts as a drop-in replacement for
+ * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match)
+ * with the following differences:
+ *
+ * - It knows what the name of the precache is, and only checks in that cache.
+ * - It allows you to pass in an "original" URL without versioning parameters,
+ * and it will automatically look up the correct cache key for the currently
+ * active revision of that URL.
+ *
+ * E.g., `matchPrecache('index.html')` will find the correct precached
+ * response for the currently active service worker, even if the actual cache
+ * key is `'/index.html?__WB_REVISION__=1234abcd'`.
+ *
+ * @param {string|Request} request The key (without revisioning parameters)
+ * to look up in the precache.
+ * @return {Promise}
+ */
+ async matchPrecache(request) {
+ const url = request instanceof Request ? request.url : request;
+ const cacheKey = this.getCacheKeyForURL(url);
+ if (cacheKey) {
+ const cache = await self.caches.open(this.strategy.cacheName);
+ return cache.match(cacheKey);
+ }
+ return undefined;
+ }
+ /**
+ * Returns a function that looks up `url` in the precache (taking into
+ * account revision information), and returns the corresponding `Response`.
+ *
+ * @param {string} url The precached URL which will be used to lookup the
+ * `Response`.
+ * @return {workbox-routing~handlerCallback}
+ */
+ createHandlerBoundToURL(url) {
+ const cacheKey = this.getCacheKeyForURL(url);
+ if (!cacheKey) {
+ throw new WorkboxError('non-precached-url', {
+ url
+ });
+ }
+ return options => {
+ options.request = new Request(url);
+ options.params = Object.assign({
+ cacheKey
+ }, options.params);
+ return this.strategy.handle(options);
+ };
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ let precacheController;
+ /**
+ * @return {PrecacheController}
+ * @private
+ */
+ const getOrCreatePrecacheController = () => {
+ if (!precacheController) {
+ precacheController = new PrecacheController();
+ }
+ return precacheController;
+ };
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Removes any URL search parameters that should be ignored.
+ *
+ * @param {URL} urlObject The original URL.
+ * @param {Array} ignoreURLParametersMatching RegExps to test against
+ * each search parameter name. Matches mean that the search parameter should be
+ * ignored.
+ * @return {URL} The URL with any ignored search parameters removed.
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) {
+ // Convert the iterable into an array at the start of the loop to make sure
+ // deletion doesn't mess up iteration.
+ for (const paramName of [...urlObject.searchParams.keys()]) {
+ if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) {
+ urlObject.searchParams.delete(paramName);
+ }
+ }
+ return urlObject;
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Generator function that yields possible variations on the original URL to
+ * check, one at a time.
+ *
+ * @param {string} url
+ * @param {Object} options
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ function* generateURLVariations(url, {
+ ignoreURLParametersMatching = [/^utm_/, /^fbclid$/],
+ directoryIndex = 'index.html',
+ cleanURLs = true,
+ urlManipulation
+ } = {}) {
+ const urlObject = new URL(url, location.href);
+ urlObject.hash = '';
+ yield urlObject.href;
+ const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching);
+ yield urlWithoutIgnoredParams.href;
+ if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) {
+ const directoryURL = new URL(urlWithoutIgnoredParams.href);
+ directoryURL.pathname += directoryIndex;
+ yield directoryURL.href;
+ }
+ if (cleanURLs) {
+ const cleanURL = new URL(urlWithoutIgnoredParams.href);
+ cleanURL.pathname += '.html';
+ yield cleanURL.href;
+ }
+ if (urlManipulation) {
+ const additionalURLs = urlManipulation({
+ url: urlObject
+ });
+ for (const urlToAttempt of additionalURLs) {
+ yield urlToAttempt.href;
+ }
+ }
+ }
+
+ /*
+ Copyright 2020 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * A subclass of {@link workbox-routing.Route} that takes a
+ * {@link workbox-precaching.PrecacheController}
+ * instance and uses it to match incoming requests and handle fetching
+ * responses from the precache.
+ *
+ * @memberof workbox-precaching
+ * @extends workbox-routing.Route
+ */
+ class PrecacheRoute extends Route {
+ /**
+ * @param {PrecacheController} precacheController A `PrecacheController`
+ * instance used to both match requests and respond to fetch events.
+ * @param {Object} [options] Options to control how requests are matched
+ * against the list of precached URLs.
+ * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will
+ * check cache entries for a URLs ending with '/' to see if there is a hit when
+ * appending the `directoryIndex` value.
+ * @param {Array} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An
+ * array of regex's to remove search params when looking for a cache match.
+ * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will
+ * check the cache for the URL with a `.html` added to the end of the end.
+ * @param {workbox-precaching~urlManipulation} [options.urlManipulation]
+ * This is a function that should take a URL and return an array of
+ * alternative URLs that should be checked for precache matches.
+ */
+ constructor(precacheController, options) {
+ const match = ({
+ request
+ }) => {
+ const urlsToCacheKeys = precacheController.getURLsToCacheKeys();
+ for (const possibleURL of generateURLVariations(request.url, options)) {
+ const cacheKey = urlsToCacheKeys.get(possibleURL);
+ if (cacheKey) {
+ const integrity = precacheController.getIntegrityForCacheKey(cacheKey);
+ return {
+ cacheKey,
+ integrity
+ };
+ }
+ }
+ {
+ logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url));
+ }
+ return;
+ };
+ super(match, precacheController.strategy);
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Add a `fetch` listener to the service worker that will
+ * respond to
+ * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests}
+ * with precached assets.
+ *
+ * Requests for assets that aren't precached, the `FetchEvent` will not be
+ * responded to, allowing the event to fall through to other `fetch` event
+ * listeners.
+ *
+ * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute}
+ * options.
+ *
+ * @memberof workbox-precaching
+ */
+ function addRoute(options) {
+ const precacheController = getOrCreatePrecacheController();
+ const precacheRoute = new PrecacheRoute(precacheController, options);
+ registerRoute(precacheRoute);
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Adds items to the precache list, removing any duplicates and
+ * stores the files in the
+ * {@link workbox-core.cacheNames|"precache cache"} when the service
+ * worker installs.
+ *
+ * This method can be called multiple times.
+ *
+ * Please note: This method **will not** serve any of the cached files for you.
+ * It only precaches files. To respond to a network request you call
+ * {@link workbox-precaching.addRoute}.
+ *
+ * If you have a single array of files to precache, you can just call
+ * {@link workbox-precaching.precacheAndRoute}.
+ *
+ * @param {Array} [entries=[]] Array of entries to precache.
+ *
+ * @memberof workbox-precaching
+ */
+ function precache(entries) {
+ const precacheController = getOrCreatePrecacheController();
+ precacheController.precache(entries);
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * This method will add entries to the precache list and add a route to
+ * respond to fetch events.
+ *
+ * This is a convenience method that will call
+ * {@link workbox-precaching.precache} and
+ * {@link workbox-precaching.addRoute} in a single call.
+ *
+ * @param {Array} entries Array of entries to precache.
+ * @param {Object} [options] See the
+ * {@link workbox-precaching.PrecacheRoute} options.
+ *
+ * @memberof workbox-precaching
+ */
+ function precacheAndRoute(entries, options) {
+ precache(entries);
+ addRoute(options);
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ const SUBSTRING_TO_FIND = '-precache-';
+ /**
+ * Cleans up incompatible precaches that were created by older versions of
+ * Workbox, by a service worker registered under the current scope.
+ *
+ * This is meant to be called as part of the `activate` event.
+ *
+ * This should be safe to use as long as you don't include `substringToFind`
+ * (defaulting to `-precache-`) in your non-precache cache names.
+ *
+ * @param {string} currentPrecacheName The cache name currently in use for
+ * precaching. This cache won't be deleted.
+ * @param {string} [substringToFind='-precache-'] Cache names which include this
+ * substring will be deleted (excluding `currentPrecacheName`).
+ * @return {Array} A list of all the cache names that were deleted.
+ *
+ * @private
+ * @memberof workbox-precaching
+ */
+ const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => {
+ const cacheNames = await self.caches.keys();
+ const cacheNamesToDelete = cacheNames.filter(cacheName => {
+ return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName;
+ });
+ await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName)));
+ return cacheNamesToDelete;
+ };
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Adds an `activate` event listener which will clean up incompatible
+ * precaches that were created by older versions of Workbox.
+ *
+ * @memberof workbox-precaching
+ */
+ function cleanupOutdatedCaches() {
+ // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705
+ self.addEventListener('activate', event => {
+ const cacheName = cacheNames.getPrecacheName();
+ event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => {
+ {
+ if (cachesDeleted.length > 0) {
+ logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted);
+ }
+ }
+ }));
+ });
+ }
+
+ /*
+ Copyright 2018 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * NavigationRoute makes it easy to create a
+ * {@link workbox-routing.Route} that matches for browser
+ * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}.
+ *
+ * It will only match incoming Requests whose
+ * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode}
+ * is set to `navigate`.
+ *
+ * You can optionally only apply this route to a subset of navigation requests
+ * by using one or both of the `denylist` and `allowlist` parameters.
+ *
+ * @memberof workbox-routing
+ * @extends workbox-routing.Route
+ */
+ class NavigationRoute extends Route {
+ /**
+ * If both `denylist` and `allowlist` are provided, the `denylist` will
+ * take precedence and the request will not match this route.
+ *
+ * The regular expressions in `allowlist` and `denylist`
+ * are matched against the concatenated
+ * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname}
+ * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search}
+ * portions of the requested URL.
+ *
+ * *Note*: These RegExps may be evaluated against every destination URL during
+ * a navigation. Avoid using
+ * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077),
+ * or else your users may see delays when navigating your site.
+ *
+ * @param {workbox-routing~handlerCallback} handler A callback
+ * function that returns a Promise resulting in a Response.
+ * @param {Object} options
+ * @param {Array} [options.denylist] If any of these patterns match,
+ * the route will not handle the request (even if a allowlist RegExp matches).
+ * @param {Array} [options.allowlist=[/./]] If any of these patterns
+ * match the URL's pathname and search parameter, the route will handle the
+ * request (assuming the denylist doesn't match).
+ */
+ constructor(handler, {
+ allowlist = [/./],
+ denylist = []
+ } = {}) {
+ {
+ finalAssertExports.isArrayOfClass(allowlist, RegExp, {
+ moduleName: 'workbox-routing',
+ className: 'NavigationRoute',
+ funcName: 'constructor',
+ paramName: 'options.allowlist'
+ });
+ finalAssertExports.isArrayOfClass(denylist, RegExp, {
+ moduleName: 'workbox-routing',
+ className: 'NavigationRoute',
+ funcName: 'constructor',
+ paramName: 'options.denylist'
+ });
+ }
+ super(options => this._match(options), handler);
+ this._allowlist = allowlist;
+ this._denylist = denylist;
+ }
+ /**
+ * Routes match handler.
+ *
+ * @param {Object} options
+ * @param {URL} options.url
+ * @param {Request} options.request
+ * @return {boolean}
+ *
+ * @private
+ */
+ _match({
+ url,
+ request
+ }) {
+ if (request && request.mode !== 'navigate') {
+ return false;
+ }
+ const pathnameAndSearch = url.pathname + url.search;
+ for (const regExp of this._denylist) {
+ if (regExp.test(pathnameAndSearch)) {
+ {
+ logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`);
+ }
+ return false;
+ }
+ }
+ if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) {
+ {
+ logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`);
+ }
+ return true;
+ }
+ {
+ logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`);
+ }
+ return false;
+ }
+ }
+
+ /*
+ Copyright 2019 Google LLC
+
+ Use of this source code is governed by an MIT-style
+ license that can be found in the LICENSE file or at
+ https://opensource.org/licenses/MIT.
+ */
+ /**
+ * Helper function that calls
+ * {@link PrecacheController#createHandlerBoundToURL} on the default
+ * {@link PrecacheController} instance.
+ *
+ * If you are creating your own {@link PrecacheController}, then call the
+ * {@link PrecacheController#createHandlerBoundToURL} on that instance,
+ * instead of using this function.
+ *
+ * @param {string} url The precached URL which will be used to lookup the
+ * `Response`.
+ * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the
+ * response from the network if there's a precache miss.
+ * @return {workbox-routing~handlerCallback}
+ *
+ * @memberof workbox-precaching
+ */
+ function createHandlerBoundToURL(url) {
+ const precacheController = getOrCreatePrecacheController();
+ return precacheController.createHandlerBoundToURL(url);
+ }
+
+ exports.NavigationRoute = NavigationRoute;
+ exports.cleanupOutdatedCaches = cleanupOutdatedCaches;
+ exports.clientsClaim = clientsClaim;
+ exports.createHandlerBoundToURL = createHandlerBoundToURL;
+ exports.precacheAndRoute = precacheAndRoute;
+ exports.registerRoute = registerRoute;
+
+}));
diff --git a/waiter_pwa/eslint.config.js b/waiter_pwa/eslint.config.js
new file mode 100644
index 0000000..4fa125d
--- /dev/null
+++ b/waiter_pwa/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/waiter_pwa/index.html b/waiter_pwa/index.html
index e2f7081..e0bd685 100644
--- a/waiter_pwa/index.html
+++ b/waiter_pwa/index.html
@@ -3,7 +3,7 @@
-
+
waiter_pwa
diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx
index be6f2ec..f168353 100644
--- a/waiter_pwa/src/App.jsx
+++ b/waiter_pwa/src/App.jsx
@@ -1,20 +1,260 @@
-import { useEffect } from 'react'
-import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
+import { useEffect, useState } from 'react'
+import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
+import useShiftStore from './store/shiftStore'
+import useThemeStore from './store/themeStore'
+import useTableColourStore from './store/tableColourStore'
import client from './api/client'
import LoginPage from './pages/LoginPage'
import TableListPage from './pages/TableListPage'
import TableDetailPage from './pages/TableDetailPage'
import AddItemsPage from './pages/AddItemsPage'
import OfflinePage from './pages/OfflinePage'
+import { NotificationProvider } from './context/NotificationContext'
-function ProtectedRoute({ children }) {
- const token = useAuthStore(s => s.token)
- if (!token) return
- return children
+// ─── Utility ─────────────────────────────────────────────────────────────────
+
+function Spinner() {
+ return (
+
+ )
}
-// Rehydrates user object from token on every app load
+// ─── Gate Screens ─────────────────────────────────────────────────────────────
+
+function GateCard({ emoji, title, subtitle, children }) {
+ return (
+
+
+
{emoji}
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {children}
+
+ )
+}
+
+function GateBtn({ onClick, disabled, variant = 'primary', children }) {
+ const base = {
+ height: 44, padding: '0 24px', borderRadius: 12, border: 'none',
+ fontSize: 15, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer',
+ opacity: disabled ? 0.6 : 1, transition: 'opacity 120ms',
+ }
+ const styles = {
+ primary: { background: 'var(--accent)', color: '#0f172a' },
+ secondary: { background: 'var(--bg3)', color: 'var(--text)' },
+ danger: { background: 'var(--danger)', color: '#fff' },
+ }
+ return {children}
+}
+
+function ClosedScreen({ onRefresh, onLogout }) {
+ return (
+
+
+ Ανανέωση
+ Αποσύνδεση
+
+
+ )
+}
+
+function WaitingManagerScreen({ onRefresh, onLogout }) {
+ return (
+
+
+ Ανανέωση
+ Αποσύνδεση
+
+
+ )
+}
+
+function StartShiftScreen({ username, onStart, onLogout }) {
+ const [startingCash, setStartingCash] = useState('')
+ const [starting, setStarting] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleStart() {
+ setStarting(true)
+ setError(null)
+ try {
+ await onStart(startingCash ? parseFloat(startingCash) : null)
+ } catch (e) {
+ setError(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
+ setStarting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Αρχικά μετρητά (προαιρετικό)
+
+
+ €
+ setStartingCash(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleStart()}
+ style={{
+ flex: 1, background: 'var(--bg3)', border: '1px solid var(--border)',
+ borderRadius: 10, padding: '10px 12px',
+ color: 'var(--text)', fontSize: 16, outline: 'none',
+ }}
+ />
+
+
+
+ {error && (
+
{error}
+ )}
+
+
+ {starting ? 'Εκκίνηση…' : '▶ Έναρξη Βάρδιας'}
+
+
+
+
+ Αποσύνδεση
+
+
+ )
+}
+
+// ─── Protected Layout with Shift Gate ────────────────────────────────────────
+
+function AppLayout() {
+ const { token, user, logout } = useAuthStore()
+ const navigate = useNavigate()
+ const {
+ shift, businessDay,
+ setShift, setBusinessDay,
+ setSelfStartAllowed, setSelfEndAllowed,
+ gateStatus, setGateStatus,
+ } = useShiftStore()
+
+ if (!token) return
+
+ const isManager = user?.role && user.role !== 'waiter'
+
+ async function checkGate() {
+ if (!user) return
+ if (isManager) { setGateStatus('ready'); return }
+
+ setGateStatus('loading')
+ try {
+ const dayRes = await client.get('/api/business-day/current')
+ const day = dayRes.data
+ setBusinessDay(day)
+ if (!day) { setGateStatus('closed'); return }
+
+ const shiftRes = await client.get('/api/shifts/my')
+ if (shiftRes.data) {
+ setShift(shiftRes.data)
+ setGateStatus('ready')
+ return
+ }
+
+ // No active shift — check self-start setting
+ try {
+ const settingsRes = await client.get('/api/settings/')
+ const canStart = settingsRes.data?.['shifts.waiter_self_start']?.value !== 'false'
+ const canEnd = settingsRes.data?.['shifts.waiter_self_end']?.value !== 'false'
+ setSelfStartAllowed(canStart)
+ setSelfEndAllowed(canEnd)
+ setGateStatus(canStart ? 'needs_start' : 'waiting_manager')
+ } catch {
+ setSelfStartAllowed(true)
+ setSelfEndAllowed(true)
+ setGateStatus('needs_start')
+ }
+ } catch {
+ setBusinessDay(null)
+ setGateStatus('closed')
+ }
+ }
+
+ useEffect(() => {
+ if (user) checkGate()
+ }, [user?.id])
+
+ // Poll every 15s to detect shift-end or business-day-close triggered by manager
+ useEffect(() => {
+ if (!user || isManager || gateStatus !== 'ready') return
+ const id = setInterval(async () => {
+ try {
+ const dayRes = await client.get('/api/business-day/current')
+ if (!dayRes.data) { setGateStatus('closed'); return }
+ const shiftRes = await client.get('/api/shifts/my')
+ if (!shiftRes.data) {
+ // Shift was ended by manager — rerun full gate check
+ checkGate()
+ }
+ } catch {
+ // network error — ignore, don't lock
+ }
+ }, 15_000)
+ return () => clearInterval(id)
+ }, [user, isManager, gateStatus])
+
+ async function handleStartShift(startingCash) {
+ const res = await client.post('/api/shifts/start', { starting_cash: startingCash })
+ setShift(res.data)
+ setGateStatus('ready')
+ }
+
+ function handleLogout() {
+ logout()
+ navigate('/login')
+ }
+
+ if (!user || gateStatus === 'loading') return
+
+ if (gateStatus === 'closed') return
+ if (gateStatus === 'waiting_manager') return
+ if (gateStatus === 'needs_start') {
+ return (
+
+ )
+ }
+
+ return
+}
+
+// ─── Global helpers ───────────────────────────────────────────────────────────
+
function AuthRehydrator() {
const { token, user, login, logout } = useAuthStore()
useEffect(() => {
@@ -37,19 +277,48 @@ function OfflineListener() {
return null
}
+function ThemeApplier() {
+ const dark = useThemeStore(s => s.dark)
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
+ }, [dark])
+ return null
+}
+
+function ColourLoader() {
+ const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
+ useEffect(() => {
+ client.get('/api/settings/')
+ .then(r => {
+ const raw = r.data?.['ui.table_colours']?.value
+ if (raw) loadFromBackend(raw)
+ })
+ .catch(() => {})
+ }, [])
+ return null
+}
+
+// ─── App ──────────────────────────────────────────────────────────────────────
+
export default function App() {
return (
+
+
-
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+
+ } />
+
+
)
}
diff --git a/waiter_pwa/src/assets/icons/backspace.svg b/waiter_pwa/src/assets/icons/backspace.svg
new file mode 100644
index 0000000..73f44a8
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/backspace.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/categories.svg b/waiter_pwa/src/assets/icons/categories.svg
new file mode 100644
index 0000000..fa41ea8
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/categories.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/waiter_pwa/src/assets/icons/categories2.svg b/waiter_pwa/src/assets/icons/categories2.svg
new file mode 100644
index 0000000..7fd030d
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/categories2.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/flags.svg b/waiter_pwa/src/assets/icons/flags.svg
new file mode 100644
index 0000000..f79702c
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/flags.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/merge.svg b/waiter_pwa/src/assets/icons/merge.svg
new file mode 100644
index 0000000..2833e35
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/merge.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/notifications.svg b/waiter_pwa/src/assets/icons/notifications.svg
new file mode 100644
index 0000000..7e4ba21
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/notifications.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/print.svg b/waiter_pwa/src/assets/icons/print.svg
new file mode 100644
index 0000000..54c4c39
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/print.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/transfer.svg b/waiter_pwa/src/assets/icons/transfer.svg
new file mode 100644
index 0000000..52641ab
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/transfer.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/assets/icons/waiter.svg b/waiter_pwa/src/assets/icons/waiter.svg
new file mode 100644
index 0000000..adb6452
--- /dev/null
+++ b/waiter_pwa/src/assets/icons/waiter.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/waiter_pwa/src/components/Icons.jsx b/waiter_pwa/src/components/Icons.jsx
new file mode 100644
index 0000000..d0a6134
--- /dev/null
+++ b/waiter_pwa/src/components/Icons.jsx
@@ -0,0 +1,46 @@
+// Inline SVG icon components — avoids vite-plugin-svgr dependency.
+// All icons use currentColor so they inherit the surrounding text color.
+
+export function FlagsIcon({ width = 24, height = 24 }) {
+ return (
+
+
+
+ )
+}
+
+export function TransferIcon({ width = 24, height = 24 }) {
+ return (
+
+
+
+
+ )
+}
+
+export function MergeIcon({ width = 24, height = 24 }) {
+ return (
+
+
+
+ )
+}
+
+export function WaiterIcon({ width = 24, height = 24 }) {
+ return (
+
+
+
+
+ )
+}
+
+export function PrintIcon({ width = 24, height = 24 }) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/waiter_pwa/src/components/OrderDrawer.jsx b/waiter_pwa/src/components/OrderDrawer.jsx
new file mode 100644
index 0000000..210b07e
--- /dev/null
+++ b/waiter_pwa/src/components/OrderDrawer.jsx
@@ -0,0 +1,871 @@
+import { useState, useEffect, useCallback } from 'react'
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmt(n) {
+ if (n === 0) return ''
+ const s = n > 0 ? `+${n.toFixed(2)} €` : `${n.toFixed(2)} €`
+ return s
+}
+
+function buildInitialState(product) {
+ const preferenceSets = product.preference_sets || []
+
+ const prefs = {}
+ const subChoices = {}
+ const sharedSubs = {}
+
+ preferenceSets.forEach(ps => {
+ const def = ps.default_choice_id != null
+ ? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
+ : null
+ prefs[ps.id] = def
+ if (def) {
+ if (def.sub_choices?.length > 0) {
+ subChoices[def.id] = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
+ }
+ if (ps.shared_subset?.choices?.length > 0 && !def.disables_subset) {
+ sharedSubs[ps.id] = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
+ }
+ }
+ })
+
+ return { prefs, subChoices, sharedSubs }
+}
+
+// Build sorted favorites list across all item types
+function buildFavorites(product) {
+ const items = []
+ ;(product.quick_options || []).forEach(q => {
+ if (q.is_favorite) items.push({ type: 'quick', item: q, sortOrder: q.favorite_sort_order ?? 0 })
+ })
+ ;(product.ingredients || []).forEach(ing => {
+ if (ing.is_favorite) items.push({ type: 'ingredient', item: ing, sortOrder: ing.favorite_sort_order ?? 0 })
+ })
+ ;(product.options || []).forEach(opt => {
+ if (opt.is_favorite) items.push({ type: 'option', item: opt, sortOrder: opt.favorite_sort_order ?? 0 })
+ })
+ ;(product.preference_sets || []).forEach(ps => {
+ if (ps.is_favorite) items.push({ type: 'pref', item: ps, sortOrder: ps.favorite_sort_order ?? 0 })
+ })
+ return items.sort((a, b) => a.sortOrder - b.sortOrder)
+}
+
+const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
+
+// ── Primitives ────────────────────────────────────────────────────────────────
+
+function Stepper({ value, onChange, min = 0, max = 99 }) {
+ return (
+ e.stopPropagation()}>
+
onChange(Math.max(min, value - 1))} disabled={value <= min}
+ style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? 'var(--muted)' : 'var(--text)' }}>−
+
{value}
+
onChange(Math.min(max, value + 1))} disabled={value >= max}
+ style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? 'var(--muted)' : 'var(--text)' }}>+
+
+ )
+}
+
+function CheckCircle({ selected }) {
+ return (
+
+ )
+}
+
+function RadioDot({ selected }) {
+ return (
+
+ )
+}
+
+function Row({ selected, onClick, children, right, left, style = {} }) {
+ return (
+
+ {left &&
{left}
}
+
{children}
+ {right &&
{right}
}
+
+ )
+}
+
+// ── Shared: single quick option row ──────────────────────────────────────────
+
+function QuickOptionRow({ opt, quickState, setQuickState }) {
+ const qty = quickState[opt.id] || 0
+ const selected = qty > 0
+ const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
+ return (
+
}
+ right={opt.allow_multiple ? (
+ e.stopPropagation()}>
+ {selected
+ ?
setQuickState(s => ({ ...s, [opt.id]: v }))} />
+ : { e.stopPropagation(); setQuickState(s => ({ ...s, [opt.id]: 1 })) }}
+ style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
+
+
+ }
+
+ ) : null}
+ >
+ {opt.name}
+ {opt.price > 0 && +{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}
}
+
+ )
+}
+
+// ── Shared: single extra/option row ──────────────────────────────────────────
+
+function ExtraOptionRow({ opt, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
+ const sel = extrasState[opt.id]
+ const selected = !!sel
+ const open = expandedExtra === opt.id
+ const hasSubs = opt.sub_choices?.length > 0
+ const subLabel = sel ? opt.sub_choices?.find(s => s.name === sel.subName)?.name : null
+
+ const toggle = () => {
+ if (selected) {
+ setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n })
+ if (open) setExpandedExtra(null)
+ } else {
+ const firstSub = hasSubs ? opt.sub_choices[0] : null
+ setExtrasState(s => ({ ...s, [opt.id]: { qty: 1, subName: firstSub?.name ?? null } }))
+ if (hasSubs) setExpandedExtra(opt.id)
+ }
+ }
+
+ return (
+
+
|
}
+ right={
+
e.stopPropagation()}>
+ {opt.allow_multiple && !selected && (
+
{ e.stopPropagation(); toggle() }}
+ style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
+
+
+ )}
+ {selected && opt.allow_multiple && (
+
{
+ if (v === 0) { setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n }); return }
+ setExtrasState(s => ({ ...s, [opt.id]: { ...sel, qty: v } }))
+ }} />
+ )}
+ {selected && hasSubs && (
+ { e.stopPropagation(); setExpandedExtra(open ? null : opt.id) }}
+ style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
+
+
+
+
+ )}
+
+ }
+ >
+
{opt.name}
+
+ {(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)} €` : 'Included'}
+ {subLabel && · {subLabel} }
+
+
+
+ {selected && open && hasSubs && (
+
+
Επιλογή
+ {opt.sub_choices.map((sub, si) => {
+ const isSel = sel.subName === sub.name
+ return (
+
setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))}
+ left={ }>
+
+
{sub.name}
+ {(sub.extra_cost ?? 0) !== 0 &&
+{sub.extra_cost.toFixed(2)} €
}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+// ── Shared: single ingredient row ─────────────────────────────────────────────
+
+function IngredientRow({ ing, removedState, setRemovedState }) {
+ const removed = !!removedState[ing.id]
+ return (
+ setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
+ right={
+
+ {removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
+
+ }>
+ {ing.name}
+
+ )
+}
+
+// ── Shared: single preference set ─────────────────────────────────────────────
+
+function PrefSetBlock({ ps, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
+ const selChoice = prefs[ps.id] ?? null
+ const complete = selChoice != null
+ && !(selChoice.sub_choices?.length > 0 && subChoices[selChoice.id] == null)
+ && !(ps.shared_subset?.choices?.length > 0 && !selChoice.disables_subset && sharedSubs[ps.id] == null)
+ const showShared = ps.shared_subset?.choices?.length > 0 && selChoice != null && !selChoice.disables_subset
+
+ function selectPref(choice) {
+ setPrefs(p => ({ ...p, [ps.id]: choice }))
+ if (choice?.sub_choices?.length > 0) {
+ setSubChoices(s => ({ ...s, [choice.id]: s[choice.id] ?? (choice.sub_choices.find(x => x.is_default) ?? choice.sub_choices[0]) }))
+ }
+ if (ps.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
+ setSharedSubs(s => s[ps.id] != null ? s : { ...s, [ps.id]: ps.shared_subset.choices.find(x => x.is_default) ?? ps.shared_subset.choices[0] })
+ }
+ }
+
+ return (
+
+
+
{ps.name}
+ {!complete &&
Απαιτείται
}
+
+
+ {ps.choices.map(ch => {
+ const isSel = selChoice?.id === ch.id
+ const hasSubs = ch.sub_choices?.length > 0
+ const subMissing = isSel && hasSubs && subChoices[ch.id] == null
+
+ return (
+
+
selectPref(ch)} left={ }
+ right={(ch.extra_cost ?? 0) !== 0 ? {ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} €
: null}>
+ {ch.name}
+
+
+ {isSel && hasSubs && (
+
+ {subMissing &&
— απαιτείται επιλογή
}
+ {ch.sub_choices.map((sub, si) => {
+ const subSel = subChoices[ch.id]?.name === sub.name
+ return (
+
setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={ }
+ right={(sub.extra_cost ?? 0) !== 0 ? +{sub.extra_cost.toFixed(2)} €
: null}>
+ {sub.name}
+
+ )
+ })}
+
+ )}
+
+ )
+ })}
+
+ {showShared && (
+
+
{ps.shared_subset.name}
+ {ps.shared_subset.choices.map((sub, si) => {
+ const subSel = sharedSubs[ps.id]?.name === sub.name
+ return (
+
setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={ }
+ right={(sub.extra_cost ?? 0) !== 0 ? +{sub.extra_cost.toFixed(2)} €
: null}>
+ {sub.name}
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+// ── Tab: Favorites ────────────────────────────────────────────────────────────
+
+function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtrasState, expandedExtra, setExpandedExtra, removedState, setRemovedState, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
+ const favorites = buildFavorites(product)
+
+ if (favorites.length === 0) return (
+ Δεν υπάρχουν αγαπημένα για αυτό το προϊόν.
+ )
+
+ return (
+
+ {favorites.map((fav, fi) => {
+ if (fav.type === 'quick') {
+ return
+ }
+ if (fav.type === 'ingredient') {
+ return
+ }
+ if (fav.type === 'option') {
+ return
+ }
+ if (fav.type === 'pref') {
+ return (
+
+ )
+ }
+ return null
+ })}
+
+ )
+}
+
+// ── Tab: Quick Options ────────────────────────────────────────────────────────
+
+function QuickTab({ product, quickState, setQuickState }) {
+ const quickOptions = product.quick_options || []
+ if (quickOptions.length === 0) return (
+ Δεν υπάρχουν γρήγορες επιλογές.
+ )
+ return (
+
+ {quickOptions.map(opt => (
+
+ ))}
+
+ )
+}
+
+// ── Tab: Extras ───────────────────────────────────────────────────────────────
+
+function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
+ const options = product.options || []
+ if (options.length === 0) return (
+ Δεν υπάρχουν extras.
+ )
+ return (
+
+ {options.map(opt => (
+
+ ))}
+
+ )
+}
+
+// ── Tab: Υλικά (Ingredients) ─────────────────────────────────────────────────
+
+function IngredientsTab({ product, removedState, setRemovedState }) {
+ const ingredients = product.ingredients || []
+ if (ingredients.length === 0) return (
+ Δεν υπάρχουν υλικά.
+ )
+ return (
+
+
+ Πατήστε για να αφαιρέσετε υλικό από το πιάτο.
+
+ {ingredients.map(ing => (
+
+ ))}
+
+ )
+}
+
+// ── Tab: Προτιμήσεις ─────────────────────────────────────────────────────────
+
+function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
+ const preferenceSets = product.preference_sets || []
+ if (preferenceSets.length === 0) return (
+ Δεν υπάρχουν προτιμήσεις.
+ )
+
+ return (
+
+ {preferenceSets.map(ps => (
+
+ ))}
+
+ )
+}
+
+// ── Tab: Notes ────────────────────────────────────────────────────────────────
+
+function NotesTab({ note, setNote }) {
+ return (
+
+
+ Οτιδήποτε ειδικό για την κουζίνα.
+
+
+ )
+}
+
+// ── Tab: Summary ──────────────────────────────────────────────────────────────
+
+function SummaryTab({ product, summaryLines, note, onJumpTab }) {
+ const isEmpty = summaryLines.length === 0 && !note
+ const byGroup = { quick: [], extras: [], removed: [], prefs: [] }
+ summaryLines.forEach(l => byGroup[l.group]?.push(l))
+
+ const Section = ({ title, tab, lines }) => lines.length === 0 ? null : (
+
+
+
{title}
+
onJumpTab(tab)} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή
+
+
+ {lines.map((l, i) => (
+
+
+
+ {l.qty > 1 && {l.qty}× }
+ {l.label}
+
+ {l.detail &&
{l.detail}
}
+
+ {l.price !== 0 &&
{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €
}
+
+ ))}
+
+
+ )
+
+ return (
+
+ {isEmpty ? (
+
+ Δεν έχει γίνει καμία προσαρμογή. Χρησιμοποιήστε τις καρτέλες για να διαμορφώσετε το προϊόν.
+
+ ) : (
+ <>
+
+
+
+
+ {note && (
+
+
+
Σημείωση
+
onJumpTab('notes')} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή
+
+
{note}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// ── Main OrderDrawer ──────────────────────────────────────────────────────────
+
+export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialState }) {
+ const preferenceSets = product?.preference_sets || []
+ const quickOptions = product?.quick_options || []
+ const options = product?.options || []
+ const ingredients = product?.ingredients || []
+ const favorites = product ? buildFavorites(product) : []
+
+ const hasTabs = {
+ favorites: favorites.length > 0,
+ quick: quickOptions.length > 0,
+ extras: options.length > 0,
+ ingredients: ingredients.length > 0,
+ prefs: preferenceSets.length > 0,
+ }
+
+ const firstTab = hasTabs.favorites ? 'favorites'
+ : hasTabs.quick ? 'quick'
+ : hasTabs.extras ? 'extras'
+ : hasTabs.ingredients ? 'ingredients'
+ : hasTabs.prefs ? 'prefs'
+ : 'notes'
+
+ const [activeTab, setActiveTab] = useState(firstTab)
+ const [qty, setQty] = useState(1)
+ const [quickState, setQuickState] = useState({})
+ const [extrasState, setExtrasState] = useState({})
+ const [expandedExtra, setExpandedExtra] = useState(null)
+ const [removedState, setRemovedState] = useState({})
+ const [prefs, setPrefs] = useState({})
+ const [subChoices, setSubChoices] = useState({})
+ const [sharedSubs, setSharedSubs] = useState({})
+ const [note, setNote] = useState('')
+ const [addAttempted, setAddAttempted] = useState(false)
+
+ // Reset/init when drawer opens or product changes
+ useEffect(() => {
+ if (!isOpen || !product) return
+ const base = buildInitialState(product)
+ if (initialState) {
+ setQty(initialState.qty ?? 1)
+ setQuickState(initialState.quickState ?? {})
+ setExtrasState(initialState.extrasState ?? {})
+ setRemovedState(initialState.removedState ?? {})
+ setPrefs(initialState.prefs ?? base.prefs)
+ setSubChoices(initialState.subChoices ?? base.subChoices)
+ setSharedSubs(initialState.sharedSubs ?? base.sharedSubs)
+ setNote(initialState.note ?? '')
+ } else {
+ setQty(1)
+ setQuickState({})
+ setExtrasState({})
+ setRemovedState({})
+ setPrefs(base.prefs)
+ setSubChoices(base.subChoices)
+ setSharedSubs(base.sharedSubs)
+ setNote('')
+ }
+ setExpandedExtra(null)
+ setAddAttempted(false)
+ setActiveTab(initialState?.activeTab ?? firstTab)
+ }, [isOpen, product?.id])
+
+ // Derived: summary lines + price
+ const { summaryLines, totalPrice } = (() => {
+ if (!product) return { summaryLines: [], totalPrice: 0 }
+ let price = product.base_price
+ const lines = []
+
+ preferenceSets.forEach(ps => {
+ const choice = prefs[ps.id]
+ if (!choice) return
+ const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
+ const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
+ const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
+ const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
+ if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null })
+ else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
+ price += delta
+ })
+
+ quickOptions.forEach(opt => {
+ const q = quickState[opt.id] || 0
+ if (q === 0) return
+ const linePrice = opt.price * q
+ lines.push({ group: 'quick', label: opt.name, qty: q, price: linePrice, detail: null })
+ price += linePrice
+ })
+
+ options.forEach(opt => {
+ const sel = extrasState[opt.id]
+ if (!sel) return
+ const sub = opt.sub_choices?.find(s => s.name === sel.subName)
+ const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
+ lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null })
+ price += linePrice
+ })
+
+ ingredients.forEach(ing => {
+ if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null })
+ })
+
+ return { summaryLines: lines, totalPrice: price * qty }
+ })()
+
+ // Validation
+ function isPrefComplete(ps) {
+ const choice = prefs[ps.id]
+ if (!choice) return false
+ if (choice.sub_choices?.length > 0 && subChoices[choice.id] == null) return false
+ if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && sharedSubs[ps.id] == null) return false
+ return true
+ }
+ const allPrefsOk = preferenceSets.every(isPrefComplete)
+ const extrasSubsMissing = options.some(opt => {
+ const sel = extrasState[opt.id]
+ return sel && opt.sub_choices?.length > 0 && sel.subName == null
+ })
+ const canAdd = allPrefsOk && !extrasSubsMissing
+
+ const prefsHasMandatory = hasTabs.prefs && preferenceSets.some(ps => ps.default_choice_id == null)
+ const prefsTabAlert = hasTabs.prefs && !allPrefsOk && (addAttempted || prefsHasMandatory)
+
+ // Also alert the favorites tab if it contains an incomplete pref
+ const favHasIncompletePref = hasTabs.favorites && !allPrefsOk && favorites.some(f => f.type === 'pref' && !isPrefComplete(f.item))
+ const favTabAlert = favHasIncompletePref && (addAttempted || prefsHasMandatory)
+
+ function handleAdd() {
+ if (!canAdd) {
+ setAddAttempted(true)
+ if (!allPrefsOk) {
+ // Jump to favorites if the incomplete pref is there, else prefs tab
+ if (favHasIncompletePref) setActiveTab('favorites')
+ else if (hasTabs.prefs) setActiveTab('prefs')
+ }
+ return
+ }
+
+ const prefChoices = preferenceSets.flatMap(ps => {
+ const choice = prefs[ps.id]
+ if (!choice) return []
+ const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
+ const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
+ if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
+ if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
+ const sharedSub = sharedSubs[ps.id] ?? null
+ if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
+ }
+ return entries
+ })
+
+ const optionEntries = options.flatMap(opt => {
+ const sel = extrasState[opt.id]
+ if (!sel) return []
+ const sub = opt.sub_choices?.find(s => s.name === sel.subName)
+ const entries = []
+ for (let i = 0; i < sel.qty; i++) {
+ entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 })
+ if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0 })
+ }
+ return entries
+ })
+
+ const quickEntries = quickOptions.flatMap(opt => {
+ const q = quickState[opt.id] || 0
+ if (q === 0) return []
+ return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0 }))
+ })
+
+ const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
+
+ onAdd({
+ product_id: product.id,
+ quantity: qty,
+ selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
+ removed_ingredients: removedNames,
+ notes: note,
+ _drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note },
+ })
+ onClose()
+ }
+
+ // Tabs definition
+ const tabs = [
+ hasTabs.favorites && { id: 'favorites', label: '♥ Αγαπ.' },
+ hasTabs.quick && { id: 'quick', label: 'Quick' },
+ hasTabs.extras && { id: 'extras', label: 'Extras' },
+ hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
+ hasTabs.prefs && { id: 'prefs', label: 'Προτιμ.' },
+ { id: 'notes', label: 'Note' },
+ { id: 'summary', label: 'Summary' },
+ ].filter(Boolean)
+
+ const badgeFor = id => {
+ if (id === 'favorites') {
+ // count favorited items that have been interacted with
+ const favQuick = favorites.filter(f => f.type === 'quick' && (quickState[f.item.id] || 0) > 0).length
+ const favIng = favorites.filter(f => f.type === 'ingredient' && removedState[f.item.id]).length
+ const favExt = favorites.filter(f => f.type === 'option' && extrasState[f.item.id]).length
+ const favPref = favorites.filter(f => f.type === 'pref' && isPrefComplete(f.item)).length
+ return favQuick + favIng + favExt + favPref
+ }
+ if (id === 'quick') return Object.values(quickState).filter(v => v > 0).length
+ if (id === 'extras') return Object.values(extrasState).filter(Boolean).length
+ if (id === 'ingredients') return Object.values(removedState).filter(Boolean).length
+ if (id === 'prefs') return preferenceSets.filter(isPrefComplete).length
+ if (id === 'notes') return note ? 1 : 0
+ if (id === 'summary') return summaryLines.length + (note ? 1 : 0)
+ return 0
+ }
+
+ if (!product) return null
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Sheet */}
+
+ {/* Grab handle */}
+
+
+ {/* Header */}
+
+ {product.image_url && (
+
+ )}
+
+
{product.name}
+
{product.base_price.toFixed(2)} €
+
+
+
+
+
+
+ {/* Tabs bar */}
+
+
+ {tabs.map(t => {
+ const active = activeTab === t.id
+ const badge = badgeFor(t.id)
+ const isAlert = (t.id === 'prefs' && prefsTabAlert) || (t.id === 'favorites' && favTabAlert)
+ const tabColor = isAlert ? '#f59e0b' : active ? '#f59e0b' : 'var(--muted)'
+ return (
+ setActiveTab(t.id)} style={{
+ padding: '12px 6px',
+ background: 'none', border: 'none',
+ borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
+ color: tabColor,
+ fontSize: 14, fontWeight: (active || isAlert) ? 700 : 500,
+ fontFamily: 'inherit', cursor: 'pointer',
+ display: 'inline-flex', alignItems: 'center', gap: 5,
+ whiteSpace: 'nowrap', marginRight: 8,
+ transition: 'color 120ms ease, border-color 120ms ease',
+ animation: isAlert ? 'tab-pulse 0.9s ease-in-out 3' : 'none',
+ }}>
+ {t.label}
+ {badge > 0 && !isAlert && (
+ {badge}
+ )}
+
+ )
+ })}
+
+
+
+ {/* Scrollable content */}
+
+ {activeTab === 'favorites' && (
+
+ )}
+ {activeTab === 'quick' &&
}
+ {activeTab === 'extras' &&
}
+ {activeTab === 'ingredients' &&
}
+ {activeTab === 'prefs' &&
}
+ {activeTab === 'notes' &&
}
+ {activeTab === 'summary' &&
}
+
+
+ {/* Footer: qty stepper + ΠΡΟΣΘΗΚΗ */}
+
+
+
setQty(q => Math.max(1, q - 1))} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? 'var(--muted)' : 'var(--text)' }}>−
+
{qty}
+
setQty(q => q + 1)} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: 'pointer', color: 'var(--text)' }}>+
+
+
+ ΠΡΟΣΘΗΚΗ
+ {totalPrice.toFixed(2)} €
+
+
+
+ >
+ )
+}
diff --git a/waiter_pwa/src/components/OrderSummary.jsx b/waiter_pwa/src/components/OrderSummary.jsx
index 71c0e2e..193a13d 100644
--- a/waiter_pwa/src/components/OrderSummary.jsx
+++ b/waiter_pwa/src/components/OrderSummary.jsx
@@ -1,21 +1,58 @@
+import { useRef } from 'react'
+
function fmtPrice(v) {
return Number(v).toFixed(2) + ' €'
}
-function ItemRow({ item, selectable, selected, onToggle }) {
+function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
const isPaid = item.status === 'paid'
const isCancelled = item.status === 'cancelled'
+ const isStacked = item.quantity > 1
let opts = []
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {}
let removed = []
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
+ // Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll)
+ const pressTimer = useRef(null)
+ const didLongPress = useRef(false)
+ const touchStartPos = useRef({ x: 0, y: 0 })
+
+ function handleTouchStart(e) {
+ if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return
+ didLongPress.current = false
+ touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
+ pressTimer.current = setTimeout(() => {
+ didLongPress.current = true
+ onLongPress(item)
+ }, 500)
+ }
+
+ function handleTouchMove(e) {
+ const dx = Math.abs(e.touches[0].clientX - touchStartPos.current.x)
+ const dy = Math.abs(e.touches[0].clientY - touchStartPos.current.y)
+ if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
+ }
+
+ function handleTouchEnd() {
+ clearTimeout(pressTimer.current)
+ }
+
+ function handleClick() {
+ if (didLongPress.current) { didLongPress.current = false; return }
+ if (selectable && !isPaid && !isCancelled) onToggle(item.id)
+ }
+
return (
onToggle(item.id) : undefined}
- style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default' }}
+ className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
+ onClick={handleClick}
+ onTouchStart={handleTouchStart}
+ onTouchMove={handleTouchMove}
+ onTouchEnd={handleTouchEnd}
+ onTouchCancel={handleTouchEnd}
+ style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }}
>
{selectable && !isPaid && !isCancelled && (
@@ -26,8 +63,11 @@ function ItemRow({ item, selectable, selected, onToggle }) {
{item.product?.name || `#${item.product_id}`}
×{item.quantity}
{fmtPrice(item.unit_price * item.quantity)}
- {isPaid && Πληρωμένο }
- {isCancelled && Ακυρώθηκε }
+ {isPaid && Paid }
+ {isCancelled && Cancelled }
+ {!isPaid && !isCancelled && !item.printed && (
+ ⏳
+ )}
{opts.map((o, i) =>
+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}
)}
{removed.map((r, i) =>
- {r}
)}
@@ -36,26 +76,45 @@ function ItemRow({ item, selectable, selected, onToggle }) {
)
}
-export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle }) {
+export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem }) {
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
- const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ const total = activeItems
+ .filter(i => i.status !== 'cancelled')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ const paidTotal = activeItems
+ .filter(i => i.status === 'paid')
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
return (
{activeItems.length === 0 &&
Δεν υπάρχουν αντικείμενα
}
- {activeItems.map(item => (
+ {activeItems.map((item, idx) => (
))}
Σύνολο
{fmtPrice(total)}
+ {paidTotal > 0 && paidTotal < total && (
+
+ Πληρωμένο
+ {fmtPrice(paidTotal)}
+
+ )}
+ {paidTotal > 0 && paidTotal < total && (
+
+ Εκκρεμεί
+ {fmtPrice(total - paidTotal)}
+
+ )}
)
}
diff --git a/waiter_pwa/src/components/PinPad.jsx b/waiter_pwa/src/components/PinPad.jsx
index 0597446..fb93521 100644
--- a/waiter_pwa/src/components/PinPad.jsx
+++ b/waiter_pwa/src/components/PinPad.jsx
@@ -29,7 +29,12 @@ export default function PinPad({ onSubmit, loading }) {
{[1,2,3,4,5,6,7,8,9].map(d => (
press(String(d))} className="pin-btn">{d}
))}
-
⌫
+
+
+
+
+
+
press('0')} className="pin-btn">0
{loading ? '…' : '✓'}
diff --git a/waiter_pwa/src/components/ProductPicker.jsx b/waiter_pwa/src/components/ProductPicker.jsx
index 16f2157..9fef5c1 100644
--- a/waiter_pwa/src/components/ProductPicker.jsx
+++ b/waiter_pwa/src/components/ProductPicker.jsx
@@ -1,5 +1,18 @@
import { useState } from 'react'
-import ItemOptionsModal from './ItemOptionsModal'
+import OrderDrawer from './OrderDrawer'
+
+function CategoriesIcon({ width = 20, height = 20 }) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+const API_URL = import.meta.env.VITE_API_URL || ''
function hexToRgba(hex, alpha) {
if (!hex) return null
@@ -10,66 +23,209 @@ function hexToRgba(hex, alpha) {
return `rgba(${r},${g},${b},${alpha})`
}
-export default function ProductPicker({ categories, products, onAdd }) {
- const [activeCat, setActiveCat] = useState(categories[0]?.id ?? null)
- const [selectedProduct, setSelectedProduct] = useState(null)
- const [viewAllOpen, setViewAllOpen] = useState(false)
+function ProductGrid({ products, onOpen }) {
+ if (products.length === 0) return null
+ return (
+
+ {products.map(product => {
+ const initials = product.name
+ .trim()
+ .split(/\s+/)
+ .slice(0, 2)
+ .map(w => w[0])
+ .join('')
+ .toUpperCase()
+ return (
+
onOpen(product)}>
+
+
+ {product.image_url
+ ?
+ :
{initials}
+ }
+
+
+
+ {product.name}
+ {Number(product.base_price).toFixed(2)} €
+
+
+ )
+ })}
+
+ )
+}
- const filtered = products.filter(p => p.category_id === activeCat)
+// Builds the ordered list of sections for a top-level category:
+// interleaves direct products (as a "General" section) and sub-categories
+// according to general_sort_order and each sub's sort_order.
+function buildSections(parent, subcategories, directProducts) {
+ const sections = []
+
+ if (directProducts.length > 0) {
+ sections.push({ _isGeneral: true, sort_order: parent.general_sort_order, products: directProducts })
+ }
+
+ for (const sub of subcategories) {
+ sections.push({ ...sub, _isGeneral: false, sort_order: sub.sort_order })
+ }
+
+ return sections.sort((a, b) => a.sort_order - b.sort_order)
+}
+
+export default function ProductPicker({ categories, products, onAdd }) {
+ const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
+ const initialCatId = topLevel[0]?.id ?? null
+ const [activeCat, setActiveCat] = useState(initialCatId)
+ const [drawerProduct, setDrawerProduct] = useState(null)
+ const [viewAllOpen, setViewAllOpen] = useState(false)
+ // Track which sub-category sections are expanded (by sub-cat id or '__general__')
+ const [expandedSubs, setExpandedSubs] = useState(() => {
+ if (!initialCatId) return {}
+ const subs = categories.filter(c => c.parent_id === initialCatId)
+ const state = {}
+ subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
+ return state
+ })
+
+ const activeParent = categories.find(c => c.id === activeCat)
+ const subcategories = activeParent
+ ? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
+ : []
+ const hasSubcats = subcategories.length > 0
+
+ // Products directly on this top-level category (no sub-cat)
+ const directProducts = products.filter(p => p.category_id === activeCat)
+ // Products for the flat view (no sub-cats)
+ const flatProducts = products.filter(p => p.category_id === activeCat)
+
+ // Build sections for accordion view
+ const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
+
+ function buildDefaultExpanded(catId) {
+ const subs = categories.filter(c => c.parent_id === catId)
+ const state = {}
+ subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
+ return state
+ }
function selectCategory(id) {
setActiveCat(id)
setViewAllOpen(false)
+ setExpandedSubs(buildDefaultExpanded(id))
}
+ function toggleSub(key) {
+ setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))
+ }
+
+ function openDrawer(product) { setDrawerProduct(product) }
+ function closeDrawer() { setDrawerProduct(null) }
+
return (
- {/* View All button — always first */}
-
setViewAllOpen(true)}
- title="Εμφάνιση όλων"
- >
- ⊞
-
+
+ setViewAllOpen(true)}
+ title="Εμφάνιση όλων"
+ >
+
+
+
- {categories.map(cat => {
- const isActive = activeCat === cat.id
- const bg = cat.color
- ? isActive ? cat.color : hexToRgba(cat.color, 0.35)
- : isActive ? 'var(--accent)' : 'var(--bg3)'
- const color = cat.color
- ? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
- : isActive ? '#1c1400' : 'var(--muted)'
- return (
-
setActiveCat(cat.id)}
- >
- {cat.name}
-
- )
- })}
+
+
+
+ {topLevel.map(cat => {
+ const isActive = activeCat === cat.id
+ const bg = cat.color
+ ? isActive ? cat.color : hexToRgba(cat.color, 0.35)
+ : isActive ? 'var(--accent)' : 'var(--bg3)'
+ const color = cat.color
+ ? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
+ : isActive ? 'var(--accent-fg)' : 'var(--muted)'
+ return (
+ selectCategory(cat.id)}
+ >
+ {cat.name}
+
+ )
+ })}
+
+
-
- {filtered.map(product => (
-
setSelectedProduct(product)}>
- {product.name}
- {Number(product.base_price).toFixed(2)} €
-
- ))}
- {filtered.length === 0 && (
-
- Δεν υπάρχουν προϊόντα
-
+ {/* Product area — flat grid or accordion depending on sub-cats */}
+
+ {!hasSubcats ? (
+ // No sub-categories: original flat grid
+ <>
+
+ {flatProducts.length === 0 && (
+
+ Δεν υπάρχουν προϊόντα
+
+ )}
+ >
+ ) : (
+ // Has sub-categories: accordion view
+
+ {sections.map(section => {
+ const key = section._isGeneral ? '__general__' : String(section.id)
+ const isOpen = !!expandedSubs[key]
+ const sectionProducts = section._isGeneral
+ ? section.products
+ : products.filter(p => p.category_id === section.id)
+ if (sectionProducts.length === 0) return null
+
+ // General products appear flat — no collapsible header
+ if (section._isGeneral) {
+ return (
+
+ )
+ }
+
+ const accentColor = section.color ?? activeParent?.color ?? null
+
+ return (
+
+
toggleSub(key)}
+ >
+ {accentColor && }
+ {section.name}
+ {sectionProducts.length}
+
+
+
+
+
+ {isOpen && (
+
+ )}
+
+ )
+ })}
+
)}
- {/* View All modal */}
+ {/* View All modal — top-level categories only */}
{viewAllOpen && (
setViewAllOpen(false)}>
setViewAllOpen(false)}>✕
- {categories.map(cat => {
+ {topLevel.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color || 'var(--bg3)'
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
@@ -102,13 +258,12 @@ export default function ProductPicker({ categories, products, onAdd }) {
)}
- {selectedProduct && (
-
setSelectedProduct(null)}
- />
- )}
+ { onAdd(item); closeDrawer() }}
+ />
)
}
diff --git a/waiter_pwa/src/components/TableCard.jsx b/waiter_pwa/src/components/TableCard.jsx
index d31178d..a11fc01 100644
--- a/waiter_pwa/src/components/TableCard.jsx
+++ b/waiter_pwa/src/components/TableCard.jsx
@@ -1,24 +1,196 @@
-export default function TableCard({ table, order, currentUserId, onClick }) {
- const hasOrder = !!order
- const isMyTable = hasOrder && order.waiters?.some(w => w.waiter_id === currentUserId)
+import { useRef, useState } from 'react'
+import useThemeStore from '../store/themeStore'
+import useTableColourStore from '../store/tableColourStore'
- let statusLabel = 'Ελεύθερο'
- let cardClass = 'table-card table-card--free'
+const STATUS_LABELS = {
+ free: 'ΕΛΕΥΘΕΡΟ',
+ open: 'ΑΝΟΙΧΤΟ',
+ mine: 'ΔΙΚΟ ΜΟΥ',
+ paid: 'ΠΛΗΡΩΜΕΝΟ',
+ partially_paid: 'ΜΕΡ. ΠΛHΡ.',
+}
- if (hasOrder && isMyTable) {
- statusLabel = 'Δικό μου'
- cardClass = 'table-card table-card--mine'
- } else if (hasOrder) {
- statusLabel = 'Ενεργό'
- cardClass = 'table-card table-card--active'
- }
+const DRAG_THRESHOLD = 8
+const HOLD_MS = 480
+
+export default function TableCard({ table, order, isMine, flags = [], groupName = '', onClick, onLongPress }) {
+ const holdTimer = useRef(null)
+ const startPos = useRef({ x: 0, y: 0 })
+ const didFire = useRef(false)
+ const [showTip, setShowTip] = useState(false)
+
+ const dark = useThemeStore(s => s.dark)
+ const colours = useTableColourStore(s => s.colours)
+
+ let statusKey = 'free'
+ if (order?.status === 'paid') statusKey = 'paid'
+ else if (order?.status === 'partially_paid') statusKey = 'partially_paid'
+ else if (order && isMine) statusKey = 'mine'
+ else if (order) statusKey = 'open'
+
+ const mode = dark ? 'dark' : 'light'
+ const cfg = colours[mode][statusKey]
const displayName = table.label || `T${table.number}`
+ function cancel() {
+ clearTimeout(holdTimer.current)
+ holdTimer.current = null
+ }
+
+ function onTouchStart(e) {
+ const t = e.touches[0]
+ startPos.current = { x: t.clientX, y: t.clientY }
+ didFire.current = false
+ holdTimer.current = setTimeout(() => {
+ didFire.current = true
+ if (onLongPress) onLongPress()
+ else setShowTip(true)
+ }, HOLD_MS)
+ }
+
+ function onTouchMove(e) {
+ if (!holdTimer.current) return
+ const t = e.touches[0]
+ const dx = Math.abs(t.clientX - startPos.current.x)
+ const dy = Math.abs(t.clientY - startPos.current.y)
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
+ }
+
+ function onTouchEnd() {
+ cancel()
+ setShowTip(false)
+ }
+
+ function onMouseDown(e) {
+ startPos.current = { x: e.clientX, y: e.clientY }
+ didFire.current = false
+ holdTimer.current = setTimeout(() => {
+ didFire.current = true
+ if (onLongPress) onLongPress()
+ else setShowTip(true)
+ }, HOLD_MS)
+ }
+ function onMouseMove(e) {
+ if (!holdTimer.current) return
+ const dx = Math.abs(e.clientX - startPos.current.x)
+ const dy = Math.abs(e.clientY - startPos.current.y)
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
+ }
+ function onMouseUp() { cancel(); setShowTip(false) }
+ function onMouseLeave() { cancel(); setShowTip(false) }
+
+ function handleClick(e) {
+ if (didFire.current) { e.preventDefault(); return }
+ onClick?.()
+ }
+
return (
-
- {displayName}
- {statusLabel}
-
+
+
+ {/* Top-left: table name + area */}
+
+
+ {displayName}
+
+ {groupName && (
+
+ {groupName}
+
+ )}
+
+
+ {/* Bottom-left: status badge */}
+
+
+ {STATUS_LABELS[statusKey]}
+
+
+
+ {/* Bottom-right: flag circles, stacked, up to 3 visible */}
+ {flags.length > 0 && (
+
+ {flags.slice(0, 3).map(f => (
+
+ {f.emoji || '🏷️'}
+
+ ))}
+ {flags.length > 3 && (
+
+ +{flags.length - 3}
+
+ )}
+
+ )}
+
+
+ {/* Flag name tooltip on long-press (only when no onLongPress handler) */}
+ {showTip && flags.length > 0 && (
+
+ {flags.map(f => (
+
+ {f.emoji || '🏷️'}
+ {f.name}
+
+ ))}
+
+ )}
+
)
}
diff --git a/waiter_pwa/src/components/UserMenu.jsx b/waiter_pwa/src/components/UserMenu.jsx
new file mode 100644
index 0000000..cc1d8c3
--- /dev/null
+++ b/waiter_pwa/src/components/UserMenu.jsx
@@ -0,0 +1,181 @@
+import { useEffect, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import useAuthStore from '../store/authStore'
+import useShiftStore from '../store/shiftStore'
+import useThemeStore from '../store/themeStore'
+import client from '../api/client'
+
+function formatTime(iso) {
+ if (!iso) return ''
+ return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
+}
+
+function formatDuration(iso) {
+ if (!iso) return ''
+ const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
+ if (mins < 60) return `${mins}λ`
+ const h = Math.floor(mins / 60)
+ const m = mins % 60
+ return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
+}
+
+export default function UserMenu() {
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const ref = useRef(null)
+ const navigate = useNavigate()
+ const { user, logout } = useAuthStore()
+ const { dark, toggle } = useThemeStore()
+ const {
+ shift, selfEndAllowed,
+ setShift, clearShift,
+ } = useShiftStore()
+
+ useEffect(() => {
+ function onClick(e) {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', onClick)
+ return () => document.removeEventListener('mousedown', onClick)
+ }, [])
+
+ function handleLogout() {
+ setOpen(false)
+ logout()
+ navigate('/login')
+ }
+
+ const activeBreak = shift?.breaks?.find(b => !b.ended_at)
+ const isWaiter = user?.role === 'waiter'
+
+ async function handleEndShift() {
+ if (!window.confirm('Να τελειώσει η βάρδια σου;')) return
+ setBusy(true)
+ try {
+ await client.post('/api/shifts/end', {})
+ clearShift()
+ setOpen(false)
+ } catch {
+ // ignore — gate will re-check
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function handleBreak() {
+ setBusy(true)
+ try {
+ if (activeBreak) {
+ await client.post(`/api/shifts/${shift.id}/break/end`)
+ } else {
+ await client.post(`/api/shifts/${shift.id}/break/start`)
+ }
+ const res = await client.get('/api/shifts/my')
+ setShift(res.data)
+ } catch {
+ // ignore
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
setOpen(o => !o)}
+ title="Μενού χρήστη"
+ style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
+ >
+ {/* Break indicator dot */}
+ {activeBreak && (
+
+ )}
+ {user?.username}
+
+
+
+
+
+ {open && (
+
+ {/* ── Shift info (waiters only) ─────────────────────── */}
+ {isWaiter && shift && (
+ <>
+
+
+ Βάρδια ενεργή
+
+
+ Από {formatTime(shift.started_at)}
+ {formatDuration(shift.started_at)}
+
+ {shift.starting_cash != null && (
+
+ Αρχικά: €{shift.starting_cash.toFixed(2)}
+
+ )}
+ {activeBreak && (
+
+ ☕ Σε διάλειμμα από {formatTime(activeBreak.started_at)}
+
+ )}
+
+
+ {/* Break button */}
+
+ {activeBreak ? '▶' : '☕'}
+ {activeBreak ? 'Τέλος Διαλείμματος' : 'Διάλειμμα'}
+
+
+ {/* End shift button */}
+ {selfEndAllowed ? (
+
+ ⏹
+ Τέλος Βάρδιας
+
+ ) : (
+
+ Ζητήστε από τον διαχειριστή να κλείσει τη βάρδια
+
+ )}
+
+
+ >
+ )}
+
+ {/* ── Theme toggle ──────────────────────────────────── */}
+
{ toggle(); setOpen(false) }}>
+ {dark ? '☀️' : '🌙'}
+ {dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}
+
+
+
+
+
+ ⏏
+ Αποσύνδεση
+
+
+ )}
+
+ )
+}
diff --git a/waiter_pwa/src/context/NotificationContext.jsx b/waiter_pwa/src/context/NotificationContext.jsx
new file mode 100644
index 0000000..68d823f
--- /dev/null
+++ b/waiter_pwa/src/context/NotificationContext.jsx
@@ -0,0 +1,123 @@
+import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
+import useAuthStore from '../store/authStore'
+import client from '../api/client'
+
+const NotificationContext = createContext(null)
+
+export function useNotifications() {
+ return useContext(NotificationContext)
+}
+
+// ─── Persistent banner (one message at a time, stacked) ───────────────────────
+
+function NotificationBanner({ message, onAck }) {
+ const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
+
+ return (
+
+
📢
+
+ {message.sender_name && (
+
+ {message.sender_name}
+
+ )}
+
+ {message.body}
+
+ {tableIds.length > 0 && (
+
+ Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
+
+ )}
+
+
onAck(message.id)}
+ style={{
+ flexShrink: 0, height: 32, padding: '0 12px',
+ borderRadius: 8, border: 'none',
+ background: '#4f46e5', color: 'white',
+ fontSize: 12, fontWeight: 700, cursor: 'pointer',
+ }}
+ >OK ✓
+
+ )
+}
+
+export function NotificationProvider({ children }) {
+ const { token, user } = useAuthStore()
+ const [pendingMessages, setPendingMessages] = useState([]) // unacked
+ const [recentMessages, setRecentMessages] = useState([]) // last 10 (for history)
+ const pollRef = useRef(null)
+
+ const fetchUnread = useCallback(async () => {
+ if (!token || !user) return
+ try {
+ const res = await client.get('/api/messages/unread')
+ setPendingMessages(res.data)
+ } catch { /* offline or unauthenticated — swallow */ }
+ }, [token, user?.id])
+
+ const fetchRecent = useCallback(async () => {
+ if (!token || !user) return
+ try {
+ const res = await client.get('/api/messages/recent?limit=10')
+ setRecentMessages(res.data)
+ } catch { }
+ }, [token, user?.id])
+
+ useEffect(() => {
+ if (!token || !user) return
+ fetchUnread()
+ fetchRecent()
+ pollRef.current = setInterval(fetchUnread, 2000)
+ return () => clearInterval(pollRef.current)
+ }, [token, user?.id])
+
+ async function ackMessage(messageId) {
+ try {
+ await client.post(`/api/messages/${messageId}/ack`)
+ setPendingMessages(prev => prev.filter(m => m.id !== messageId))
+ fetchRecent()
+ } catch { }
+ }
+
+ const unreadCount = pendingMessages.length
+
+ return (
+
+ {children}
+
+ {/* Floating banner stack (max 3 visible) */}
+ {pendingMessages.length > 0 && (
+
+
+ {pendingMessages.slice(0, 3).map(msg => (
+
+
+
+ ))}
+ {pendingMessages.length > 3 && (
+
+ +{pendingMessages.length - 3} ακόμα μηνύματα
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/waiter_pwa/src/index.css b/waiter_pwa/src/index.css
index 5073256..d0a90d2 100644
--- a/waiter_pwa/src/index.css
+++ b/waiter_pwa/src/index.css
@@ -1,32 +1,101 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+/* Prevent text selection everywhere — app behaves like native */
+*, *::before, *::after {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+}
+input, textarea, [contenteditable] {
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+@keyframes tab-pulse {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.25; }
+ 100% { opacity: 1; }
+}
+
+@keyframes gate-spin {
+ to { transform: rotate(360deg); }
+}
+
:root {
- --bg: #0f172a;
- --bg2: #1e293b;
- --bg3: #334155;
- --text: #e2e8f0;
- --muted: #64748b;
- --accent: #f59e0b;
- --accent-dim: #78350f;
- --success: #22c55e;
- --danger: #ef4444;
- --danger-dim: #7f1d1d;
- --border: #334155;
+ /* "Free" table card — dark theme: muted blue-slate */
+ --card-free-bg: #243044;
+ --card-free-text: #94b8d4;
+ --card-free-muted: rgba(148,184,212,0.45);
+
+ /* Dark theme — deep navy */
+ --bg: #0d1520;
+ --bg2: #1a2535;
+ --bg3: #243044;
+ --bg4: #2e3d54;
+ --text: #edf2f7;
+ --text2: #94a3b8;
+ --muted: #5a7390;
+ --accent: #f59e0b;
+ --accent-fg: #1c1000;
+ --accent-dim: #6b3a00;
+ --success: #22c55e;
+ --success-fg: #052e16;
+ --danger: #f87171;
+ --danger-sat: #ef4444;
+ --danger-dim: #450a0a;
+ --primary: #3b82f6;
+ --primary-fg: #ffffff;
+ --border: #253245;
+ --shadow: rgba(0,0,0,0.35);
font-family: system-ui, 'Segoe UI', sans-serif;
font-size: 16px;
color: var(--text);
background: var(--bg);
}
-body { background: var(--bg); }
+[data-theme="light"] {
+ /* "Free" table card — light theme: cool light grey */
+ --card-free-bg: #dde5ef;
+ --card-free-text: #3d5270;
+ --card-free-muted: rgba(61,82,112,0.45);
-#root { min-height: 100svh; display: flex; flex-direction: column; }
+ /* Light theme — warm slate / off-white */
+ --bg: #f1f5f9;
+ --bg2: #ffffff;
+ --bg3: #e8edf4;
+ --bg4: #dce3ee;
+ --text: #1e293b;
+ --text2: #475569;
+ --muted: #7a8fa6;
+ --accent: #e08c00;
+ --accent-fg: #ffffff;
+ --accent-dim: #fef3c7;
+ --success: #16a34a;
+ --success-fg: #ffffff;
+ --danger: #dc2626;
+ --danger-sat: #dc2626;
+ --danger-dim: #fee2e2;
+ --primary: #2563eb;
+ --primary-fg: #ffffff;
+ --border: #cdd6e0;
+ --shadow: rgba(0,0,0,0.10);
+}
+
+html, body {
+ background: var(--bg);
+ overscroll-behavior: none;
+ overflow: hidden;
+ height: 100%;
+}
+
+#root { height: 100%; display: flex; flex-direction: column; }
/* ── Layout ─────────────────────────────────────────────── */
.page {
display: flex;
flex-direction: column;
- min-height: 100svh;
+ height: 100svh;
+ overflow: hidden;
background: var(--bg);
}
.page--centered {
@@ -66,12 +135,12 @@ body { background: var(--bg); }
min-height: 48px;
transition: opacity 0.15s;
}
-.btn:disabled { opacity: 0.45; cursor: not-allowed; }
-.btn--primary { background: #1d4ed8; color: #fff; }
-.btn--accent { background: var(--accent); color: #1c1400; }
-.btn--success { background: #15803d; color: #fff; }
-.btn--danger { background: var(--danger); color: #fff; }
-.btn--secondary{ background: var(--bg3); color: var(--text); }
+.btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.btn--primary { background: var(--primary); color: var(--primary-fg); }
+.btn--accent { background: var(--accent); color: var(--accent-fg); }
+.btn--success { background: var(--success); color: var(--success-fg); }
+.btn--danger { background: var(--danger-sat); color: #fff; }
+.btn--secondary{ background: var(--bg3); color: var(--text); }
.btn--lg { min-height: 64px; font-size: 17px; border-radius: 14px; }
.icon-btn {
@@ -113,7 +182,7 @@ body { background: var(--bg); }
}
.pin-btn:active { background: var(--bg3); }
.pin-btn--secondary { background: transparent; color: var(--muted); }
-.pin-btn--confirm { background: var(--accent); color: #1c1400; border-color: var(--accent); }
+.pin-btn--confirm { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.pin-btn--confirm:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Login ───────────────────────────────────────────────── */
@@ -127,7 +196,7 @@ body { background: var(--bg); }
padding: 32px 24px;
}
.app-title { font-size: 32px; font-weight: 700; color: var(--accent); }
-.app-subtitle { font-size: 14px; color: var(--muted); margin-top: -16px; }
+.app-subtitle { font-size: 14px; color: var(--muted); }
.login-greeting { font-size: 16px; color: var(--text); }
.text-input {
width: 100%;
@@ -140,7 +209,7 @@ body { background: var(--bg); }
outline: none;
}
.text-input:focus { border-color: var(--accent); }
-.error-msg { color: #fca5a5; font-size: 14px; text-align: center; }
+.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
/* ── Filter Tabs ─────────────────────────────────────────── */
.filter-tabs {
@@ -161,35 +230,34 @@ body { background: var(--bg); }
font-weight: 600;
cursor: pointer;
}
-.filter-tab--active { background: var(--accent); color: #1c1400; }
+.filter-tab--active { background: var(--accent); color: var(--accent-fg); }
/* ── Table Grid ──────────────────────────────────────────── */
.table-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
align-content: start;
}
-.table-card {
+.table-card-v2 {
+ position: relative;
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
- min-height: 132px;
- max-height: 132px;
- border-radius: 14px;
- border: 2px solid transparent;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 12px 12px 48px;
+ width: 100%;
+ min-height: 116px;
+ border-radius: 16px;
+ border: none;
cursor: pointer;
- font-size: 14px;
+ text-align: left;
+ overflow: hidden;
+ transition: transform 0.12s;
+ box-shadow: 0 2px 10px var(--shadow);
}
-.table-card__number { font-size: 28px; font-weight: 700; }
-.table-card__name { font-size: 12px; color: var(--muted); }
-.table-card__status { font-size: 12px; font-weight: 600; margin-top: 2px; }
-.table-card--free { background: var(--bg2); color: var(--muted); border-color: var(--border); }
-.table-card--active { background: #1e3a5f; color: #93c5fd; border-color: #1d4ed8; }
-.table-card--mine { background: #451a03; color: var(--accent); border-color: var(--accent); }
+.table-card-v2:active { transform: scale(0.96); }
/* ── FAB ─────────────────────────────────────────────────── */
.fab {
@@ -200,11 +268,11 @@ body { background: var(--bg); }
height: 56px;
border-radius: 50%;
background: var(--accent);
- color: #1c1400;
+ color: var(--accent-fg);
font-size: 24px;
border: none;
cursor: pointer;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+ box-shadow: 0 4px 16px var(--shadow);
}
/* ── Cart badge ──────────────────────────────────────────── */
@@ -213,7 +281,7 @@ body { background: var(--bg); }
top: 2px;
right: 2px;
background: var(--accent);
- color: #1c1400;
+ color: var(--accent-fg);
font-size: 10px;
font-weight: 700;
border-radius: 50%;
@@ -227,15 +295,46 @@ body { background: var(--bg); }
/* ── Category Tabs ───────────────────────────────────────── */
.category-tabs {
display: flex;
- gap: 8px;
- padding: 10px 12px;
- overflow-x: auto;
+ align-items: center;
background: var(--bg2);
border-bottom: 1px solid var(--border);
+}
+.category-tabs__sticky {
+ flex-shrink: 0;
+ padding: 10px 0 10px 12px;
+ display: flex;
+ align-items: center;
+ background: var(--bg2);
+ z-index: 2;
+}
+.category-tabs__scroll-wrap {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: stretch;
+ overflow: hidden;
+}
+.category-tabs__fade {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 40px;
+ background: linear-gradient(to right, var(--bg2) 40%, transparent 100%);
+ pointer-events: none;
+ z-index: 1;
+}
+.category-tabs__scroll {
+ display: flex;
+ gap: 8px;
+ padding: 10px 12px 10px 36px;
+ overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
+ flex: 1;
}
-.category-tabs::-webkit-scrollbar { display: none; }
+.category-tabs__scroll::-webkit-scrollbar { display: none; }
.cat-tab {
flex-shrink: 0;
padding: 8px 16px;
@@ -249,13 +348,15 @@ body { background: var(--bg); }
white-space: nowrap;
transition: filter 0.12s;
}
-.cat-tab--active { background: var(--accent); color: #1c1400; }
+.cat-tab--active { background: var(--accent); color: var(--accent-fg); }
.cat-tab--viewall {
background: var(--bg3);
color: var(--text);
- font-size: 18px;
- padding: 4px 12px;
+ padding: 8px 10px;
border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
/* ── Category All Modal ──────────────────────────────────── */
@@ -283,23 +384,35 @@ body { background: var(--bg); }
}
.cat-all-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px;
overflow-y: auto;
flex: 1;
+ align-content: start;
+}
+@media (min-width: 480px) {
+ .cat-all-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+@media (min-width: 720px) {
+ .cat-all-grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
}
.cat-all-tile {
position: relative;
display: flex;
align-items: center;
justify-content: center;
- min-height: 90px;
+ height: 76px;
+ max-height: 76px;
border-radius: 14px;
border: none;
cursor: pointer;
overflow: hidden;
- padding: 8px;
+ padding: 8px 10px;
}
.cat-all-tile--active { outline: 3px solid #fff; }
.cat-all-tile__overlay {
@@ -309,38 +422,135 @@ body { background: var(--bg); }
}
.cat-all-tile__name {
position: relative;
- font-size: 14px;
+ font-size: 16px;
font-weight: 700;
color: #fff;
text-align: center;
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
line-height: 1.3;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ word-break: break-word;
}
/* ── Product Grid ────────────────────────────────────────── */
-.product-picker { display: flex; flex-direction: column; flex: 1; }
+.product-picker { display: flex; flex-direction: column; flex: 1; min-height: 0; }
+.product-area { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
+
+/* Sub-category accordion */
+.subcat-accordion { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; }
+.subcat-section { border-radius: 12px; overflow: hidden; background: var(--bg2); border: 1px solid var(--border); }
+.subcat-header {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ color: var(--text);
+ transition: background 0.12s;
+}
+.subcat-header:active { background: var(--bg3); }
+.subcat-header--open { background: var(--bg3); }
+.subcat-header__pill {
+ width: 4px;
+ height: 28px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ opacity: 0.85;
+}
+.subcat-header__name { flex: 1; font-size: 14px; font-weight: 600; }
+.subcat-header__count {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--muted);
+ background: var(--bg3);
+ border-radius: 10px;
+ padding: 2px 7px;
+ flex-shrink: 0;
+}
+.subcat-header--open .subcat-header__count { background: var(--bg); }
+.subcat-header__chevron { flex-shrink: 0; color: var(--muted); transition: transform 200ms ease; }
+.subcat-body { padding: 0 0 6px; }
+.subcat-body .product-grid { padding: 8px 10px; overflow-y: unset; }
+.subcat-general .product-grid { padding: 8px 10px; }
+
.product-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
padding: 12px;
- overflow-y: auto;
}
.product-btn {
display: flex;
- flex-direction: column;
- gap: 4px;
- padding: 14px;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 0;
+ padding: 0;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
text-align: left;
- min-height: 80px;
+ overflow: hidden;
}
.product-btn:active { background: var(--bg3); }
-.product-btn__name { font-size: 14px; font-weight: 600; color: var(--text); line-height: 1.3; }
-.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: auto; }
+
+.product-btn__thumb {
+ flex-shrink: 0;
+ padding: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.product-btn__thumb-inner {
+ width: 64px;
+ height: 64px;
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--bg3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.product-btn__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.product-btn__initials {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--muted);
+ user-select: none;
+}
+
+.product-btn__info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 10px 12px;
+}
+.product-btn__name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ line-height: 1.35;
+ /* always occupy exactly 2 lines */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: calc(1.35em * 2);
+}
+.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 4px; }
/* ── Cart Panel ──────────────────────────────────────────── */
.cart-panel {
@@ -364,15 +574,16 @@ body { background: var(--bg); }
/* ── Order Summary ───────────────────────────────────────── */
.order-summary { display: flex; flex-direction: column; gap: 0; overflow-y: auto; flex: 1; padding: 12px; }
-.order-item { padding: 12px 0; border-bottom: 1px solid var(--border); }
+.order-item { padding: 12px 10px; border-bottom: 1px solid var(--border); }
+.order-item--last { border-bottom: none; }
.order-item--paid { opacity: 0.5; }
.order-item--cancelled { opacity: 0.3; text-decoration: line-through; }
-.order-item--selected { background: rgba(245,158,11,0.08); border-radius: 8px; padding: 8px; }
+.order-item--selected { background: rgba(245,158,11,0.10); border-radius: 8px; }
.order-item__row { display: flex; align-items: center; gap: 8px; }
-.order-item__name { flex: 1; font-size: 15px; font-weight: 600; }
-.order-item__qty { font-size: 13px; color: var(--muted); }
-.order-item__price { font-size: 14px; color: var(--text); font-weight: 600; }
-.order-item__modifier { font-size: 12px; color: var(--muted); padding-left: 16px; margin-top: 2px; }
+.order-item__name { flex: 1; font-size: 17px; font-weight: 600; }
+.order-item__qty { font-size: 15px; color: var(--muted); }
+.order-item__price { font-size: 16px; color: var(--text); font-weight: 600; }
+.order-item__modifier { font-size: 13px; color: var(--muted); padding-left: 16px; margin-top: 3px; }
.order-summary__total {
display: flex;
justify-content: space-between;
@@ -384,11 +595,12 @@ body { background: var(--bg); }
margin-top: 8px;
}
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px; margin-left: 4px; }
-.badge--paid { background: #15803d; color: #fff; }
-.badge--cancelled { background: var(--danger-dim); color: #fca5a5; }
+.badge--paid { background: var(--success); color: var(--success-fg); }
+.badge--cancelled{ background: var(--danger-dim); color: var(--danger); }
+.badge--draft { background: var(--accent-dim); color: var(--accent); }
/* ── Detail Body ─────────────────────────────────────────── */
-.detail-body { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
+.detail-body { display: flex; flex-direction: column; flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
.action-bar {
display: flex;
gap: 8px;
@@ -407,6 +619,9 @@ body { background: var(--bg); }
align-items: flex-end;
z-index: 100;
}
+.modal-overlay--top {
+ align-items: flex-start;
+}
.modal-sheet {
background: var(--bg2);
border-radius: 20px 20px 0 0;
@@ -418,6 +633,10 @@ body { background: var(--bg); }
flex-direction: column;
gap: 12px;
}
+.modal-sheet--top {
+ border-radius: 0 0 20px 20px;
+ padding: 12px 20px 24px;
+}
.modal-handle {
width: 40px;
height: 4px;
@@ -425,6 +644,10 @@ body { background: var(--bg); }
border-radius: 2px;
margin: 0 auto 8px;
}
+.modal-sheet--top .modal-handle {
+ margin: 8px auto 0;
+ order: 99;
+}
.modal-title { font-size: 20px; font-weight: 700; text-align: center; }
.modal-price { font-size: 18px; color: var(--accent); text-align: center; font-weight: 600; }
.modal-section h3 { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
@@ -466,3 +689,44 @@ body { background: var(--bg); }
cursor: pointer;
}
.qty-value { font-size: 24px; font-weight: 700; min-width: 36px; text-align: center; }
+
+/* ── User Menu Dropdown ──────────────────────────────────── */
+.user-menu-dropdown {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 200;
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.25);
+ min-width: 200px;
+ padding: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.user-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 15px;
+ cursor: pointer;
+ text-align: left;
+}
+.user-menu-item:hover { background: var(--bg3); }
+.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
+.user-menu-item--disabled:hover { background: transparent; }
+.user-menu-item--danger { color: var(--danger); }
+.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
+.user-menu-divider {
+ height: 1px;
+ background: var(--border);
+ margin: 4px 0;
+}
diff --git a/waiter_pwa/src/pages/AddItemsPage.jsx b/waiter_pwa/src/pages/AddItemsPage.jsx
index 3e87a27..af6bf34 100644
--- a/waiter_pwa/src/pages/AddItemsPage.jsx
+++ b/waiter_pwa/src/pages/AddItemsPage.jsx
@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react'
-import { useNavigate, useParams } from 'react-router-dom'
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import ProductPicker from '../components/ProductPicker'
+import OrderDrawer from '../components/OrderDrawer'
import client from '../api/client'
export default function AddItemsPage() {
const { tableId } = useParams()
+ const [searchParams] = useSearchParams()
+ const isNewTable = searchParams.get('new') === '1'
const navigate = useNavigate()
const [categories, setCategories] = useState([])
@@ -12,9 +15,11 @@ export default function AddItemsPage() {
const [cart, setCart] = useState([])
const [orderId, setOrderId] = useState(null)
const [sending, setSending] = useState(false)
+ const [retrying, setRetrying] = useState(false)
const [error, setError] = useState('')
- // null = not yet sent, { allOk, results } = sent
const [printAck, setPrintAck] = useState(null)
+ const [cartOpen, setCartOpen] = useState(false)
+ const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
useEffect(() => {
async function load() {
@@ -30,31 +35,81 @@ export default function AddItemsPage() {
load()
}, [tableId])
+ // Back button: if this was a new table and nothing was added, leave the table FREE
+ function handleBack() {
+ if (isNewTable && cart.length === 0) {
+ navigate('/tables', { replace: true })
+ } else {
+ navigate(`/tables/${tableId}`)
+ }
+ }
+
function addToCart(item) {
- setCart(prev => [...prev, { ...item, _key: Date.now() + Math.random() }])
+ setCart(prev => {
+ // Try to find an identical item already in the cart to stack onto.
+ // Two items are identical when every meaningful field matches exactly.
+ const { _key: _k, _drawerState: _ds, ...newCore } = item
+ const matchIdx = prev.findIndex(existing => {
+ const { _key, _drawerState, ...existCore } = existing
+ return JSON.stringify(existCore) === JSON.stringify(newCore)
+ })
+ if (matchIdx !== -1) {
+ const next = [...prev]
+ next[matchIdx] = { ...next[matchIdx], quantity: next[matchIdx].quantity + (item.quantity ?? 1) }
+ return next
+ }
+ return [...prev, { ...item, _key: Date.now() + Math.random() }]
+ })
}
function removeFromCart(key) {
setCart(prev => prev.filter(i => i._key !== key))
}
+ function changeCartQty(key, newQty) {
+ if (newQty <= 0) {
+ removeFromCart(key)
+ } else {
+ setCart(prev => prev.map(i => i._key === key ? { ...i, quantity: newQty } : i))
+ }
+ }
+
+ function openEditDrawer(cartItem) {
+ const product = products.find(p => p.id === cartItem.product_id)
+ if (!product) return
+ setCartOpen(false)
+ setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
+ }
+
+ function handleEditSave(updatedItem) {
+ setCart(prev => prev.map(i =>
+ i._key === editItem.cartKey ? { ...updatedItem, _key: i._key } : i
+ ))
+ setEditItem(null)
+ }
+
async function sendOrder() {
- if (cart.length === 0 || !orderId) return
+ if (cart.length === 0) return
setSending(true)
setError('')
setPrintAck(null)
+ setCartOpen(false)
try {
- const res = await client.post(`/api/orders/${orderId}/items`, {
- items: cart.map(({ _key, ...item }) => item),
+ // For new (free) tables, open the order now — lazily
+ let activeOrderId = orderId
+ if (!activeOrderId) {
+ const { data: newOrder } = await client.post('/api/orders/', { table_id: Number(tableId) })
+ activeOrderId = newOrder.id
+ setOrderId(activeOrderId)
+ }
+
+ const res = await client.post(`/api/orders/${activeOrderId}/items`, {
+ items: cart.map(({ _key, _drawerState, ...item }) => item),
})
const printResults = res.data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
setPrintAck({ allOk, results: printResults })
- if (allOk) {
- // All printed fine — navigate back after a short moment
- setTimeout(() => navigate(`/tables/${tableId}`), 1200)
- }
- // If there were print failures, stay on page — waiter sees the ack panel
+ if (allOk) setTimeout(() => navigate('/tables'), 1200)
} catch (err) {
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
} finally {
@@ -62,135 +117,454 @@ export default function AddItemsPage() {
}
}
- function getProductName(id) {
- return products.find(p => p.id === id)?.name || `#${id}`
+ async function retryNow() {
+ if (!orderId) return
+ setRetrying(true)
+ try {
+ const res = await client.post(`/api/orders/${orderId}/retry-print`)
+ const printResults = res.data.print_results ?? []
+ const allOk = printResults.length === 0 || printResults.every(r => r.success)
+ setPrintAck({ allOk, results: printResults })
+ if (allOk) setTimeout(() => navigate('/tables'), 1200)
+ } catch { } finally { setRetrying(false) }
}
- // If we have a print ack with failures, show the ack overlay instead of the normal UI
+ function saveAsDraft() { navigate(`/tables/${tableId}`, { replace: true }) }
+ function leaveAndContinue() { navigate(`/tables/${tableId}`, { replace: true }) }
+
+ function getProduct(id) { return products.find(p => p.id === id) }
+
+ // Returns structured sections for the expanded cart view
+ function buildItemSections(item, product) {
+ const sections = []
+
+ if (item.selected_options?.length) {
+ // Group consecutive options into logical sections by type
+ // Prefs: options that match a preference choice (have a real id matching preference_sets choices)
+ const prefIds = new Set(
+ (product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
+ )
+ const quickNames = new Set((product?.quick_options || []).map(o => o.name))
+ const extraIds = new Set((product?.options || []).map(o => o.id))
+
+ const prefLines = []
+ const quickLines = []
+ const extraLines = []
+
+ item.selected_options.forEach(o => {
+ if (prefIds.has(o.id)) prefLines.push(o)
+ else if (o.id != null && extraIds.has(o.id)) extraLines.push(o)
+ else if (quickNames.has(o.name)) quickLines.push(o)
+ else if (o.id == null) {
+ // sub-choice — attach to last extra or pref line
+ if (extraLines.length > 0) extraLines.push({ ...o, _sub: true })
+ else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true })
+ }
+ })
+
+ // Deduplicate quick lines: multiple entries of same name → single entry with qty
+ const quickDeduped = []
+ quickLines.forEach(o => {
+ const existing = quickDeduped.find(x => x.name === o.name)
+ if (existing) existing._qty = (existing._qty || 1) + 1
+ else quickDeduped.push({ ...o, _qty: 1 })
+ })
+
+ if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines })
+ if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
+ if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines })
+ }
+
+ if (item.removed_ingredients?.length) {
+ sections.push({ type: 'removed', lines: item.removed_ingredients.map(n => ({ name: n })) })
+ }
+
+ if (item.notes) {
+ sections.push({ type: 'note', lines: [{ name: item.notes }] })
+ }
+
+ return sections
+ }
+
+ // Simple flat summary for the collapsed one-liner
+ function buildItemSummary(item) {
+ const lines = []
+ if (item.selected_options?.length) {
+ item.selected_options.forEach(o => {
+ if (o.price_delta && o.price_delta !== 0)
+ lines.push(`${o.name} (${o.price_delta > 0 ? '+' : ''}${o.price_delta.toFixed(2)} €)`)
+ else lines.push(o.name)
+ })
+ }
+ if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`)
+ if (item.notes) lines.push(item.notes)
+ return lines
+ }
+
+ // Print-failure dialog
if (printAck && !printAck.allOk) {
return (
- navigate(`/tables/${tableId}`)}>←
- Αποτέλεσμα εκτύπωσης
+ navigate(`/tables/${tableId}`, { replace: true })}>←
+ Πρόβλημα εκτύπωσης
-
-
-
-
- ⚠ Πρόβλημα εκτύπωσης
-
-
- Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
- Τα αντικείμενα παραμένουν ως "σχέδιο" — δεν έχουν σταλεί στην κουζίνα/μπαρ.
-
+
+
+
⚠ Η παραγγελία αποθηκεύτηκε
+
Ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
-
{printAck.results.map((r, i) => (
-
-
{r.success ? '✓' : '✗'}
+
+
{r.success ? '✓' : '✗'}
-
- {r.printer_name}
-
- {r.error && (
-
{r.error}
- )}
+
{r.printer_name}
+ {!r.success &&
Εκτυπωτής μη προσβάσιμος
}
))}
-
-
- navigate(`/tables/${tableId}`)}
- >
- Επιστροφή στο τραπέζι
-
- {
- setPrintAck(null)
- setCart([])
- navigate(`/tables/${tableId}`)
- }}
- >
- Εντάξει, συνέχεια
-
-
+
Επιλέξτε πώς να συνεχίσετε:
+
+ 🔄
+
+
{retrying ? 'Επανάληψη…' : 'Επανάληψη τώρα'}
+
Δοκιμή αποστολής στον εκτυπωτή ξανά
+
+
+
+ 📋
+
+
Αποθήκευση ως προσχέδιο
+
Τα αντικείμενα μένουν στο τραπέζι με πορτοκαλί ένδειξη
+
+
+
+ 🕐
+
+
Συνέχεια (προσχέδιο)
+
Τα αντικείμενα εμφανίζονται ως εκκρεμή στο dashboard
+
+
)
}
+ // Compact names for the strip preview (max 3 items shown)
+ const stripItems = cart.slice(-3).reverse()
+ const hiddenCount = cart.length > 3 ? cart.length - 3 : 0
+
return (
-
+
- navigate(`/tables/${tableId}`)}>←
- Προσθήκη
+ ←
+ {isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}
+ {/* Cart icon with badge — opens side drawer */}
document.getElementById('cart-panel').scrollIntoView({ behavior: 'smooth' })}
+ onClick={() => setCartOpen(true)}
>
- 🛒
- {cart.length > 0 && {cart.length} }
+
+
+
+ {cart.length > 0 && (
+ {cart.length}
+ )}
+ {/* Product picker takes all remaining space */}
{categories.length > 0 && (
)}
-
-
Σταδιακά ({cart.length})
-
- {cart.length === 0 && (
-
Προσθέστε αντικείμενα
- )}
-
- {cart.map(item => (
-
- {getProductName(item.product_id)} ×{item.quantity}
- removeFromCart(item._key)}>✕
-
- ))}
-
- {error &&
{error}
}
-
- {/* Success flash when all printers OK */}
- {printAck?.allOk && (
-
- ✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
+ {/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
+
+ {/* Floating compact cart — shown only when there are items */}
+ {cart.length > 0 && (
+
setCartOpen(true)}
+ style={{
+ background: 'var(--bg3)',
+ border: '1px solid var(--border)',
+ borderRadius: 12,
+ padding: '8px 12px',
+ marginBottom: 10,
+ cursor: 'pointer',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+ }}
+ >
+ {stripItems.map(item => {
+ const p = getProduct(item.product_id)
+ return (
+
+ {p?.name ?? `#${item.product_id}`}
+ ×{item.quantity}
+
+ )
+ })}
+ {hiddenCount > 0 && (
+
+ +{hiddenCount} ακόμα — δείτε όλα →
+
+ )}
)}
+ {/* Full-width send button */}
- {sending ? 'Αποστολή…' : 'Αποστολή Παραγγελίας'}
+ {sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
+
+ {error &&
{error}
}
+ {printAck?.allOk && (
+
+ ✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
+
+ )}
+
+ {/* ── Cart side drawer ────────────────────────────────────────────────── */}
+ <>
+ {/* Backdrop */}
+
setCartOpen(false)}
+ style={{
+ position: 'fixed', inset: 0,
+ background: 'rgba(0,0,0,0.55)',
+ opacity: cartOpen ? 1 : 0,
+ pointerEvents: cartOpen ? 'auto' : 'none',
+ transition: 'opacity 240ms ease',
+ zIndex: 50,
+ }}
+ />
+ {/* Panel */}
+
+ {/* Drawer header */}
+
+
+
Παραγγελία
+
{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'}
+
+
setCartOpen(false)} style={{ background: 'var(--bg3)', border: 'none', borderRadius: '50%', width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text)' }}>
+
+
+
+
+ {/* Item list */}
+
+ {cart.length === 0 ? (
+
Η παραγγελία είναι κενή.
+ ) : (
+
+ {cart.map(item => {
+ const product = getProduct(item.product_id)
+ const summaryLines = buildItemSummary(item)
+ const sections = buildItemSections(item, product)
+ return (
+ openEditDrawer(item)}
+ onRemove={() => removeFromCart(item._key)}
+ onChangeQty={qty => changeCartQty(item._key, qty)}
+ />
+ )
+ })}
+
+ )}
+
+
+ {/* Drawer footer */}
+
+
+ {sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
+
+
+
+ >
+
+ {/* Edit drawer */}
+ {editItem && (
+
setEditItem(null)}
+ onAdd={handleEditSave}
+ initialState={editItem.drawerState}
+ />
+ )}
+
+ )
+}
+
+// ── Cart Item (used in the side drawer) ───────────────────────────────────────
+
+const SECTION_META = {
+ prefs: { icon: '◉', label: null },
+ quick: { icon: '>', label: null },
+ extras: { icon: '+', label: null },
+ removed: { icon: '−', label: null },
+ note: { icon: 'i', label: null },
+}
+
+function SectionIcon({ type }) {
+ const icons = {
+ prefs:
,
+ quick:
,
+ extras:
,
+ removed:
,
+ note:
,
+ }
+ return
{icons[type] ?? null}
+}
+
+function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onChangeQty }) {
+ const [expanded, setExpanded] = useState(false)
+ const hasDetails = sections.length > 0
+
+ return (
+
+ {/* Whole header row is always clickable to expand (qty stepper is always available) */}
+
setExpanded(e => !e)}
+ style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', cursor: 'pointer' }}
+ >
+ {/* Chevron — always shown */}
+
+
+
+
+ {/* Name */}
+
+
+ {product?.name ?? `#${item.product_id}`}
+
+ {!expanded && hasDetails && (
+
+ {summaryLines[0]}{summaryLines.length > 1 ? ` +${summaryLines.length - 1}` : ''}
+
+ )}
+
+
+ {/* Quantity on the right */}
+
×{item.quantity}
+
+ {/* Edit — stop propagation so it doesn't toggle expand */}
+
{ e.stopPropagation(); onEdit() }} style={{ background: 'none', border: '1px solid var(--border)', borderRadius: 7, color: 'var(--muted)', cursor: 'pointer', padding: '3px 9px', fontSize: 12, fontWeight: 500, flexShrink: 0 }}>
+ Επεξ.
+
+ {/* Remove */}
+
{ e.stopPropagation(); onRemove() }} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', flexShrink: 0 }}>
+
+
+
+
+ {expanded && (
+
+ {sections.map((sec, si) => (
+
+ {/* Divider between sections */}
+
+
+ {sec.lines.map((line, li) => (
+
+
+
+ {sec.type === 'note' ? line.name : (
+ <>
+ {line.name}
+ {line._qty > 1 && (
+ ×{line._qty}
+ )}
+ {line.price_delta !== 0 && line.price_delta != null && (
+
+ ({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} €)
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+
+ ))}
+
+ {/* ── Quick qty row ── */}
+
+
+
{ e.stopPropagation(); onChangeQty(item.quantity - 1) }}
+ style={{
+ width: 36, height: 36, borderRadius: '50%',
+ background: 'var(--bg3)', border: '1px solid var(--border)',
+ color: item.quantity <= 1 ? 'var(--muted)' : 'var(--danger)',
+ fontSize: 20, fontWeight: 700, cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}
+ >
+ {item.quantity <= 1 ? (
+
+ ) : '−'}
+
+
+ {item.quantity}
+
+
{ e.stopPropagation(); onChangeQty(item.quantity + 1) }}
+ style={{
+ width: 36, height: 36, borderRadius: '50%',
+ background: 'var(--bg3)', border: '1px solid var(--border)',
+ color: '#22c55e',
+ fontSize: 20, fontWeight: 700, cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}
+ >+
+
+
+ )}
)
}
diff --git a/waiter_pwa/src/pages/LoginPage.jsx b/waiter_pwa/src/pages/LoginPage.jsx
index 5ba63a9..92024cd 100644
--- a/waiter_pwa/src/pages/LoginPage.jsx
+++ b/waiter_pwa/src/pages/LoginPage.jsx
@@ -1,63 +1,200 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import PinPad from '../components/PinPad'
import useAuthStore from '../store/authStore'
import client from '../api/client'
+const API_URL = import.meta.env.VITE_API_URL || ''
+
+// ─── Waiter card ──────────────────────────────────────────────────────────────
+
+function WaiterCard({ waiter, onClick }) {
+ const initials = (waiter.full_name || waiter.nickname || '?')
+ .split(' ')
+ .map(w => w[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase()
+
+ return (
+
+ {/* Avatar */}
+
+ {waiter.avatar_url
+ ?
+ : initials
+ }
+
+
+ {/* Name block */}
+
+
+ {waiter.full_name || waiter.nickname || '—'}
+
+ {waiter.nickname && waiter.full_name && (
+
{waiter.nickname}
+ )}
+
+
+ {/* On-shift dot */}
+ {waiter.on_shift && (
+
+ )}
+
+ ›
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
export default function LoginPage() {
- const { savedUsername, login, clearSavedUsername } = useAuthStore()
- const [username, setUsername] = useState(savedUsername || '')
- const [error, setError] = useState('')
- const [loading, setLoading] = useState(false)
+ const { login } = useAuthStore()
const navigate = useNavigate()
+ const [waiters, setWaiters] = useState([])
+ const [loadingWaiters, setLoadingWaiters] = useState(true)
+ const [selectedWaiter, setSelectedWaiter] = useState(null)
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ client.get('/api/auth/waiters')
+ .then(r => setWaiters(r.data))
+ .catch(() => setWaiters([]))
+ .finally(() => setLoadingWaiters(false))
+ }, [])
+
async function handlePin(pin) {
+ if (!selectedWaiter) return
setError('')
setLoading(true)
try {
- const { data } = await client.post('/api/auth/login', { username, pin })
+ // We send waiter id as identifier; backend matches by id+pin
+ const { data } = await client.post('/api/auth/login-by-id', { waiter_id: selectedWaiter.id, pin })
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
navigate('/tables')
} catch (err) {
- setError(err.response?.data?.detail || 'Λανθασμένα στοιχεία')
+ setError(err.response?.data?.detail || 'Λανθασμένο PIN')
} finally {
setLoading(false)
}
}
- function switchUser() {
- clearSavedUsername()
- setUsername('')
- setError('')
+ // ── Waiter picker screen ───────────────────────────────────────────────────
+
+ if (!selectedWaiter) {
+ // Sort: on-shift first, then alphabetical
+ const sorted = [...waiters].sort((a, b) => {
+ if (a.on_shift !== b.on_shift) return a.on_shift ? -1 : 1
+ return (a.full_name || a.nickname || '').localeCompare(b.full_name || b.nickname || '')
+ })
+
+ return (
+
+ {/* Static header — never scrolls */}
+
+
TableServe
+
Ποιος είσαι;
+
+
+ {/* Scrollable card list */}
+
+
+ {loadingWaiters ? (
+
Φόρτωση…
+ ) : waiters.length === 0 ? (
+
Δεν βρέθηκαν σερβιτόροι
+ ) : (
+
+ {sorted.map(w => (
+ { setError(''); setSelectedWaiter(w) }} />
+ ))}
+
+ )}
+
+
+
+ )
}
+ // ── PIN screen ─────────────────────────────────────────────────────────────
+
+ const initials = (selectedWaiter.full_name || selectedWaiter.nickname || '?')
+ .split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
+
return (
TableServe
-
Σύστημα Παραγγελιών
- {savedUsername ? (
-
Καλωσόρισες, {savedUsername}
- ) : (
-
setUsername(e.target.value)}
- autoComplete="off"
- />
- )}
+ {/* Selected waiter mini-card */}
+
+
+ {selectedWaiter.avatar_url
+ ?
+ : initials
+ }
+
+
+
+ {selectedWaiter.full_name || selectedWaiter.nickname}
+
+ {selectedWaiter.nickname && selectedWaiter.full_name && (
+
{selectedWaiter.nickname}
+ )}
+
+
{ setSelectedWaiter(null); setError('') }}
+ style={{ background: 'none', border: 'none', color: 'var(--muted)', cursor: 'pointer', fontSize: 13, padding: '4px 8px' }}
+ >
+ Αλλαγή
+
+
+
+
Εισάγετε PIN
{error &&
{error}
}
-
- {savedUsername && (
-
- Δεν είσαι εσύ;
-
- )}
)
diff --git a/waiter_pwa/src/pages/TableDetailPage.jsx b/waiter_pwa/src/pages/TableDetailPage.jsx
index 13db93a..055d4c3 100644
--- a/waiter_pwa/src/pages/TableDetailPage.jsx
+++ b/waiter_pwa/src/pages/TableDetailPage.jsx
@@ -1,15 +1,402 @@
-import { useEffect, useState } from 'react'
-import { useNavigate, useParams } from 'react-router-dom'
+import { useEffect, useState, useRef } from 'react'
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import OrderSummary from '../components/OrderSummary'
import useAuthStore from '../store/authStore'
import client from '../api/client'
+import { TransferIcon, MergeIcon, FlagsIcon, WaiterIcon, PrintIcon } from '../components/Icons'
-function fmtPrice(v) {
- return Number(v).toFixed(2) + ' €'
+function fmtPrice(v) { return Number(v).toFixed(2) + ' €' }
+
+// ─── Print results modal ──────────────────────────────────────────────────────
+
+function PrintResultsModal({ results, onClose }) {
+ return (
+
+
e.stopPropagation()}>
+
+
Αποτέλεσμα εκτύπωσης
+
+ {results.map((r, i) => (
+
+
{r.success ? '✓' : '✗'}
+
+
+ {r.printer_name}
+
+ {!r.success && (
+
Εκτυπωτής μη προσβάσιμος
+ )}
+
+
+ ))}
+
+
Κλείσιμο
+
+
+ )
}
+// ─── Split stepper modal ──────────────────────────────────────────────────────
+
+function SplitModal({ item, onConfirm, onClose }) {
+ const [splitQty, setSplitQty] = useState(1)
+ const max = item.quantity - 1
+
+ return (
+
+
e.stopPropagation()}>
+
+
Διαχωρισμός
+
+ {item.product?.name} ×{item.quantity}
+
+
+ Χώρισε σε πόσα;
+
+
+
+ setSplitQty(q => Math.max(1, q - 1))}>−
+ {splitQty}
+ setSplitQty(q => Math.min(max, q + 1))}>+
+
+
+
+ Νέα γραμμή: ×{splitQty}
+ Μένει: ×{item.quantity - splitQty}
+
+
+
+ Άκυρο
+ onConfirm(splitQty)}>
+ Διαχωρισμός
+
+
+
+
+ )
+}
+
+// ─── Actions top sheet ────────────────────────────────────────────────────────
+
+function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
+ const hasOrder = !!order
+ const actions = [
+ { Icon: TransferIcon, label: 'Μεταφορά Τραπεζιού', sub: 'Μεταφορά σε άλλο τραπέζι', onClick: hasOrder ? onTransfer : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
+ { Icon: MergeIcon, label: 'Συγχώνευση Τραπεζιού', sub: 'Συγχώνευση με άλλο τραπέζι', onClick: hasOrder ? onMerge : null, color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
+ { Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', sub: 'Επιλογή σημαιών', onClick: onSetFlags, color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
+ { Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', sub: 'Προσθήκη σερβιτόρου στην παραγγελία', onClick: hasOrder ? onAssignWaiter : null, color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
+ { Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', sub: 'Εκτύπωση σύνοψης παραγγελίας', onClick: hasOrder ? onPrintSynopsis : null, color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
+ ]
+
+ return (
+
+
e.stopPropagation()} style={{ gap: 0 }}>
+
+
ACTIONS
+ {actions.map((a, i) => (
+
{ a.onClick?.(); onClose() }}
+ disabled={!a.onClick}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 16,
+ padding: '14px 0', background: 'none', border: 'none',
+ borderBottom: i < actions.length - 1 ? '1px solid var(--border)' : 'none',
+ cursor: a.onClick ? 'pointer' : 'not-allowed',
+ opacity: a.onClick ? 1 : 0.35, textAlign: 'left',
+ }}
+ >
+
+
+
+
+ {a.onClick && › }
+
+ ))}
+
+
+ )
+}
+
+// ─── Table picker (for transfer / merge / move-items) ─────────────────────────
+
+function TablePicker({ title, subtitle, tables, currentTableId, onSelect, onClose, loading }) {
+ return (
+
+
e.stopPropagation()}>
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {loading && (
+
Φόρτωση…
+ )}
+ {!loading && tables.length === 0 && (
+
Δεν υπάρχουν διαθέσιμα τραπέζια
+ )}
+ {!loading && tables.map(t => (
+
onSelect(t)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: '14px 4px', background: 'none', border: 'none',
+ borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
+ }}
+ >
+
+
+ {t.label || `T${t.number}`}
+
+ {t.orderStatus && (
+
+ {t.orderStatus === 'open' ? 'Ανοιχτό' : t.orderStatus === 'partially_paid' ? 'Μερικώς πληρωμένο' : t.orderStatus}
+
+ )}
+
+ ›
+
+ ))}
+
+
Άκυρο
+
+
+ )
+}
+
+// ─── Flag picker ──────────────────────────────────────────────────────────────
+
+function FlagPicker({ tableId, currentFlagIds, flagDefs, onSave, onClose, loading }) {
+ const [selected, setSelected] = useState(currentFlagIds || [])
+
+ // Sync selected when currentFlagIds loads
+ useEffect(() => { setSelected(currentFlagIds || []) }, [currentFlagIds.join(',')])
+
+ function toggle(id) {
+ setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
Ενδείξεις Τραπεζιού
+
+ {loading && (
+
Φόρτωση…
+ )}
+ {!loading && flagDefs.length === 0 && (
+
Δεν υπάρχουν σημαίες
+ )}
+ {!loading && flagDefs.map(f => {
+ const sel = selected.includes(f.id)
+ return (
+
toggle(f.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 14,
+ padding: '14px 4px', background: 'none', border: 'none',
+ borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
+ opacity: sel ? 1 : 0.7,
+ }}
+ >
+
+ {f.emoji || '🏷️'}
+
+
+ {f.name}
+
+
+ {sel ? '✓' : '○'}
+
+
+ )
+ })}
+
+
+ Άκυρο
+ onSave(selected)}>Αποθήκευση
+
+
+
+ )
+}
+
+// ─── Assign waiter picker ─────────────────────────────────────────────────────
+
+function AssignWaiterPicker({ orderId, currentWaiterIds, waiters, onAssigned, onClose, loading }) {
+ const [saving, setSaving] = useState(false)
+
+ async function assign(waiterId) {
+ setSaving(true)
+ try {
+ await client.put(`/api/orders/${orderId}/assign-waiter`, { waiter_id: waiterId })
+ onAssigned()
+ onClose()
+ } catch {
+ // already assigned or error — just close
+ onClose()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const available = waiters.filter(w => !currentWaiterIds.includes(w.id))
+
+ return (
+
+
e.stopPropagation()}>
+
+
Ανάθεση Σερβιτόρου
+
+ {loading && (
+
Φόρτωση…
+ )}
+ {!loading && available.length === 0 && (
+
Όλοι οι σερβιτόροι έχουν ήδη ανατεθεί
+ )}
+ {!loading && available.map(w => (
+
assign(w.id)}
+ disabled={saving}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 14,
+ padding: '14px 4px', background: 'none', border: 'none',
+ borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
+ }}
+ >
+
+ {(w.nickname || w.username).charAt(0).toUpperCase()}
+
+ {w.nickname || w.full_name || w.username}
+ ›
+
+ ))}
+
+
Άκυρο
+
+
+ )
+}
+
+// ─── Print synopsis picker ────────────────────────────────────────────────────
+
+function PrintSynopsisPicker({ orderId, onClose }) {
+ const [printers, setPrinters] = useState([])
+ const [printing, setPrinting] = useState(false)
+
+ useEffect(() => {
+ client.get('/api/system/status').then(r => setPrinters(r.data?.printers || [])).catch(() => {})
+ }, [])
+
+ async function print(printerId) {
+ setPrinting(true)
+ try {
+ await client.post(`/api/orders/${orderId}/print-synopsis`, { printer_id: printerId })
+ onClose()
+ } catch {
+ onClose()
+ } finally {
+ setPrinting(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
Εκτύπωση Σύνοψης
+
Επιλέξτε εκτυπωτή
+
+ {printers.filter(p => p.reachable).map(p => (
+
print(p.id)}
+ disabled={printing}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 14,
+ padding: '14px 4px', background: 'none', border: 'none',
+ borderBottom: '1px solid var(--border)', cursor: 'pointer', textAlign: 'left',
+ }}
+ >
+ 🖨️
+ {p.name}
+ ›
+
+ ))}
+ {printers.filter(p => !p.reachable).map(p => (
+
+ 🖨️
+ {p.name}
+ Offline
+
+ ))}
+
+
Άκυρο
+
+
+ )
+}
+
+// ─── Pay confirm modal ────────────────────────────────────────────────────────
+
+function PayConfirmModal({ payAll, payIds, activeItems, onConfirm, onClose }) {
+ const payTotal = activeItems
+ .filter(i => payIds.includes(i.id))
+ .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+
+ return (
+
+
e.stopPropagation()}>
+
+
Επιβεβαίωση πληρωμής
+
+ {payAll ? 'Όλα τα αντικείμενα' : `${payIds.length} αντικείμενο${payIds.length !== 1 ? 'α' : ''}`}
+
+
+ {fmtPrice(payTotal)}
+
+
+
+ Άκυρο
+
+
+ Πληρώθηκαν ✓
+
+
+
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
export default function TableDetailPage() {
const { tableId } = useParams()
+ const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuthStore()
const navigate = useNavigate()
@@ -21,18 +408,47 @@ export default function TableDetailPage() {
const [confirmClose, setConfirmClose] = useState(false)
const [confirmPay, setConfirmPay] = useState(false)
const [error, setError] = useState('')
+ const [printResults, setPrintResults] = useState(null)
+ const [retrying, setRetrying] = useState(false)
+
+ // Tab: 'active' | 'paid'
+ const [activeTab, setActiveTab] = useState('active')
+
+ // Actions sheet state
+ const [showActions, setShowActions] = useState(false)
+ const [actionsMode, setActionsMode] = useState(null)
+ // actionsMode: null | 'transfer' | 'merge' | 'flags' | 'assign_waiter' | 'print_synopsis' | 'split' | 'move_items'
+ const [pendingTable, setPendingTable] = useState(null)
+ // pendingTable: { table, mode: 'transfer'|'merge'|'move_items' } — waiting for confirmation
+
+ // Data for sub-flows
+ const [allTables, setAllTables] = useState([])
+ const [allOrders, setAllOrders] = useState([])
+ const [flagDefs, setFlagDefs] = useState([])
+ const [currentFlagIds, setCurrentFlagIds] = useState([])
+ const [allWaiters, setAllWaiters] = useState([])
+ const [actionDataLoading, setActionDataLoading] = useState(false)
+ const [splitItem, setSplitItem] = useState(null)
+
+ const scrollRef = useRef(null)
async function load() {
setLoading(true)
try {
- const { data } = await client.get(`/api/tables/${tableId}/status`)
- setTable(data.table)
- if (data.active_order_id) {
- const { data: o } = await client.get(`/api/orders/${data.active_order_id}`)
+ const [statusRes, flagDefsRes, flagAssignRes] = await Promise.all([
+ client.get(`/api/tables/${tableId}/status`),
+ client.get('/api/flags/defs'),
+ client.get(`/api/flags/table/${tableId}`),
+ ])
+ setTable(statusRes.data.table)
+ if (statusRes.data.active_order_id) {
+ const { data: o } = await client.get(`/api/orders/${statusRes.data.active_order_id}`)
setOrder(o)
} else {
setOrder(null)
}
+ setFlagDefs(flagDefsRes.data)
+ setCurrentFlagIds(flagAssignRes.data.map(a => a.flag_id))
} catch {
setError('Σφάλμα φόρτωσης')
} finally {
@@ -42,10 +458,58 @@ export default function TableDetailPage() {
useEffect(() => { load() }, [tableId])
+ // Handle ?action= param from table list long-press quick actions
+ useEffect(() => {
+ const action = searchParams.get('action')
+ if (!action || loading) return
+ setSearchParams({}, { replace: true })
+ // Load supporting data, then jump straight to the action mode
+ loadActionData()
+ setActionsMode(action)
+ }, [loading])
+
+ // Load supporting data for sub-flows (lazy, only when Actions opened)
+ async function loadActionData() {
+ setActionDataLoading(true)
+ const [tablesRes, ordersRes, waitersRes] = await Promise.allSettled([
+ client.get('/api/tables/'),
+ client.get('/api/orders/active'),
+ client.get('/api/waiters/on-shift'),
+ ])
+ if (tablesRes.status === 'fulfilled') setAllTables(tablesRes.value.data)
+ if (ordersRes.status === 'fulfilled') setAllOrders(ordersRes.value.data)
+ if (waitersRes.status === 'fulfilled') setAllWaiters(waitersRes.value.data)
+ setActionDataLoading(false)
+ }
+
+ function openActions() {
+ setAllTables([])
+ setAllOrders([])
+ setAllWaiters([])
+ loadActionData()
+ setShowActions(true)
+ }
+
const activeItems = order?.items?.filter(i => i.status === 'active') || []
+ const paidItems = order?.items?.filter(i => i.status === 'paid') || []
+ const unprintedItems = activeItems.filter(i => !i.printed)
const allPaid = order && activeItems.length === 0
- // Any waiter whose zone covers this table can interact with orders on it
const canInteract = !!order
+ const allActiveSelected = activeItems.length > 0 && activeItems.every(i => selectedIds.includes(i.id))
+
+ async function sendDraftToKitchen() {
+ if (!order || retrying) return
+ setRetrying(true)
+ try {
+ const res = await client.post(`/api/orders/${order.id}/retry-print`)
+ setPrintResults(res.data.print_results ?? [])
+ await load()
+ } catch {
+ setError('Σφάλμα κατά την εκτύπωση')
+ } finally {
+ setRetrying(false)
+ }
+ }
async function openOrder() {
try {
@@ -59,8 +523,9 @@ export default function TableDetailPage() {
async function paySelected() {
setPaying(true)
setConfirmPay(false)
+ const idsToPayNow = selectedIds.length > 0 ? selectedIds : activeItems.map(i => i.id)
try {
- await client.post(`/api/orders/${order.id}/pay`, { item_ids: selectedIds })
+ await client.post(`/api/orders/${order.id}/pay`, { item_ids: idsToPayNow })
setSelectedIds([])
await load()
} catch {
@@ -90,26 +555,154 @@ export default function TableDetailPage() {
setSelectedIds(allSelected ? [] : allActive)
}
- const selectedTotal = activeItems
- .filter(i => selectedIds.includes(i.id))
- .reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ // Split item: called from SplitModal
+ async function doSplit(qty) {
+ if (!splitItem || !order) return
+ setSplitItem(null)
+ try {
+ await client.post(`/api/orders/${order.id}/items/${splitItem.id}/split`, { quantity: qty })
+ setSelectedIds([])
+ await load()
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Σφάλμα διαχωρισμού')
+ }
+ }
- const allActiveSelected = activeItems.length > 0 && activeItems.every(i => selectedIds.includes(i.id))
+ // Transfer table
+ async function doTransfer(targetTable) {
+ if (!order) return
+ setPendingTable(null)
+ setActionsMode(null)
+ try {
+ await client.post(`/api/orders/${order.id}/transfer`, { target_table_id: targetTable.id })
+ navigate('/tables')
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Σφάλμα μεταφοράς')
+ }
+ }
+
+ // Merge table
+ async function doMerge(targetTable) {
+ if (!order) return
+ const targetOrder = allOrders.find(o => o.table_id === targetTable.id)
+ if (!targetOrder) { setError('Δεν βρέθηκε παραγγελία στο τραπέζι'); return }
+ setPendingTable(null)
+ setActionsMode(null)
+ try {
+ await client.post(`/api/orders/${order.id}/merge`, { target_order_id: targetOrder.id })
+ navigate('/tables')
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Σφάλμα συγχώνευσης')
+ }
+ }
+
+ // Move items to another table
+ async function doMoveItems(targetTable) {
+ if (!order || selectedIds.length === 0) return
+ const targetOrder = allOrders.find(o => o.table_id === targetTable.id)
+ if (!targetOrder) { setError('Δεν βρέθηκε παραγγελία στο τραπέζι'); return }
+ setPendingTable(null)
+ setActionsMode(null)
+ try {
+ await client.post(`/api/orders/${order.id}/move-items`, {
+ item_ids: selectedIds,
+ target_order_id: targetOrder.id,
+ })
+ setSelectedIds([])
+ await load()
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Σφάλμα μεταφοράς αντικειμένων')
+ }
+ }
+
+ // Save flags
+ async function doSaveFlags(flagIds) {
+ try {
+ await client.put(`/api/flags/table/${tableId}`, { flag_ids: flagIds })
+ setCurrentFlagIds(flagIds)
+ setActionsMode(null)
+ } catch {
+ setError('Σφάλμα αποθήκευσης σημαιών')
+ }
+ }
+
+ // Tables for transfer: active tables that are free (no active order), excluding current
+ const transferTargets = allTables.filter(t =>
+ t.id !== Number(tableId) &&
+ t.is_active &&
+ !allOrders.some(o => o.table_id === t.id)
+ )
+
+ // Tables for merge: tables with active orders, excluding current
+ const mergeTargets = allTables
+ .filter(t => t.id !== Number(tableId) && t.is_active)
+ .map(t => {
+ const o = allOrders.find(ord => ord.table_id === t.id)
+ return o ? { ...t, orderStatus: o.status } : null
+ })
+ .filter(Boolean)
+
+ // Tables for move-items: tables with open/partially_paid orders, excluding current
+ const moveItemsTargets = allTables
+ .filter(t => t.id !== Number(tableId) && t.is_active)
+ .map(t => {
+ const o = allOrders.find(ord => ord.table_id === t.id)
+ return o && (o.status === 'open' || o.status === 'partially_paid') ? { ...t, orderStatus: o.status } : null
+ })
+ .filter(Boolean)
+
+ const tableName = table ? (table.label || `T${table.number}`) : '—'
if (loading) return
return (
+ {/* Top bar */}
navigate('/tables')}>←
- Τραπέζι {table?.number}
- ↺
+ {tableName}
+
+ ⚡
+ ACTIONS
+
{error &&
{error}
}
+ {/* Active flag cards */}
+ {currentFlagIds.length > 0 && flagDefs.length > 0 && (
+
+ {currentFlagIds.map(fid => {
+ const def = flagDefs.find(d => d.id === fid)
+ if (!def) return null
+ return (
+ doSaveFlags(currentFlagIds.filter(id => id !== fid))}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 12,
+ padding: '10px 14px',
+ background: (def.color || '#6295F3') + '22',
+ border: `1px solid ${def.color || '#6295F3'}`,
+ borderRadius: 12, cursor: 'pointer', textAlign: 'left',
+ width: '100%',
+ }}
+ >
+ {def.emoji || '🏷️'}
+ {def.name}
+ ✕
+
+ )
+ })}
+
+ )}
+
{!order && (
-
+
Δεν υπάρχει ενεργή παραγγελία
Άνοιγμα Παραγγελίας
@@ -118,70 +711,250 @@ export default function TableDetailPage() {
)}
{order && (
-
-
-
- {canInteract && activeItems.length > 0 && (
-
-
- {allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
+
+ {/* Unprinted items warning */}
+ {unprintedItems.length > 0 && (
+
+
⏳
+
+
+ {unprintedItems.length} αντικείμενο{unprintedItems.length !== 1 ? 'α' : ''} δεν εκτυπώθηκε{unprintedItems.length !== 1 ? 'αν' : ''}
+
+
+ Δεν έχουν σταλεί στην κουζίνα/μπαρ
+
+
+
+ {retrying ? '…' : 'Αποστολή'}
)}
- {canInteract && (
-
-
navigate(`/tables/${tableId}/add`)}>
- + Προσθήκη
-
+ {/* ── Tabs ── */}
+
+ setActiveTab('active')}
+ style={{
+ flex: 1, padding: '9px 0', border: 'none', cursor: 'pointer',
+ fontWeight: 700, fontSize: 13,
+ background: activeTab === 'active' ? 'var(--accent)' : 'transparent',
+ color: activeTab === 'active' ? 'var(--accent-fg)' : 'var(--muted)',
+ transition: 'background 0.15s',
+ }}
+ >
+ Εκκρεμή {activeItems.length > 0 && (
+ {activeItems.length}
+ )}
+
+ setActiveTab('paid')}
+ style={{
+ flex: 1, padding: '9px 0', border: 'none', cursor: 'pointer',
+ fontWeight: 700, fontSize: 13,
+ background: activeTab === 'paid' ? '#14532d' : 'transparent',
+ color: activeTab === 'paid' ? '#86efac' : 'var(--muted)',
+ transition: 'background 0.15s',
+ }}
+ >
+ Πληρωμένα {paidItems.length > 0 && (
+ {paidItems.length}
+ )}
+
+
-
setConfirmPay(true)}
- disabled={selectedIds.length === 0 || paying}
- >
- {paying ? '…' : `Πληρωμή (${selectedIds.length})`}
-
+ {activeTab === 'active' && (
+ <>
+
{ setSplitItem(item) }}
+ />
- setConfirmClose(true)}
- disabled={!allPaid}
- >
- Κλείσιμο
-
+ {/* Floating controls row — only visible when items are selected */}
+ {canInteract && activeItems.length > 0 && selectedIds.length > 0 && (
+
+ {/* Clear selection */}
+ setSelectedIds([])}
+ style={{
+ display: 'inline-flex', alignItems: 'center',
+ height: 36, padding: '0 16px',
+ borderRadius: 999,
+ background: 'rgba(239,68,68,0.18)', color: '#fca5a5',
+ border: 'none', cursor: 'pointer',
+ fontSize: 13, fontWeight: 600,
+ }}
+ >
+ καθ. επιλ.
+
+
+ {/* Select all — hidden once everything is already selected */}
+ {!allActiveSelected && (
+
+ όλα
+ {activeItems.length}
+
+ )}
+
+ {/* Transfer items */}
+ {
+ setAllTables([])
+ setAllOrders([])
+ loadActionData()
+ setActionsMode('move_items')
+ }}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ height: 36, padding: '0 16px',
+ borderRadius: 999,
+ background: 'rgba(96,165,250,0.18)', color: '#93c5fd',
+ border: 'none', cursor: 'pointer',
+ fontSize: 13, fontWeight: 600,
+ }}
+ >
+ μεταφορά
+ {selectedIds.length}
+
+
+ )}
+ >
+ )}
+
+ {activeTab === 'paid' && (
+
+ {paidItems.length === 0 ? (
+
+ Δεν υπάρχουν πληρωμένα αντικείμενα
+
+ ) : (
+
{}}
+ />
+ )}
)}
+
)}
+ {/* Action bar — sticky at bottom, only when there's an active order on the active tab */}
+ {order && canInteract && activeTab === 'active' && (
+
+ navigate(`/tables/${tableId}/add`)}
+ style={{ borderRadius: 999, flex: 1 }}
+ >
+ + ADD
+
+
+ setConfirmPay(true)}
+ disabled={paying}
+ style={{ borderRadius: 999, flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
+ >
+ {paying ? '…' : (
+ <>
+ € PAY
+ {selectedIds.length > 0 && (
+ {selectedIds.length}
+ )}
+ >
+ )}
+
+
+ setConfirmClose(true)}
+ disabled={!allPaid}
+ style={{ borderRadius: 999, flex: 1 }}
+ >
+ ✕ CLOSE
+
+
+ )}
+
+ {/* Split stepper modal */}
+ {splitItem && (
+
setSplitItem(null)}
+ />
+ )}
+
{/* Pay confirmation */}
{confirmPay && (
- setConfirmPay(false)}>
-
e.stopPropagation()}>
-
-
Επιβεβαίωση πληρωμής
-
- {selectedIds.length} αντικείμενο{selectedIds.length !== 1 ? 'α' : ''}
-
-
- {fmtPrice(selectedTotal)}
-
-
- setConfirmPay(false)}>
- Άκυρο
-
-
- Πληρώθηκαν ✓
-
-
-
-
+ i.id) : selectedIds}
+ activeItems={activeItems}
+ onConfirm={paySelected}
+ onClose={() => setConfirmPay(false)}
+ />
)}
{/* Close confirmation */}
@@ -198,12 +971,142 @@ export default function TableDetailPage() {
Άκυρο
- Κλείσιμο
+ CLOSE
)}
+
+ {/* Print retry results */}
+ {printResults && (
+ setPrintResults(null)} />
+ )}
+
+ {/* Actions top sheet */}
+ {showActions && actionsMode === null && (
+ setShowActions(false)}
+ onTransfer={() => { setShowActions(false); setActionsMode('transfer') }}
+ onMerge={() => { setShowActions(false); setActionsMode('merge') }}
+ onSetFlags={() => { setShowActions(false); setActionsMode('flags') }}
+ onAssignWaiter={() => { setShowActions(false); setActionsMode('assign_waiter') }}
+ onPrintSynopsis={() => { setShowActions(false); setActionsMode('print_synopsis') }}
+ />
+ )}
+
+ {/* Transfer picker */}
+ {actionsMode === 'transfer' && !pendingTable && (
+ setPendingTable({ table: t, mode: 'transfer' })}
+ onClose={() => setActionsMode(null)}
+ loading={actionDataLoading}
+ />
+ )}
+
+ {/* Merge picker */}
+ {actionsMode === 'merge' && !pendingTable && (
+ setPendingTable({ table: t, mode: 'merge' })}
+ onClose={() => setActionsMode(null)}
+ loading={actionDataLoading}
+ />
+ )}
+
+ {/* Move items picker */}
+ {actionsMode === 'move_items' && !pendingTable && (
+ setPendingTable({ table: t, mode: 'move_items' })}
+ onClose={() => setActionsMode(null)}
+ loading={actionDataLoading}
+ />
+ )}
+
+ {/* Transfer / Merge / Move-items confirmation */}
+ {pendingTable && (
+ setPendingTable(null)}>
+
e.stopPropagation()}>
+
+
+ {pendingTable.mode === 'transfer' ? 'Επιβεβαίωση Μεταφοράς'
+ : pendingTable.mode === 'merge' ? 'Επιβεβαίωση Συγχώνευσης'
+ : 'Επιβεβαίωση Μεταφοράς Αντικειμένων'}
+
+
+ {pendingTable.mode === 'transfer' ? 'Μεταφορά παραγγελίας στο τραπέζι'
+ : pendingTable.mode === 'merge' ? 'Συγχώνευση παραγγελίας με το τραπέζι'
+ : `Μεταφορά ${selectedIds.length} αντικειμένου${selectedIds.length !== 1 ? 'ων' : ''} στο τραπέζι`}
+
+
+ {pendingTable.table.label || `T${pendingTable.table.number}`}
+
+
+ setPendingTable(null)}>
+ Άκυρο
+
+ {
+ if (pendingTable.mode === 'transfer') doTransfer(pendingTable.table)
+ else if (pendingTable.mode === 'merge') doMerge(pendingTable.table)
+ else doMoveItems(pendingTable.table)
+ }}
+ >
+ {pendingTable.mode === 'transfer' ? 'Μεταφορά ✓'
+ : pendingTable.mode === 'merge' ? 'Συγχώνευση ✓'
+ : 'Μεταφορά ✓'}
+
+
+
+
+ )}
+
+ {/* Flag picker */}
+ {actionsMode === 'flags' && (
+ setActionsMode(null)}
+ loading={actionDataLoading}
+ />
+ )}
+
+ {/* Assign waiter */}
+ {actionsMode === 'assign_waiter' && order && (
+ w.waiter_id)}
+ waiters={allWaiters}
+ onAssigned={load}
+ onClose={() => setActionsMode(null)}
+ loading={actionDataLoading}
+ />
+ )}
+
+ {/* Print synopsis */}
+ {actionsMode === 'print_synopsis' && order && (
+ setActionsMode(null)}
+ />
+ )}
)
}
diff --git a/waiter_pwa/src/pages/TableListPage.jsx b/waiter_pwa/src/pages/TableListPage.jsx
index 229cd41..257366d 100644
--- a/waiter_pwa/src/pages/TableListPage.jsx
+++ b/waiter_pwa/src/pages/TableListPage.jsx
@@ -2,31 +2,218 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import TableCard from '../components/TableCard'
import ConnectionBanner from '../components/ConnectionBanner'
+import UserMenu from '../components/UserMenu'
import useAuthStore from '../store/authStore'
+import useTableColourStore from '../store/tableColourStore'
import client from '../api/client'
+import { useNotifications } from '../context/NotificationContext'
+import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
const FILTERS = ['all', 'mine', 'free']
const FILTER_LABELS = { all: 'Όλα', mine: 'Δικά μου', free: 'Ελεύθερα' }
+function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
+
+// ─── Notification history drawer ─────────────────────────────────────────────
+
+function NotificationDrawer({ messages, onClose, onAck }) {
+ return (
+
+
e.stopPropagation()} style={{ maxHeight: '80svh' }}>
+
+
Ειδοποιήσεις
+ {messages.length === 0 && (
+
Δεν υπάρχουν ειδοποιήσεις
+ )}
+
+ {messages.map(msg => {
+ const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })()
+ return (
+
+
📢
+
+ {msg.sender_name && (
+
+ {msg.sender_name}
+
+ )}
+
{msg.body}
+ {tableIds.length > 0 && (
+
Τραπέζι: {tableIds.join(', ')}
+ )}
+
+ {new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ )
+ })}
+
+
Κλείσιμο
+
+
+ )
+}
+
+// ─── Table quick-view + actions popup (long-press) ────────────────────────────
+
+const QUICK_ACTIONS = [
+ { Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
+ { Icon: TransferIcon, label: 'Μεταφορά', key: 'transfer', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
+ { Icon: MergeIcon, label: 'Συγχώνευση', key: 'merge', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
+ { Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', key: 'print_synopsis', color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
+ { Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
+]
+
+function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) {
+ const tableName = table.label || `T${table.number}`
+ const activeItems = order?.items?.filter(i => i.status === 'active') || []
+ const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
+ const paid = order?.payments?.reduce((s, p) => s + p.amount, 0) || 0
+ const due = Math.max(0, total - paid)
+
+ const statusLabel = {
+ open: 'Ανοιχτό',
+ partially_paid: 'Μερικώς πληρωμένο',
+ paid: 'Πληρωμένο',
+ }[order?.status] || 'Ελεύθερο'
+
+ return (
+
+ {/* Status overview card */}
+
e.stopPropagation()}>
+
+
+
+ {tableName}
+ {statusLabel}
+
+
+ {order ? (
+
+
+ Σύνολο
+ {fmtPrice(total)}
+
+
+ Πληρωμένο
+ {fmtPrice(paid)}
+
+ {due > 0 && (
+
+ Υπόλοιπο
+ {fmtPrice(due)}
+
+ )}
+
+ ) : (
+
Δεν υπάρχει ενεργή παραγγελία
+ )}
+
+ {flags.length > 0 && (
+
+ {flags.map(f => (
+
+ {f.emoji || '🏷️'}
+ {f.name}
+
+ ))}
+
+ )}
+
+
{ onClose(); onNavigate() }}
+ >
+ Άνοιγμα τραπεζιού
+
+
+
+ {/* Quick actions card */}
+
+
+ ACTIONS
+
+
+ {QUICK_ACTIONS.map((a, i) => {
+ const disabled = !order && a.key !== 'flags'
+ return (
+
{ onClose(); onAction(a.key) }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 14,
+ padding: '12px 0', background: 'none', border: 'none',
+ borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ opacity: disabled ? 0.35 : 1, textAlign: 'left',
+ }}
+ >
+
+
+
+ {a.label}
+ {!disabled && › }
+
+ )
+ })}
+
+
+
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
export default function TableListPage() {
- const { user, logout } = useAuthStore()
+ const { user } = useAuthStore()
const [tables, setTables] = useState([])
const [groups, setGroups] = useState([])
const [orders, setOrders] = useState([])
+ const [flagDefs, setFlagDefs] = useState([])
+ const [flagAssignments, setFlagAssignments] = useState([])
const [filter, setFilter] = useState('all')
const [offline, setOffline] = useState(false)
const [zoneOpen, setZoneOpen] = useState(false)
const [selectedZones, setSelectedZones] = useState(new Set())
+ const [showNotifs, setShowNotifs] = useState(false)
+ const [quickModal, setQuickModal] = useState(null) // { table, order, flags }
const zoneRef = useRef(null)
const navigate = useNavigate()
+ const { unreadCount, recentMessages, ackMessage, fetchRecent } = useNotifications() || {}
+ const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
+
useEffect(() => {
const handler = () => setOffline(true)
window.addEventListener('backend-offline', handler)
return () => window.removeEventListener('backend-offline', handler)
}, [])
- // Close zone dropdown on outside click
useEffect(() => {
function onClick(e) {
if (zoneRef.current && !zoneRef.current.contains(e.target)) setZoneOpen(false)
@@ -37,22 +224,42 @@ export default function TableListPage() {
async function load() {
try {
- const [tablesRes, ordersRes, groupsRes] = await Promise.all([
+ const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes] = await Promise.all([
client.get('/api/tables/'),
- client.get('/api/orders/my'),
+ client.get('/api/orders/active'),
client.get('/api/tables/groups'),
+ client.get('/api/flags/defs'),
+ client.get('/api/flags/assignments'),
+ client.get('/api/settings/'),
])
setTables(tablesRes.data)
setOrders(ordersRes.data)
setGroups(groupsRes.data)
+ setFlagDefs(flagDefsRes.data)
+ setFlagAssignments(flagAssignRes.data)
+ const raw = settingsRes.data?.['ui.table_colours']?.value
+ if (raw) loadFromBackend(raw)
setOffline(false)
} catch {}
}
useEffect(() => { load() }, [])
+ const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
+ const tableFlagsMap = {}
+ flagAssignments.forEach(a => {
+ if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = []
+ const def = flagDefMap[a.flag_id]
+ if (def) tableFlagsMap[a.table_id].push(def)
+ })
+
function getOrder(tableId) {
- return orders.find(o => o.table_id === tableId && ['open', 'partially_paid'].includes(o.status))
+ return orders.find(o => o.table_id === tableId)
+ }
+
+ function isMyOrder(order) {
+ if (!order || !user) return false
+ return order.waiter_ids?.includes(user.id)
}
function toggleZone(id) {
@@ -66,24 +273,50 @@ export default function TableListPage() {
const filtered = tables.filter(t => {
const order = getOrder(t.id)
if (filter === 'free' && order) return false
- if (filter === 'mine' && !(order && order.waiters?.some(w => w.waiter_id === user?.id))) return false
+ if (filter === 'mine' && !isMyOrder(order)) return false
if (selectedZones.size > 0 && !selectedZones.has(t.group_id ?? 'none')) return false
return true
})
- function handleLogout() {
- logout()
- navigate('/login')
- }
-
const zoneActive = selectedZones.size > 0
+ function handleQuickAction(tableId, actionKey) {
+ // Navigate to table then trigger action via URL param so TableDetailPage can handle it
+ navigate(`/tables/${tableId}?action=${actionKey}`)
+ }
+
return (
Τραπέζια
- {user?.username}
- ⏏
+
+ { setShowNotifs(true); fetchRecent?.() }}
+ style={{
+ position: 'relative', background: 'none', border: 'none',
+ color: 'var(--text)', fontSize: 22, cursor: 'pointer',
+ minWidth: 44, minHeight: 44, borderRadius: 8,
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
+ }}
+ >
+
+
+
+
+ {(unreadCount || 0) > 0 && (
+
+ {unreadCount > 9 ? '9+' : unreadCount}
+
+ )}
+
+
+
{offline &&
}
@@ -95,7 +328,6 @@ export default function TableListPage() {
))}
- {/* Zone filter */}
setSelectedZones(new Set())}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
- color: selectedZones.size === 0 ? '#fff' : '#374151',
- background: selectedZones.size === 0 ? '#4f46e5' : 'transparent',
+ color: selectedZones.size === 0 ? 'var(--primary-fg)' : 'var(--text)',
+ background: selectedZones.size === 0 ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -128,8 +360,8 @@ export default function TableListPage() {
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
textAlign: 'left', padding: '12px 14px', borderRadius: 8, fontSize: 15,
- color: selectedZones.has(g.id) ? '#fff' : '#374151',
- background: selectedZones.has(g.id) ? '#4f46e5' : 'transparent',
+ color: selectedZones.has(g.id) ? 'var(--primary-fg)' : 'var(--text)',
+ background: selectedZones.has(g.id) ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -143,8 +375,8 @@ export default function TableListPage() {
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '12px 14px', borderRadius: 8, fontSize: 15,
- color: selectedZones.has('none') ? '#fff' : '#374151',
- background: selectedZones.has('none') ? '#4f46e5' : 'transparent',
+ color: selectedZones.has('none') ? 'var(--primary-fg)' : 'var(--text)',
+ background: selectedZones.has('none') ? 'var(--primary)' : 'transparent',
border: 'none', cursor: 'pointer',
}}
>
@@ -156,19 +388,52 @@ export default function TableListPage() {
-
- {filtered.map(t => (
-
navigate(`/tables/${t.id}`)}
- />
- ))}
+
+
+ {filtered.map(t => {
+ const order = getOrder(t.id)
+ const tableFlags = tableFlagsMap[t.id] || []
+ const grp = groups.find(g => g.id === t.group_id)
+ // Free tables go straight to the item picker; occupied tables go to detail
+ const destination = order
+ ? `/tables/${t.id}`
+ : `/tables/${t.id}/add?new=1`
+ return (
+
navigate(destination)}
+ onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
+ />
+ )
+ })}
+
+
+
↺
- ↺
+ {showNotifs && (
+ setShowNotifs(false)}
+ onAck={ackMessage}
+ />
+ )}
+
+ {quickModal && (
+ setQuickModal(null)}
+ onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
+ onAction={(key) => handleQuickAction(quickModal.table.id, key)}
+ />
+ )}
)
}
diff --git a/waiter_pwa/src/store/shiftStore.js b/waiter_pwa/src/store/shiftStore.js
new file mode 100644
index 0000000..88055e7
--- /dev/null
+++ b/waiter_pwa/src/store/shiftStore.js
@@ -0,0 +1,21 @@
+import { create } from 'zustand'
+
+const useShiftStore = create((set) => ({
+ shift: null,
+ businessDay: null,
+ selfStartAllowed: true,
+ selfEndAllowed: true,
+ gateStatus: 'loading', // 'loading' | 'closed' | 'needs_start' | 'waiting_manager' | 'ready'
+
+ setShift: (shift) => set({ shift }),
+ setBusinessDay: (day) => set({ businessDay: day }),
+ setSelfStartAllowed: (v) => set({ selfStartAllowed: v }),
+ setSelfEndAllowed: (v) => set({ selfEndAllowed: v }),
+ setGateStatus: (s) => set({ gateStatus: s }),
+ // Called when waiter ends their shift — sends them back to the start screen
+ clearShift: () => set({ shift: null, gateStatus: 'needs_start' }),
+ // Called on logout
+ clear: () => set({ shift: null, businessDay: null, gateStatus: 'loading' }),
+}))
+
+export default useShiftStore
diff --git a/waiter_pwa/src/store/tableColourStore.js b/waiter_pwa/src/store/tableColourStore.js
new file mode 100644
index 0000000..2554704
--- /dev/null
+++ b/waiter_pwa/src/store/tableColourStore.js
@@ -0,0 +1,90 @@
+import { create } from 'zustand'
+
+export const DEFAULT_COLOURS = {
+ light: {
+ free: {
+ cardBg: '#dde5ef',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#3d5270',
+ badgeText: '#3d5270',
+ },
+ mine: {
+ cardBg: '#e8610a',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#e8610a',
+ },
+ open: {
+ cardBg: '#FF8F60',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#FF8F60',
+ },
+ partially_paid: {
+ cardBg: '#FFDC67',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#d4a800',
+ },
+ paid: {
+ cardBg: '#81D264',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#81D264',
+ },
+ },
+ dark: {
+ free: {
+ cardBg: '#243044',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#94b8d4',
+ badgeText: '#94b8d4',
+ },
+ mine: {
+ cardBg: '#e8610a',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#e8610a',
+ },
+ open: {
+ cardBg: '#FF8F60',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#FF8F60',
+ },
+ partially_paid: {
+ cardBg: '#FFDC67',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#d4a800',
+ },
+ paid: {
+ cardBg: '#81D264',
+ badgeBg: 'rgba(255,255,255,0.92)',
+ nameText: '#ffffff',
+ badgeText: '#81D264',
+ },
+ },
+}
+
+const useTableColourStore = create((set) => ({
+ colours: DEFAULT_COLOURS,
+ loadFromBackend: (raw) => {
+ try {
+ const parsed = JSON.parse(raw)
+ if (parsed?.light && parsed?.dark) {
+ // Deep-merge so any status keys added after the settings were saved
+ // (e.g. 'paid') still fall back to their defaults.
+ const merged = { light: {}, dark: {} }
+ for (const mode of ['light', 'dark']) {
+ for (const status of Object.keys(DEFAULT_COLOURS[mode])) {
+ merged[mode][status] = { ...DEFAULT_COLOURS[mode][status], ...(parsed[mode][status] || {}) }
+ }
+ }
+ set({ colours: merged })
+ }
+ } catch {}
+ },
+}))
+
+export default useTableColourStore
diff --git a/waiter_pwa/src/store/themeStore.js b/waiter_pwa/src/store/themeStore.js
new file mode 100644
index 0000000..e7795c3
--- /dev/null
+++ b/waiter_pwa/src/store/themeStore.js
@@ -0,0 +1,12 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+const useThemeStore = create(persist(
+ (set) => ({
+ dark: true,
+ toggle: () => set(s => ({ dark: !s.dark })),
+ }),
+ { name: 'pos-theme' }
+))
+
+export default useThemeStore