Frontend overhaul: manager dashboard restructure, waiter PWA rework, new order drawer and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1
CLAUDE_DESIGN/.design-canvas.state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}}
|
||||
82
CLAUDE_DESIGN/Daily Ops Dashboard.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Daily Ops — SimplePOS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f4f2;
|
||||
--surface: #ffffff;
|
||||
--ink-900: #111315;
|
||||
--ink-700: #2b2f33;
|
||||
--ink-500: #5a6169;
|
||||
--ink-400: #8a9099;
|
||||
--ink-300: #b8bdc4;
|
||||
--ink-200: #dfe2e6;
|
||||
--ink-100: #edeff1;
|
||||
|
||||
--brand-50: #eef2fb;
|
||||
--brand-100: #dfe6f7;
|
||||
--brand-200: #c2cff0;
|
||||
--brand-500: #3758c9;
|
||||
--brand-700: #25409a;
|
||||
|
||||
/* Status palette — match table cards */
|
||||
--open-50: #eef7f0;
|
||||
--open-100: #d7ecdc;
|
||||
--open-500: #2f9e5e;
|
||||
--open-700: #1f7042;
|
||||
--occ-50: #eef2fb;
|
||||
--occ-100: #d7dff4;
|
||||
--occ-500: #3758c9;
|
||||
--occ-700: #25409a;
|
||||
--res-50: #f4eefb;
|
||||
--res-100: #e3d4f3;
|
||||
--res-500: #7a44c9;
|
||||
--res-700: #57309a;
|
||||
--alert-50: #fdeeea;
|
||||
--alert-100: #f8d2c8;
|
||||
--alert-500: #d94b26;
|
||||
--alert-700: #a5361b;
|
||||
--dirty-50: #f6f1e8;
|
||||
--dirty-100: #e8dcc3;
|
||||
--dirty-500: #8a6d2b;
|
||||
--dirty-700: #604a1d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink-900);
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
#root { width: 100vw; height: 100vh; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--ink-200); border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="browser-window.jsx"></script>
|
||||
<script type="text/babel" src="ops-data.jsx"></script>
|
||||
<script type="text/babel" src="ops-ui.jsx"></script>
|
||||
<script type="text/babel" src="ops-cards.jsx"></script>
|
||||
<script type="text/babel" src="ops-shifts.jsx"></script>
|
||||
<script type="text/babel" src="ops-layouts.jsx"></script>
|
||||
<script type="text/babel" src="ops-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
66
CLAUDE_DESIGN/Order Drawer.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Order Drawer — SimplePOS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
/* Neutrals */
|
||||
--bg: #f4f4f2;
|
||||
--surface: #ffffff;
|
||||
--ink-900: #111315;
|
||||
--ink-700: #2b2f33;
|
||||
--ink-500: #5a6169;
|
||||
--ink-400: #8a9099;
|
||||
--ink-300: #b8bdc4;
|
||||
--ink-200: #dfe2e6;
|
||||
--ink-100: #edeff1;
|
||||
|
||||
/* Brand — used for selection / primary CTA */
|
||||
--brand-50: #eef2fb;
|
||||
--brand-100: #dfe6f7;
|
||||
--brand-200: #c2cff0;
|
||||
--brand-500: #3758c9;
|
||||
--brand-600: #2c48ac;
|
||||
--brand-700: #25409a;
|
||||
|
||||
--alert-500: #d94b26;
|
||||
--alert-700: #a5361b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
background: #e6e8ec;
|
||||
color: var(--ink-900);
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* Hide scrollbars inside the phone for cleaner look */
|
||||
*::-webkit-scrollbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="ios-frame.jsx"></script>
|
||||
<script type="text/babel" src="menu-data.jsx"></script>
|
||||
<script type="text/babel" src="order-drawer.jsx"></script>
|
||||
<script type="text/babel" src="order-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
CLAUDE_DESIGN/Table Cards.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Table Cards — SimplePOS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
/* Neutrals */
|
||||
--bg: #f4f4f2;
|
||||
--surface: #ffffff;
|
||||
--ink-900: #111315;
|
||||
--ink-700: #2b2f33;
|
||||
--ink-500: #5a6169;
|
||||
--ink-400: #8a9099;
|
||||
--ink-300: #b8bdc4;
|
||||
--ink-200: #dfe2e6;
|
||||
--ink-100: #edeff1;
|
||||
|
||||
/* Status palette — muted, POS-appropriate */
|
||||
--open-50: #eef7f0;
|
||||
--open-100: #d7ecdc;
|
||||
--open-500: #2f9e5e;
|
||||
--open-700: #1f7042;
|
||||
|
||||
--occ-50: #eef2fb;
|
||||
--occ-100: #d7dff4;
|
||||
--occ-500: #3758c9;
|
||||
--occ-700: #25409a;
|
||||
|
||||
--res-50: #f4eefb;
|
||||
--res-100: #e3d4f3;
|
||||
--res-500: #7a44c9;
|
||||
--res-700: #57309a;
|
||||
|
||||
--alert-50: #fdeeea;
|
||||
--alert-100: #f8d2c8;
|
||||
--alert-500: #d94b26;
|
||||
--alert-700: #a5361b;
|
||||
|
||||
--dirty-50: #f6f1e8;
|
||||
--dirty-100: #e8dcc3;
|
||||
--dirty-500: #8a6d2b;
|
||||
--dirty-700: #604a1d;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
--shadow-1: 0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03);
|
||||
--shadow-2: 0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink-900);
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root { width: 100vw; height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="table-card.jsx"></script>
|
||||
<script type="text/babel" src="app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
115
CLAUDE_DESIGN/app.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// App — arranges the three table-card variations inside a design canvas.
|
||||
// Each variation gets its own artboard showing a 4×2 mini-grid of mixed statuses
|
||||
// so you can see how they read together, plus a separate artboard showing
|
||||
// hover/pressed states.
|
||||
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const { TableCardV1, TableCardV2, TableCardV3 } = window;
|
||||
|
||||
// Shared dataset — same across all variations so they compare fairly
|
||||
const TABLES = [
|
||||
{ name: 'A1', status: 'occupied', amount: 84.50, occupiedMins: 42, waiters: ['Marco Riva'], flags: [] },
|
||||
{ name: 'A2', status: 'occupied', amount: 127.20, occupiedMins: 98, waiters: ['Sofia Greco'], flags: ['VIP'] },
|
||||
{ name: 'A3', status: 'open', amount: null, occupiedMins: null, waiters: [], flags: [] },
|
||||
{ name: 'A4', status: 'alert', amount: 56.00, occupiedMins: 73, waiters: ['Luca Bianchi'], flags: ['Allergy'] },
|
||||
{ name: 'B1', status: 'reserved', amount: null, occupiedMins: null, waiters: ['Elena Costa'], flags: ['Birthday'] },
|
||||
{ name: 'B2', status: 'occupied', amount: 212.80, occupiedMins: 115,waiters: ['Marco Riva', 'Sofia Greco', 'Luca Bianchi', 'Elena Costa'], flags: ['VIP'] },
|
||||
{ name: 'B3', status: 'dirty', amount: null, occupiedMins: null, waiters: [], flags: [] },
|
||||
{ name: 'B4', status: 'occupied', amount: 38.00, occupiedMins: 14, waiters: ['Luca Bianchi', 'Elena Costa'], flags: [] },
|
||||
];
|
||||
|
||||
// Interaction states preview — shows the SAME card in 4 states side by side
|
||||
const STATE_CARD = { name: 'A2', status: 'occupied', amount: 127.20, occupiedMins: 98, waiters: ['Sofia Greco'], flags: ['VIP'] };
|
||||
|
||||
function TableGrid({ Card }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 260px)',
|
||||
gap: 16,
|
||||
padding: 28,
|
||||
background: 'var(--bg)',
|
||||
}}>
|
||||
{TABLES.map((t, i) => <Card key={i} {...t} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatesStrip({ Card, label }) {
|
||||
// Render four copies with simulated states by forcing CSS
|
||||
return (
|
||||
<div style={{ padding: 28, background: 'var(--bg)' }}>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 260px)', gap: 24,
|
||||
}}>
|
||||
{['Default', 'Hover', 'Pressed', 'Selected'].map((state) => (
|
||||
<div key={state} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--ink-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
}}>{state}</div>
|
||||
<div className={`state-${state.toLowerCase()}`} style={{ position: 'relative' }}>
|
||||
<Card {...STATE_CARD} />
|
||||
{state === 'Selected' && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: -3,
|
||||
borderRadius: 17,
|
||||
boxShadow: '0 0 0 3px var(--occ-500)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Style overrides to force hover/pressed on the middle two */}
|
||||
<style>{`
|
||||
.state-hover > button {
|
||||
box-shadow: var(--shadow-2) !important;
|
||||
transform: translateY(-2px) !important;
|
||||
}
|
||||
.state-pressed > button {
|
||||
box-shadow: inset 0 2px 4px rgba(16,20,24,0.08) !important;
|
||||
transform: translateY(1px) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas title="Table Card — Variations">
|
||||
<DCSection id="v1" title="Variation 1 — Left accent, stacked layout">
|
||||
<DCArtboard id="v1-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
|
||||
<TableGrid Card={TableCardV1} />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="v1-states" label="Interactive states" width={1160} height={260}>
|
||||
<StatesStrip Card={TableCardV1} label="V1" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="v2" title="Variation 2 — Top stripe, big name">
|
||||
<DCArtboard id="v2-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
|
||||
<TableGrid Card={TableCardV2} />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="v2-states" label="Interactive states" width={1160} height={260}>
|
||||
<StatesStrip Card={TableCardV2} label="V2" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="v3" title="Variation 3 — Plaque badge">
|
||||
<DCArtboard id="v3-grid" label="Grid of 8 tables — mixed statuses" width={1104} height={420}>
|
||||
<TableGrid Card={TableCardV3} />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="v3-states" label="Interactive states" width={1160} height={260}>
|
||||
<StatesStrip Card={TableCardV3} label="V3" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
114
CLAUDE_DESIGN/browser-window.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
|
||||
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
|
||||
// No dependencies, no image assets. All inline styles + inline SVG.
|
||||
|
||||
const CHROME_C = {
|
||||
barBg: '#202124',
|
||||
tabBg: '#35363a',
|
||||
text: '#e8eaed',
|
||||
dim: '#9aa0a6',
|
||||
urlBg: '#282a2d',
|
||||
};
|
||||
|
||||
function ChromeTrafficLights() {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single tab (active has curved scoops)
|
||||
function ChromeTab({ title = 'New Tab', active = false }) {
|
||||
const curve = (flip) => (
|
||||
<svg width="8" height="10" viewBox="0 0 8 10"
|
||||
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
|
||||
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', height: 34, alignSelf: 'flex-end',
|
||||
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: active ? CHROME_C.tabBg : 'transparent',
|
||||
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: 12,
|
||||
color: active ? CHROME_C.text : CHROME_C.dim,
|
||||
}}>
|
||||
{active && curve(false)}
|
||||
{active && curve(true)}
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', height: 44,
|
||||
background: CHROME_C.barBg, paddingRight: 8,
|
||||
}}>
|
||||
<ChromeTrafficLights />
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
|
||||
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeToolbar({ url = 'example.com' }) {
|
||||
const iconDot = (
|
||||
<div style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
height: 40, background: CHROME_C.tabBg,
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
|
||||
}}>
|
||||
{iconDot}
|
||||
{/* url bar */}
|
||||
<div style={{
|
||||
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
|
||||
margin: '0 6px',
|
||||
}}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
<span style={{
|
||||
flex: 1, color: CHROME_C.text, fontSize: 13,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>{url}</span>
|
||||
</div>
|
||||
{iconDot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeWindow({
|
||||
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
|
||||
width = 900, height = 600, children,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 10, overflow: 'hidden',
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.1)',
|
||||
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
|
||||
}}>
|
||||
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
|
||||
<ChromeToolbar url={url} />
|
||||
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
|
||||
});
|
||||
622
CLAUDE_DESIGN/design-canvas.jsx
Normal file
@@ -0,0 +1,622 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
338
CLAUDE_DESIGN/ios-frame.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass pill — blur + tint + shine
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, minWidth: 44, borderRadius: 9999,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: dark
|
||||
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||
...style,
|
||||
}}>
|
||||
{/* blur + tint */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||
}} />
|
||||
{/* shine */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Navigation bar — glass pills + large title
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const pillIcon = (content) => (
|
||||
<IOSGlassPill dark={dark}>
|
||||
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{content}
|
||||
</div>
|
||||
</IOSGlassPill>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{/* back chevron */}
|
||||
{pillIcon(
|
||||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{/* trailing ellipsis */}
|
||||
{trailingIcon && pillIcon(
|
||||
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* large title */}
|
||||
<div style={{
|
||||
padding: '0 16px',
|
||||
fontFamily: '-apple-system, system-ui',
|
||||
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||
color: text, letterSpacing: 0.4,
|
||||
}}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
title, keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* nav + content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
122
CLAUDE_DESIGN/menu-data.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// Menu data + product under customization
|
||||
|
||||
const MENU = [
|
||||
{ id: 'margherita', name: 'Pizza Margherita', desc: 'Tomato, mozzarella, basil', price: 9.50, emoji: '🍕' },
|
||||
{ id: 'diavola', name: 'Pizza Diavola', desc: 'Spicy salami, mozzarella, chili oil', price: 12.00, emoji: '🌶️' },
|
||||
{ id: 'quattro', name: 'Quattro Formaggi', desc: 'Mozzarella, gorgonzola, fontina, parmesan', price: 13.50, emoji: '🧀' },
|
||||
{ id: 'prosciutto', name: 'Prosciutto & Funghi', desc: 'Ham, mushrooms, mozzarella', price: 12.50, emoji: '🍄' },
|
||||
{ id: 'tiramisu', name: 'Tiramisù', desc: 'House-made, classic recipe', price: 6.50, emoji: '🍰' },
|
||||
{ id: 'coke', name: 'Coca-Cola', desc: '330ml can', price: 3.00, emoji: '🥤' },
|
||||
];
|
||||
|
||||
// Full product spec for Diavola — our drawer target
|
||||
const DIAVOLA = {
|
||||
id: 'diavola',
|
||||
name: 'Pizza Diavola',
|
||||
desc: 'Spicy salami, mozzarella, chili oil, San Marzano tomato',
|
||||
price: 12.00,
|
||||
emoji: '🌶️',
|
||||
|
||||
// ---- Quick Options -----------------------------------------------------
|
||||
// Fast preferences / add-ons. Some allow multiple, some carry a cost.
|
||||
quickOptions: [
|
||||
{ id: 'well-done', label: 'Well done', price: 0, multi: false },
|
||||
{ id: 'light-bake', label: 'Light bake', price: 0, multi: false },
|
||||
{ id: 'cut-8', label: 'Cut in 8 slices', price: 0, multi: false },
|
||||
{ id: 'extra-cheese', label: 'Extra cheese', price: 1.50, multi: true },
|
||||
{ id: 'extra-salami', label: 'Extra salami', price: 2.00, multi: true },
|
||||
{ id: 'gluten-free', label: 'Gluten-free base', price: 3.00, multi: false },
|
||||
],
|
||||
|
||||
// ---- Extras with sub-options -----------------------------------------
|
||||
// Can add multiple. Each has a flavor/variant to pick.
|
||||
extras: [
|
||||
{
|
||||
id: 'dip',
|
||||
label: 'Dipping sauce',
|
||||
price: 0.80,
|
||||
multi: true,
|
||||
subLabel: 'Choose a sauce',
|
||||
subOptions: [
|
||||
{ id: 'garlic', label: 'Garlic', price: 0 },
|
||||
{ id: 'bbq', label: 'BBQ', price: 0 },
|
||||
{ id: 'ranch', label: 'Ranch', price: 0 },
|
||||
{ id: 'truffle', label: 'Truffle mayo', price: 0.50 },
|
||||
{ id: 'spicy', label: 'Spicy arrabbiata', price: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cheese-edge',
|
||||
label: 'Stuffed crust',
|
||||
price: 2.50,
|
||||
multi: false,
|
||||
subLabel: 'Choose a filling',
|
||||
subOptions: [
|
||||
{ id: 'mozz', label: 'Mozzarella', price: 0 },
|
||||
{ id: 'gorg', label: 'Gorgonzola', price: 0.50 },
|
||||
{ id: 'nduja', label: "'Nduja", price: 1.00 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'oil',
|
||||
label: 'Drizzle of oil',
|
||||
price: 0.50,
|
||||
multi: true,
|
||||
subLabel: 'Choose an oil',
|
||||
subOptions: [
|
||||
{ id: 'evoo', label: 'Extra virgin', price: 0 },
|
||||
{ id: 'chili', label: 'Chili-infused', price: 0 },
|
||||
{ id: 'truffle', label: 'Truffle', price: 1.20 },
|
||||
{ id: 'basil', label: 'Basil', price: 0.30 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// ---- Ingredients (removable only) ------------------------------------
|
||||
ingredients: [
|
||||
{ id: 'salami', label: 'Spicy salami' },
|
||||
{ id: 'mozz', label: 'Mozzarella' },
|
||||
{ id: 'tomato', label: 'Tomato sauce' },
|
||||
{ id: 'chili', label: 'Chili oil' },
|
||||
{ id: 'basil', label: 'Basil leaves' },
|
||||
{ id: 'oregano', label: 'Oregano' },
|
||||
],
|
||||
|
||||
// ---- Preferences with sub-options ------------------------------------
|
||||
preferences: [
|
||||
{
|
||||
id: 'size',
|
||||
label: 'Size',
|
||||
required: true,
|
||||
subOptions: [
|
||||
{ id: 'small', label: 'Small (26cm)', price: -2.00, default: false },
|
||||
{ id: 'medium', label: 'Medium (32cm)', price: 0, default: true },
|
||||
{ id: 'large', label: 'Large (40cm)', price: 4.00, default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'crust',
|
||||
label: 'Crust style',
|
||||
required: false,
|
||||
subOptions: [
|
||||
{ id: 'classic', label: 'Classic', price: 0, default: true },
|
||||
{ id: 'thin', label: 'Thin & crispy', price: 0, default: false },
|
||||
{ id: 'sourdough', label: 'Sourdough', price: 1.50, default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spice',
|
||||
label: 'Spice level',
|
||||
required: false,
|
||||
subOptions: [
|
||||
{ id: 'mild', label: 'Mild', price: 0, default: false },
|
||||
{ id: 'medium', label: 'Medium', price: 0, default: true },
|
||||
{ id: 'hot', label: 'Hot', price: 0, default: false },
|
||||
{ id: 'xxx', label: 'Extra hot', price: 0, default: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
window.MENU = MENU;
|
||||
window.DIAVOLA = DIAVOLA;
|
||||
51
CLAUDE_DESIGN/ops-app.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// Top-level — design canvas with desktop and tablet artboards
|
||||
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const { ChromeWindow } = window;
|
||||
const { DesktopDashboard, TabletDashboard } = window.OpsLayouts;
|
||||
|
||||
// Tablet bezel — simple frame, since we want to communicate "tablet"
|
||||
function TabletShell({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 900, height: 1180,
|
||||
background: '#1a1a1a',
|
||||
borderRadius: 36,
|
||||
padding: 14,
|
||||
boxShadow: '0 20px 50px rgba(0,0,0,0.18)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'white', borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas title="Daily Ops Dashboard — Desktop + Tablet">
|
||||
<DCSection id="desktop" title="Desktop — primary view">
|
||||
<DCArtboard id="desktop-main" label="1440×900 — full operational view, mid-shift" width={1440} height={900}>
|
||||
<ChromeWindow url="manage.simplepos.com/today" tabs={[{ title: 'Today · Trattoria del Sole' }]} width={1440} height={900}>
|
||||
<DesktopDashboard />
|
||||
</ChromeWindow>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="tablet" title="Tablet — portrait">
|
||||
<DCArtboard id="tablet-main" label="Tablet portrait — same data, vertical stack" width={930} height={1210}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: 'transparent' }}>
|
||||
<TabletShell>
|
||||
<TabletDashboard />
|
||||
</TabletShell>
|
||||
</div>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
199
CLAUDE_DESIGN/ops-cards.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
// Dashboard cards — KPIs, tables overview, hourly chart, reservations
|
||||
|
||||
const { Avatar, Card, StatPill, Btn, Icon } = window.OpsUI;
|
||||
const { OPS_DATA } = window;
|
||||
|
||||
// ---------------------------------------------------------------- KPI big card
|
||||
function KpiCard({ label, value, sub, delta, accent = 'var(--brand-500)', tone, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: tone || 'white',
|
||||
border: '1px solid var(--ink-100)',
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
minHeight: 148,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--ink-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
}}>{label}</div>
|
||||
<StatPill delta={delta} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 38, fontWeight: 700,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: 'var(--ink-900)',
|
||||
letterSpacing: -1,
|
||||
lineHeight: 1.1,
|
||||
}}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 13, color: 'var(--ink-500)' }}>{sub}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress bar used inside KPI cards
|
||||
function ProgressBar({ pct, color = 'var(--brand-500)' }) {
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 'auto', height: 8, borderRadius: 4,
|
||||
background: 'var(--ink-100)', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.min(100, pct)}%`, height: '100%',
|
||||
background: color, borderRadius: 4,
|
||||
transition: 'width 240ms ease',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Tables
|
||||
function TablesOverview() {
|
||||
// Build a synthetic 18-table layout
|
||||
const states = {
|
||||
'A1': 'occupied', 'A2': 'occupied', 'A3': 'open', 'A4': 'alert',
|
||||
'B1': 'reserved','B2': 'occupied', 'B3': 'open', 'B4': 'occupied',
|
||||
'C1': 'open', 'C2': 'reserved','C3': 'open', 'C4': 'open',
|
||||
'D1': 'occupied','D2': 'dirty', 'D3': 'open', 'D4': 'open',
|
||||
'T1': 'occupied','T2': 'open',
|
||||
};
|
||||
const colors = {
|
||||
occupied: { bg: 'var(--occ-100)', fg: 'var(--occ-700)', accent: 'var(--occ-500)' },
|
||||
open: { bg: 'var(--open-50)', fg: 'var(--open-700)', accent: 'var(--open-500)' },
|
||||
reserved: { bg: 'var(--res-100)', fg: 'var(--res-700)', accent: 'var(--res-500)' },
|
||||
alert: { bg: 'var(--alert-100)', fg: 'var(--alert-700)', accent: 'var(--alert-500)' },
|
||||
dirty: { bg: 'var(--dirty-100)', fg: 'var(--dirty-700)', accent: 'var(--dirty-500)' },
|
||||
};
|
||||
const counts = Object.values(states).reduce((acc, s) => (acc[s] = (acc[s] || 0) + 1, acc), {});
|
||||
|
||||
return (
|
||||
<Card title="Tables right now" action={<Btn size="sm" variant="ghost">View floor</Btn>}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{Object.entries(states).map(([name, status]) => {
|
||||
const c = colors[status];
|
||||
return (
|
||||
<div key={name} style={{
|
||||
aspectRatio: '1',
|
||||
minHeight: 44,
|
||||
borderRadius: 10,
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14, fontWeight: 700,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
border: '1px solid ' + c.accent + '33',
|
||||
}}>{name}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
['occupied', 'Occupied'],
|
||||
['open', 'Open'],
|
||||
['reserved', 'Reserved'],
|
||||
['alert', 'Alert'],
|
||||
['dirty', 'Cleaning'],
|
||||
].map(([k, label]) => (
|
||||
<div key={k} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--ink-100)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--ink-700)',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: colors[k].accent }} />
|
||||
{label}
|
||||
<span style={{ fontFamily: "'Geist Mono', monospace", color: 'var(--ink-900)' }}>{counts[k] || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Hourly chart
|
||||
function HourlyRevenueCard() {
|
||||
const data = OPS_DATA.hourly;
|
||||
const max = Math.max(...data.map(d => d.revenue), 800);
|
||||
const currentHour = '19';
|
||||
return (
|
||||
<Card title="Revenue by hour" action={<div style={{ fontSize: 13, color: 'var(--ink-500)', fontWeight: 500 }}>Today</div>}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 140, padding: '8px 0 0' }}>
|
||||
{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 (
|
||||
<div key={d.hour} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, height: '100%' }}>
|
||||
<div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
width: '100%', height: `${h}%`,
|
||||
borderRadius: 6,
|
||||
minHeight: d.revenue > 0 ? 4 : 0,
|
||||
background: isCurrent
|
||||
? 'var(--brand-500)'
|
||||
: isFuture
|
||||
? 'var(--ink-100)'
|
||||
: 'var(--brand-200)',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: isCurrent ? 'var(--brand-700)' : 'var(--ink-500)',
|
||||
fontWeight: isCurrent ? 700 : 500,
|
||||
}}>{d.hour}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Reservations
|
||||
function ReservationsCard() {
|
||||
return (
|
||||
<Card title="Reservations today" action={<Btn size="sm" variant="ghost">+ Add</Btn>}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
|
||||
{OPS_DATA.reservations.map(r => (
|
||||
<div key={r.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--ink-100)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, textAlign: 'center',
|
||||
fontSize: 16, fontWeight: 700,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: 'var(--ink-900)',
|
||||
flexShrink: 0,
|
||||
}}>{r.time}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-900)' }}>{r.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 1 }}>
|
||||
{r.guests} guests · Table {r.table}
|
||||
{r.notes && <> · <span style={{ color: 'var(--brand-700)', fontWeight: 600 }}>{r.notes}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
window.OpsCards = { KpiCard, ProgressBar, TablesOverview, HourlyRevenueCard, ReservationsCard };
|
||||
73
CLAUDE_DESIGN/ops-data.jsx
Normal file
@@ -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',
|
||||
],
|
||||
};
|
||||
217
CLAUDE_DESIGN/ops-layouts.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
padding: '20px 28px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid var(--ink-100)',
|
||||
display: 'flex', alignItems: 'center', gap: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12,
|
||||
background: 'var(--ink-900)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 18,
|
||||
fontFamily: "'Geist Mono', monospace", flexShrink: 0,
|
||||
}}>TS</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--ink-900)' }}>{b.name}</div>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 10px', borderRadius: 999,
|
||||
background: 'var(--open-50)', color: 'var(--open-700)',
|
||||
fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 3, background: 'var(--open-500)' }} />
|
||||
Day open
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{b.date} · started at {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m running
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<Btn variant="secondary" size="md"><Icon name="bell" size={16} /></Btn>
|
||||
<Btn variant="secondary" size="md">Reports</Btn>
|
||||
<Btn variant="danger" size="md" onClick={onEndDay}><Icon name="stop" size={14} color="white" /> End day</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact tablet top bar
|
||||
function TabletTopBar({ onEndDay }) {
|
||||
const b = OPS_DATA.business;
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid var(--ink-100)',
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-900)' }}>{b.name}</div>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 8px', borderRadius: 999,
|
||||
background: 'var(--open-50)', color: 'var(--open-700)',
|
||||
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
}}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: 3, background: 'var(--open-500)' }} />
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{b.date} · {b.dayStartedAt} · {Math.floor(b.dayDurationMins/60)}h {b.dayDurationMins%60}m
|
||||
</div>
|
||||
</div>
|
||||
<Btn variant="secondary" size="sm"><Icon name="bell" size={14} /></Btn>
|
||||
<Btn variant="danger" size="sm" onClick={onEndDay}><Icon name="stop" size={12} color="white" /> End day</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: compact ? '1fr 1fr 1fr' : '1fr 1fr 1fr',
|
||||
gap: compact ? 12 : 20,
|
||||
}}>
|
||||
<KpiCard
|
||||
label="Revenue today"
|
||||
value={'€' + OPS_DATA.kpis.revenue.toLocaleString('en', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
sub={`Goal €${k.revenueGoal.toLocaleString()} · ${revPct.toFixed(0)}%`}
|
||||
delta={revDelta}
|
||||
>
|
||||
<ProgressBar pct={revPct} color="var(--brand-500)" />
|
||||
</KpiCard>
|
||||
<KpiCard
|
||||
label="Covers"
|
||||
value={k.covers.toString()}
|
||||
sub={`Avg €${k.avgTicket.toFixed(2)} per cover · ${covPct.toFixed(0)}%`}
|
||||
delta={covDelta}
|
||||
>
|
||||
<ProgressBar pct={covPct} color="var(--occ-500)" />
|
||||
</KpiCard>
|
||||
<KpiCard
|
||||
label="Tables"
|
||||
value={`${k.tablesOpen} / ${k.tablesTotal}`}
|
||||
sub={`${k.tablesTotal - k.tablesOpen} closed · ${tablesOpenPct.toFixed(0)}% in use`}
|
||||
>
|
||||
<ProgressBar pct={tablesOpenPct} color="var(--res-500)" />
|
||||
</KpiCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'var(--bg)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<TopBar onEndDay={() => setEndDayOpen(true)} />
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 28 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--ink-900)' }}>Today at a glance</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>Updated 19:08 · auto-refresh</div>
|
||||
</div>
|
||||
|
||||
<KpiStrip />
|
||||
|
||||
<div style={{
|
||||
marginTop: 20,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.2fr 1fr',
|
||||
gap: 20,
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<TablesOverview />
|
||||
<ShiftsCard onMessage={(s) => openCompose('', s.name)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<HourlyRevenueCard />
|
||||
<MessagesCard openCompose={openCompose} />
|
||||
<ReservationsCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComposeModal open={composeOpen} prefilled={composeText} prefilledTo={composeTo} onClose={() => setComposeOpen(false)} />
|
||||
<EndDayModal open={endDayOpen} onClose={() => setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'var(--bg)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<TabletTopBar onEndDay={() => setEndDayOpen(true)} />
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 18 }}>
|
||||
<KpiStrip compact />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 14 }}>
|
||||
<TablesOverview />
|
||||
<HourlyRevenueCard />
|
||||
<ShiftsCard onMessage={(s) => openCompose('', s.name)} />
|
||||
<MessagesCard openCompose={openCompose} />
|
||||
<ReservationsCard />
|
||||
</div>
|
||||
</div>
|
||||
<ComposeModal open={composeOpen} prefilled={composeText} prefilledTo={composeTo} onClose={() => setComposeOpen(false)} />
|
||||
<EndDayModal open={endDayOpen} onClose={() => setEndDayOpen(false)} onConfirm={() => setEndDayOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.OpsLayouts = { DesktopDashboard, TabletDashboard };
|
||||
283
CLAUDE_DESIGN/ops-shifts.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
// Shifts card + Messages card
|
||||
|
||||
const { Avatar, Card, Btn, Icon } = window.OpsUI;
|
||||
const { OPS_DATA } = window;
|
||||
|
||||
// ---------------------------------------------------------------- Shifts
|
||||
function ShiftsCard({ onMessage }) {
|
||||
return (
|
||||
<Card
|
||||
title="Shifts on now"
|
||||
action={<Btn size="sm" variant="secondary">+ Start shift</Btn>}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OPS_DATA.shifts.map(s => (
|
||||
<div key={s.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--ink-100)',
|
||||
background: s.status === 'break' ? '#fbf6ec' : 'white',
|
||||
}}>
|
||||
<Avatar name={s.name} size={42} status={s.status} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-900)' }}>{s.name}</div>
|
||||
{s.status === 'break' && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 999,
|
||||
background: 'var(--dirty-100)', color: 'var(--dirty-700)',
|
||||
fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4,
|
||||
}}>On break</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{s.section} · in at {s.clockIn} · {s.hoursWorked.toFixed(1)}h worked
|
||||
{s.tables.length > 0 && <> · tables {s.tables.join(', ')}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button onClick={() => 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)',
|
||||
}}><Icon name="chat" size={16} /></button>
|
||||
<button title="More" 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)',
|
||||
fontSize: 18, lineHeight: 1, paddingBottom: 6,
|
||||
}}>⋯</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* scheduled / not yet started */}
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
padding: '4px 4px 0',
|
||||
}}>Scheduled later</div>
|
||||
{OPS_DATA.scheduledShifts.map(s => (
|
||||
<div key={s.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px dashed var(--ink-200)',
|
||||
background: 'var(--bg)',
|
||||
}}>
|
||||
<Avatar name={s.name} size={42} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-700)' }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{s.section} · starts at {s.scheduledAt}
|
||||
</div>
|
||||
</div>
|
||||
<Btn size="sm" variant="primary">Start now</Btn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Messages
|
||||
function MessagesCard({ openCompose }) {
|
||||
return (
|
||||
<Card
|
||||
title="Messages"
|
||||
action={<Btn size="sm" variant="primary" onClick={() => openCompose()}><Icon name="send" size={14} /> New</Btn>}
|
||||
>
|
||||
{/* Quick presets */}
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8,
|
||||
}}>Quick send</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 18 }}>
|
||||
{OPS_DATA.presets.map(p => (
|
||||
<button key={p} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--ink-400)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8,
|
||||
}}>Recent</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{OPS_DATA.recentMessages.map(m => (
|
||||
<div key={m.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 4px',
|
||||
}}>
|
||||
<Avatar name={m.to === 'Everyone' ? 'All' : m.to} size={28} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-900)' }}>
|
||||
<span style={{ fontWeight: 600 }}>{m.to}</span>
|
||||
<span style={{ color: 'var(--ink-500)' }}> · {m.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-400)', fontFamily: "'Geist Mono', monospace" }}>{m.sentAt}</div>
|
||||
{!m.read && <div style={{ width: 8, height: 8, borderRadius: 4, background: 'var(--brand-500)' }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(16,20,24,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
padding: 20,
|
||||
}}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{
|
||||
width: 'min(520px, 100%)',
|
||||
background: 'white',
|
||||
borderRadius: 18,
|
||||
padding: 24,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--ink-900)' }}>New message</div>
|
||||
<button onClick={onClose} style={{
|
||||
width: 36, height: 36, borderRadius: 18,
|
||||
border: 'none', background: 'var(--ink-100)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}><Icon name="x" size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8 }}>To</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 18 }}>
|
||||
{recipients.map(r => (
|
||||
<button key={r} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 8 }}>Message</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3}
|
||||
placeholder="Type a message..."
|
||||
style={{
|
||||
width: '100%', padding: 14, fontSize: 16, fontFamily: 'inherit',
|
||||
border: '1px solid var(--ink-200)', borderRadius: 12, resize: 'none',
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
}}/>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
|
||||
{OPS_DATA.presets.map(p => (
|
||||
<button key={p} onClick={() => setText(p)} style={{
|
||||
height: 32, padding: '0 12px',
|
||||
borderRadius: 16,
|
||||
background: 'var(--ink-100)',
|
||||
border: 'none',
|
||||
color: 'var(--ink-700)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 22 }}>
|
||||
<Btn variant="secondary" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="primary" size="lg" onClick={onClose}><Icon name="send" size={16} color="white" /> Send</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- End-day modal
|
||||
function EndDayModal({ open, onClose, onConfirm }) {
|
||||
if (!open) return null;
|
||||
const k = OPS_DATA.kpis;
|
||||
return (
|
||||
<div onClick={onClose} style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(16,20,24,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 50, padding: 20,
|
||||
}}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{
|
||||
width: 'min(560px, 100%)',
|
||||
background: 'white', borderRadius: 18, padding: 28,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', marginBottom: 6 }}>End business day?</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--ink-500)', marginBottom: 22 }}>
|
||||
Today's session will close. Make sure all tables are paid and waiters have clocked out.
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '16px 18px',
|
||||
background: 'var(--bg)', borderRadius: 12,
|
||||
marginBottom: 22,
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14,
|
||||
}}>
|
||||
{[
|
||||
['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]) => (
|
||||
<div key={lbl}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{lbl}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', fontFamily: "'Geist Mono', monospace", marginTop: 2 }}>{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{k.tablesOpen > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
background: 'var(--alert-50)', border: '1px solid var(--alert-100)',
|
||||
borderRadius: 10, marginBottom: 22,
|
||||
fontSize: 14, color: 'var(--alert-700)', fontWeight: 600,
|
||||
}}>
|
||||
⚠ {k.tablesOpen} tables are still open. They'll need to be closed manually.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<Btn variant="secondary" onClick={onClose}>Cancel</Btn>
|
||||
<Btn variant="danger" size="lg" onClick={onConfirm}>End day</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.OpsCards2 = { ShiftsCard, MessagesCard, ComposeModal, EndDayModal };
|
||||
145
CLAUDE_DESIGN/ops-ui.jsx
Normal file
@@ -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 (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: bg, color: 'white',
|
||||
fontSize: size * 0.4, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{initials}</div>
|
||||
{status && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -1, right: -1,
|
||||
width: size * 0.32, height: size * 0.32,
|
||||
borderRadius: '50%',
|
||||
background: status === 'active' ? 'var(--open-500)' : status === 'break' ? 'var(--dirty-500)' : 'var(--ink-300)',
|
||||
border: '2px solid white',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, action, children, padding = 22, style }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-100)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
...style,
|
||||
}}>
|
||||
{title && (
|
||||
<div style={{
|
||||
padding: `18px ${padding}px 0`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink-900)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{title}</div>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: padding, paddingTop: title ? 14 : padding, flex: 1 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ delta }) {
|
||||
if (delta == null) return null;
|
||||
const up = delta > 0;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '3px 8px',
|
||||
borderRadius: 999,
|
||||
background: up ? 'var(--open-50)' : 'var(--alert-50)',
|
||||
color: up ? 'var(--open-700)' : 'var(--alert-700)',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
}}>
|
||||
<span style={{ fontSize: 10 }}>{up ? '▲' : '▼'}</span>
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({ value, onChange, min = 0, max = 99 }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: 36, borderRadius: 18,
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => 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' }}>−</button>
|
||||
<div style={{ minWidth: 26, textAlign: 'center', fontSize: 15, fontWeight: 600, fontFamily: "'Geist Mono', monospace" }}>{value}</div>
|
||||
<button onClick={() => onChange(Math.min(max, value + 1))} style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 18, cursor: 'pointer' }}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button onClick={onClick} style={{
|
||||
height: s.h, padding: `0 ${s.px}px`,
|
||||
borderRadius: s.h / 2,
|
||||
background: v.bg, color: v.fg,
|
||||
border: '1px solid ' + v.bd,
|
||||
fontSize: s.fs, fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
...style,
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d={paths[name]} stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
window.OpsUI = { Avatar, Card, StatPill, Stepper, Btn, Icon };
|
||||
185
CLAUDE_DESIGN/order-app.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
onMouseDown={() => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 52, height: 52,
|
||||
borderRadius: 12,
|
||||
background: 'var(--brand-50)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 28,
|
||||
flexShrink: 0,
|
||||
}}>{item.emoji}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 600, color: 'var(--ink-900)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{item.name}</div>
|
||||
<div style={{
|
||||
fontSize: 13, color: 'var(--ink-500)', marginTop: 2,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{item.desc}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 600, color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
flexShrink: 0,
|
||||
}}>€{item.price.toFixed(2)}</div>
|
||||
{badge > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -6, right: -6,
|
||||
minWidth: 22, height: 22, padding: '0 6px',
|
||||
borderRadius: 11,
|
||||
background: 'var(--brand-500)',
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(58, 88, 201, 0.35)',
|
||||
}}>{badge}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuScreen({ onTapItem, orderCounts }) {
|
||||
const categories = [
|
||||
{ label: 'All', active: true },
|
||||
{ label: 'Pizza' },
|
||||
{ label: 'Pasta' },
|
||||
{ label: 'Desserts' },
|
||||
{ label: 'Drinks' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg)',
|
||||
}}>
|
||||
{/* Top bar */}
|
||||
<div style={{
|
||||
padding: '12px 16px 8px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid var(--ink-100)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
padding: '6px 10px',
|
||||
background: 'var(--brand-50)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--brand-700)',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>TABLE B2</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>· 4 guests · Marco</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
}}>Cart <span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 20, height: 20, padding: '0 6px',
|
||||
borderRadius: 10, background: 'var(--ink-900)', color: 'white',
|
||||
fontSize: 11, fontWeight: 700, marginLeft: 4,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>3</span></div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--ink-900)', marginBottom: 10 }}>Add item</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', scrollbarWidth: 'none', paddingBottom: 2 }}>
|
||||
{categories.map(c => (
|
||||
<button key={c.label} style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 18,
|
||||
background: c.active ? 'var(--ink-900)' : 'white',
|
||||
border: '1px solid ' + (c.active ? 'var(--ink-900)' : 'var(--ink-200)'),
|
||||
color: c.active ? 'white' : 'var(--ink-700)',
|
||||
fontSize: 14, fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{c.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{MENU.map(item => (
|
||||
<MenuItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
badge={orderCounts[item.id] || 0}
|
||||
onTap={() => onTapItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hint banner when drawer not open */}
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: 'var(--brand-50)',
|
||||
borderTop: '1px solid var(--brand-200)',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: 'var(--brand-700)',
|
||||
fontWeight: 600,
|
||||
}}>Tap "Pizza Diavola" to open the drawer</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ width: '100%', height: '100%', background: 'var(--bg)' }}>
|
||||
<IOSDevice>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<MenuScreen onTapItem={openDrawer} orderCounts={orderCounts} />
|
||||
<OrderDrawer
|
||||
product={DIAVOLA}
|
||||
isOpen={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
onAddToOrder={handleAdd}
|
||||
/>
|
||||
</div>
|
||||
</IOSDevice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
920
CLAUDE_DESIGN/order-drawer.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: s.btn, width: s.w,
|
||||
borderRadius: s.btn / 2,
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
overflow: 'hidden',
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => 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',
|
||||
}}>−</button>
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontSize: s.font - 2, fontWeight: 600,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: 'var(--ink-900)',
|
||||
}}>{value}</div>
|
||||
<button
|
||||
onClick={() => 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',
|
||||
}}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- checkmark icon --------------------------------------------------------
|
||||
function Check({ size = 18 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function Chevron({ open, size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms ease' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Row primitive (shared look across tabs) -------------------------------
|
||||
function Row({ selected, onClick, left, right, children }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: selected ? 'var(--brand-50)' : 'white',
|
||||
border: '1px solid ' + (selected ? 'var(--brand-200)' : 'var(--ink-100)'),
|
||||
borderRadius: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
minHeight: 60,
|
||||
}}
|
||||
>
|
||||
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Checkbox circle (selected / not) --------------------------------------
|
||||
function CheckCircle({ selected, size = 26 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid ' + (selected ? 'var(--brand-500)' : 'var(--ink-300)'),
|
||||
background: selected ? 'var(--brand-500)' : 'white',
|
||||
color: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{selected && <Check size={size * 0.65} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Radio dot -------------------------------------------------------------
|
||||
function RadioDot({ selected, size = 22 }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid ' + (selected ? 'var(--brand-500)' : 'var(--ink-300)'),
|
||||
background: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && (
|
||||
<div style={{
|
||||
width: size - 10, height: size - 10,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--brand-500)',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// QUICK OPTIONS TAB
|
||||
// ===========================================================================
|
||||
function QuickOptionsTab({ options, state, setState }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||
{options.map(opt => {
|
||||
const qty = state[opt.id] || 0;
|
||||
const selected = qty > 0;
|
||||
return (
|
||||
<Row
|
||||
key={opt.id}
|
||||
selected={selected}
|
||||
onClick={opt.multi ? undefined : () => setState({ ...state, [opt.id]: selected ? 0 : 1 })}
|
||||
left={!opt.multi && <CheckCircle selected={selected} />}
|
||||
right={
|
||||
opt.multi ? (
|
||||
selected ? (
|
||||
<Stepper value={qty} onChange={(v) => setState({ ...state, [opt.id]: v })} />
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { 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</button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{opt.label}</div>
|
||||
{opt.price > 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
+€{opt.price.toFixed(2)} {opt.multi ? 'each' : ''}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// EXTRAS TAB — each row expands inline to pick a sub-option
|
||||
// ===========================================================================
|
||||
function ExtrasTab({ extras, state, setState, expandedId, setExpandedId }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||
{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 (
|
||||
<div key={ex.id}>
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={
|
||||
selected ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }} onClick={(e) => e.stopPropagation()}>
|
||||
{ex.multi && (
|
||||
<Stepper
|
||||
value={selection.qty}
|
||||
onChange={(v) => setState({ ...state, [ex.id]: v === 0 ? undefined : { ...selection, qty: v } })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { 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',
|
||||
}}
|
||||
>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{ex.label}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>
|
||||
{ex.price > 0 ? `+€${ex.price.toFixed(2)}${ex.multi ? ' each' : ''}` : 'Included'}
|
||||
{subLabel && <span style={{ color: 'var(--brand-700)', fontWeight: 600 }}> · {subLabel}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Inline sub-option picker */}
|
||||
{selected && open && (
|
||||
<div style={{
|
||||
margin: '6px 0 2px 16px',
|
||||
paddingLeft: 14,
|
||||
borderLeft: '2px solid var(--brand-200)',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--ink-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
padding: '6px 2px 2px',
|
||||
}}>{ex.subLabel}</div>
|
||||
{ex.subOptions.map(sub => {
|
||||
const isSel = selection.subId === sub.id;
|
||||
return (
|
||||
<Row
|
||||
key={sub.id}
|
||||
selected={isSel}
|
||||
onClick={() => setState({ ...state, [ex.id]: { ...selection, subId: sub.id } })}
|
||||
left={<RadioDot selected={isSel} />}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 15, color: 'var(--ink-900)' }}>{sub.label}</div>
|
||||
{sub.price > 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)' }}>+€{sub.price.toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// INGREDIENTS TAB — remove only
|
||||
// ===========================================================================
|
||||
function IngredientsTab({ ingredients, state, setState }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
fontSize: 13, color: 'var(--ink-700)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Tap to remove ingredients from this item.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ingredients.map(ing => {
|
||||
const removed = !!state[ing.id];
|
||||
return (
|
||||
<Row
|
||||
key={ing.id}
|
||||
selected={false}
|
||||
onClick={() => setState({ ...state, [ing.id]: !removed })}
|
||||
right={
|
||||
<div style={{
|
||||
height: 36, padding: '0 14px',
|
||||
borderRadius: 18,
|
||||
background: removed ? 'var(--alert-500)' : 'white',
|
||||
border: '1.5px solid ' + (removed ? 'var(--alert-500)' : 'var(--ink-200)'),
|
||||
color: removed ? 'white' : 'var(--ink-700)',
|
||||
fontSize: 14, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{removed ? 'Removed' : 'Remove'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: 16, fontWeight: 500,
|
||||
color: removed ? 'var(--ink-400)' : 'var(--ink-900)',
|
||||
textDecoration: removed ? 'line-through' : 'none',
|
||||
}}>{ing.label}</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PREFERENCES TAB — radio groups
|
||||
// ===========================================================================
|
||||
function PreferencesTab({ preferences, state, setState }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{preferences.map(pref => {
|
||||
const selected = state[pref.id];
|
||||
return (
|
||||
<div key={pref.id}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'baseline', gap: 8,
|
||||
padding: '0 2px 10px',
|
||||
}}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--ink-900)' }}>{pref.label}</div>
|
||||
{pref.required && (
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
color: 'var(--alert-500)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.6,
|
||||
}}>Required</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pref.subOptions.map(sub => {
|
||||
const isSel = selected === sub.id;
|
||||
return (
|
||||
<Row
|
||||
key={sub.id}
|
||||
selected={isSel}
|
||||
onClick={() => setState({ ...state, [pref.id]: sub.id })}
|
||||
left={<RadioDot selected={isSel} />}
|
||||
right={
|
||||
sub.price !== 0 ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-500)' }}>
|
||||
{fmtSigned(sub.price)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: 'var(--ink-900)' }}>{sub.label}</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
fontSize: 13, color: 'var(--ink-700)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Anything specific for the kitchen. Short and clear works best.
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Please cut in 12 slices, extra napkins..."
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--ink-900)',
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
borderRadius: 12,
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 20, marginBottom: 8 }}>
|
||||
Quick notes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{QUICK_NOTES.map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setNote(note ? `${note}\n${q}` : q)}
|
||||
style={{
|
||||
height: 38, padding: '0 14px',
|
||||
borderRadius: 19,
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-200)',
|
||||
color: 'var(--ink-700)',
|
||||
fontSize: 14, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>+ {q}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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) => (
|
||||
<button onClick={() => onJumpTab(tab)} style={{
|
||||
background: 'none', border: 'none',
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--brand-700)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||
cursor: 'pointer',
|
||||
}}>Edit</button>
|
||||
);
|
||||
|
||||
// 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 }) => (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'white',
|
||||
border: '1px solid var(--ink-100)',
|
||||
borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
minHeight: 48,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--ink-900)' }}>
|
||||
{l.qty > 1 && <span style={{ fontFamily: "'Geist Mono', monospace", color: 'var(--ink-500)', marginRight: 6 }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
{l.detail && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 2 }}>{l.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
{l.price !== 0 && (
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
color: l.price < 0 ? 'var(--alert-500)' : 'var(--ink-900)',
|
||||
}}>{fmtSigned(l.price)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isEmpty = lines.length === 0 && !config.note;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Product summary header */}
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
background: 'var(--brand-50)',
|
||||
border: '1px solid var(--brand-200)',
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 32 }}>{product.emoji}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink-900)' }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-700)', marginTop: 2 }}>Base €{product.price.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEmpty && (
|
||||
<div style={{
|
||||
padding: '28px 16px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--ink-500)',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
No customization yet. Switch to other tabs to add options.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.pref.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Preferences</span>
|
||||
{editBtn('preferences')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.pref.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.quick.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Quick options</span>
|
||||
{editBtn('quick')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.quick.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.extra.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Extras</span>
|
||||
{editBtn('extras')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.extra.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.removed.length > 0 && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Removed</span>
|
||||
{editBtn('ingredients')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{groups.removed.map((l, i) => <LineItem key={i} l={l} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.note && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span>Note</span>
|
||||
{editBtn('notes')}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fde6a7',
|
||||
borderRadius: 10,
|
||||
fontSize: 15,
|
||||
color: 'var(--ink-900)',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>{config.note}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 */}
|
||||
<div
|
||||
onClick={handleBackdrop}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(16, 20, 24, 0.45)',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 260ms ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
height: '90%',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 320ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 11,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 -8px 32px rgba(16, 20, 24, 0.18)',
|
||||
}}>
|
||||
{/* Grab handle */}
|
||||
<div style={{ padding: '8px 0 4px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--ink-200)' }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '8px 16px 0',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56,
|
||||
borderRadius: 14,
|
||||
background: 'var(--brand-50)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 32,
|
||||
flexShrink: 0,
|
||||
}}>{product.emoji}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 2 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--ink-900)', lineHeight: 1.2 }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-500)', marginTop: 4, lineHeight: 1.4 }}>{product.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 36, height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--ink-100)',
|
||||
border: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="var(--ink-700)" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
marginTop: 14,
|
||||
borderBottom: '1px solid var(--ink-100)',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
<div style={{ display: 'flex', padding: '0 16px', gap: 4, minWidth: 'max-content' }}>
|
||||
{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 (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => 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 && (
|
||||
<span style={{
|
||||
minWidth: 20, height: 20, padding: '0 6px',
|
||||
borderRadius: 10,
|
||||
background: active ? 'var(--brand-500)' : 'var(--ink-200)',
|
||||
color: active ? 'white' : 'var(--ink-700)',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 16px 20px',
|
||||
background: 'var(--bg)',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
{activeTab === 'quick' && <QuickOptionsTab options={product.quickOptions} state={quick} setState={setQuick} />}
|
||||
{activeTab === 'extras' && <ExtrasTab extras={product.extras} state={extras} setState={setExtras} expandedId={extraExpanded} setExpandedId={setExtraExpanded} />}
|
||||
{activeTab === 'ingredients' && <IngredientsTab ingredients={product.ingredients} state={removed} setState={setRemoved} />}
|
||||
{activeTab === 'preferences' && <PreferencesTab preferences={product.preferences} state={prefs} setState={setPrefs} />}
|
||||
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
|
||||
{activeTab === 'summary' && <SummaryTab product={product} config={config} lines={lines} onJumpTab={setActiveTab} />}
|
||||
</div>
|
||||
|
||||
{/* Footer — qty stepper + Add to Order */}
|
||||
<div style={{
|
||||
padding: '12px 16px 18px',
|
||||
background: 'white',
|
||||
borderTop: '1px solid var(--ink-100)',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
boxShadow: '0 -4px 12px rgba(16, 20, 24, 0.04)',
|
||||
}}>
|
||||
<Stepper value={qty} onChange={setQty} min={1} size="lg" />
|
||||
<button
|
||||
onClick={() => 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)'}
|
||||
>
|
||||
<span>Add to order</span>
|
||||
<span style={{ fontFamily: "'Geist Mono', monospace" }}>€{total.toFixed(2)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
window.OrderDrawer = OrderDrawer;
|
||||
478
CLAUDE_DESIGN/table-card.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: 22, padding: '0 8px',
|
||||
borderRadius: 999,
|
||||
background: s.bg, color: s.fg,
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{s.label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder dashes so empty fields keep their footprint but visually disappear
|
||||
function EmptyDash({ width = 40 }) {
|
||||
return <span style={{ color: 'var(--ink-300)', letterSpacing: 2, userSelect: 'none' }}>— —</span>;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => 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 */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: 'var(--radius) 0 0 var(--radius)',
|
||||
}} />
|
||||
|
||||
{/* Header row: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 32, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height whether or not flags exist */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
|
||||
{/* Stats row: amount + time. Fixed layout, empty dashes when missing */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 20, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-900)',
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => 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 */}
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: s.accent,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
|
||||
<div style={{ padding: '12px 16px 14px', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Top row: name BIG + status label */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{
|
||||
fontSize: 38, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -1,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 700,
|
||||
color: s.ink,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
textAlign: 'right',
|
||||
lineHeight: 1.2,
|
||||
paddingTop: 4,
|
||||
}}>{s.label}</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height */}
|
||||
<div style={{ marginTop: 6, height: 22, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
|
||||
{/* Stats line */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 600,
|
||||
color: 'var(--ink-900)',
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
}}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
<div style={{ width: 1, height: 16, background: s.tintStrong }} />
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-700)',
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
height: 32,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={26} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '3px 10px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={26} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => 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 */}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
width: 68, height: 56,
|
||||
borderRadius: 10,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 26, fontWeight: 700,
|
||||
letterSpacing: -0.5,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
flexShrink: 0,
|
||||
}}>{name}</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 700,
|
||||
color: s.ink,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
lineHeight: 1,
|
||||
}}>{s.label}</div>
|
||||
{/* Flags row — fixed height so layout stays stable */}
|
||||
<div style={{ height: 22, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||||
{flags.map(f => <Flag key={f} kind={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 6,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink-900)', marginTop: 2, fontFamily: "'Geist Mono', 'Geist', monospace", lineHeight: 1.1 }}>
|
||||
{amount != null ? formatEuro(amount) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-500)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'Geist', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: 'var(--ink-900)',
|
||||
lineHeight: 1.1,
|
||||
}}>
|
||||
{occupiedMins != null ? formatDuration(occupiedMins) : <EmptyDash />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 38,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: 'var(--ink-400)', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Initials name={w} size={26} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--ink-700)',
|
||||
background: 'white', border: '1px solid var(--ink-200)',
|
||||
borderRadius: 999, padding: '3px 10px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Initials name={w} size={26} />
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-700)', fontWeight: 500 }}>{w.split(' ')[0]}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Export to window
|
||||
Object.assign(window, { TableCardV1, TableCardV2, TableCardV3, STATUS });
|
||||
3137
manager_dashboard/package-lock.json
generated
Normal file
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="waiters" element={<WaitersPage />} />
|
||||
<Route index element={<Navigate to="/operations" replace />} />
|
||||
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
|
||||
<Route path="operations" element={<OperationsPage />} />
|
||||
<Route path="tables" element={<TablesPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="management" element={<ManagementPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,11 +2,10 @@ import { NavLink } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', icon: '📊', label: 'Dashboard' },
|
||||
{ to: '/operations', icon: '📊', label: 'Διοίκηση' },
|
||||
{ to: '/tables', icon: '🪑', label: 'Τραπέζια' },
|
||||
{ to: '/products', icon: '📦', label: 'Προϊόντα' },
|
||||
{ to: '/waiters', icon: '👥', label: 'Σερβιτόροι' },
|
||||
{ to: '/reports', icon: '📋', label: 'Αναφορές' },
|
||||
{ to: '/management', icon: '🗂️', label: 'Διαχείριση' },
|
||||
{ to: '/settings', icon: '⚙️', label: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
|
||||
5
manager_dashboard/src/icons/add.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C21.5093 4.43821 21.8356 5.80655 21.9449 8" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
8
manager_dashboard/src/icons/delete.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 11V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 859 B |
4
manager_dashboard/src/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 10L14 6M18 10L21 7L17 3L14 6M18 10L17 11M14 6L8 12V16H12L14.5 13.5M20 14V20H12M10 4L4 4L4 20H7" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
5
manager_dashboard/src/icons/move-down.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 10.99L13.13 14.05C12.9858 14.2058 12.811 14.3298 12.6166 14.4148C12.4221 14.4998 12.2122 14.5437 12 14.5437C11.7878 14.5437 11.5779 14.4998 11.3834 14.4148C11.189 14.3298 11.0142 14.2058 10.87 14.05L8 10.99" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
5
manager_dashboard/src/icons/move-up.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.8599L10.87 10.8C11.0125 10.6416 11.1868 10.5149 11.3815 10.4282C11.5761 10.3415 11.7869 10.2966 12 10.2966C12.2131 10.2966 12.4239 10.3415 12.6185 10.4282C12.8132 10.5149 12.9875 10.6416 13.13 10.8L16 13.8599" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(17,19,21,0.92)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 24,
|
||||
padding: '36px 32px', width: '100%', maxWidth: 340,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.4)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🔒</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#111315', marginBottom: 4 }}>
|
||||
Κλειδωμένο
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#5a6169', marginBottom: 24 }}>
|
||||
{username}
|
||||
</div>
|
||||
|
||||
{/* PIN dots */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginBottom: 24 }}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} style={{
|
||||
width: 14, height: 14, borderRadius: '50%', border: '2px solid',
|
||||
borderColor: i < pin.length ? '#3758c9' : '#d1d5db',
|
||||
background: i < pin.length ? '#3758c9' : 'transparent',
|
||||
transition: 'all 120ms',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* PIN pad */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 16 }}>
|
||||
{DIGITS.map((d, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: 13, color: '#dc2626', marginBottom: 8 }}>{error}</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p style={{ fontSize: 13, color: '#6b7280' }}>Επαλήθευση…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Lock overlay — rendered on top of everything */}
|
||||
{locked && displayName && (
|
||||
<LockScreen username={displayName} onUnlock={handleUnlock} />
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-gray-200 shrink-0">
|
||||
<span className="text-lg font-semibold text-gray-700 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Lock button */}
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Κλείδωμα"
|
||||
style={{
|
||||
height: 30, width: 30, borderRadius: 8,
|
||||
border: '1px solid #dfe2e6', background: 'white',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 15, cursor: 'pointer', color: '#5a6169',
|
||||
transition: 'background 120ms, color 120ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#f3f4f6'; e.currentTarget.style.color = '#374151' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.color = '#5a6169' }}
|
||||
>🔒</button>
|
||||
<span className="text-sm text-gray-500">{user?.username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
|
||||
>
|
||||
Αποσύνδεση
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => 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 */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row — fixed height placeholder */}
|
||||
<div style={{ marginTop: 8, height: 22 }} />
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{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 (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
739
manager_dashboard/src/pages/DashboardTab.jsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-800">Έναρξη Βάρδιας</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||||
<select className="h-10 w-full rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none"
|
||||
value={waiterId} onChange={e => setWaiterId(e.target.value)}>
|
||||
<option value="">— Επιλέξτε —</option>
|
||||
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Αρχικά Μετρητά (€)</label>
|
||||
<input type="number" step="0.01" min="0" placeholder="0.00" value={cash} onChange={e => 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" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Ακύρωση</button>
|
||||
<button onClick={submit} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Εκκίνηση…' : 'Έναρξη'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">Κλείσιμο Ημέρας</h2>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'τραπέζι είναι ακόμα ανοιχτό' : 'τραπέζια είναι ακόμα ανοιχτά'}
|
||||
</p>
|
||||
<p>Κανένα δεν έχει εκκρεμείς χρεώσεις. Θέλετε να κλείσουν όλα και να κλείσει η ημέρα;</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Κλείσε Όλα & Κλείσε Ημέρα'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Some tables have unpaid items — revenue will be lost, needs hard warning
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-red-600 text-lg font-bold">!</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">Εκκρεμείς Πληρωμές</h2>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800 space-y-2">
|
||||
<p className="font-semibold">
|
||||
{details.open_orders} {details.open_orders === 1 ? 'ανοιχτό τραπέζι' : 'ανοιχτά τραπέζια'},
|
||||
από τα οποία <span className="underline">{details.partially_paid} έχ{details.partially_paid === 1 ? 'ει' : 'ουν'} εκκρεμείς πληρωμές</span>.
|
||||
</p>
|
||||
<p>Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά θα χαθούν και δεν θα καταγραφούν στις αναφορές.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 p-3 text-xs text-gray-500 bg-gray-50">
|
||||
Επιλέξτε <strong>Ακύρωση</strong> για να χειριστείτε χειροκίνητα τα εκκρεμή τραπέζια πριν κλείσετε την ημέρα.
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button onClick={onConfirm} disabled={busy}
|
||||
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
|
||||
{busy ? 'Κλείσιμο…' : 'Αναγκαστικό Κλείσιμο'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="rounded-2xl border overflow-hidden"
|
||||
style={{ borderColor: isOpen ? '#bbf7d0' : '#e5e7eb' }}>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-5 py-3"
|
||||
style={{ background: isOpen ? '#f0fdf4' : '#f9fafb' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: isOpen ? '#16a34a' : '#9ca3af',
|
||||
boxShadow: isOpen ? '0 0 0 3px #bbf7d0' : 'none',
|
||||
}} />
|
||||
<div>
|
||||
<span className="font-bold text-sm" style={{ color: isOpen ? '#15803d' : '#6b7280' }}>
|
||||
{isOpen ? 'Εστιατόριο Ανοιχτό' : 'Εστιατόριο Κλειστό'}
|
||||
</span>
|
||||
{isOpen && businessDay?.opened_at && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
από {fmtTime(businessDay.opened_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isOpen && waitersWithoutShift.length > 0 && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
+ Βάρδια
|
||||
</button>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<button
|
||||
onClick={() => handleCloseDay(false)}
|
||||
className="h-8 px-3 rounded-lg bg-red-600 text-white text-xs font-semibold hover:bg-red-700"
|
||||
>
|
||||
Κλείσιμο Ημέρας
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => 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 ? 'Άνοιγμα…' : '▶ Άνοιγμα Ημέρας'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active shifts */}
|
||||
{isOpen && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 bg-white">
|
||||
{activeShifts.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Κανένας σερβιτόρος σε βάρδια</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeShifts.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-xl px-3 py-1.5">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{s.waiter_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{fmtTime(s.started_at)} · {fmtShiftDuration(s.started_at)}</span>
|
||||
{s.total_collected > 0 && (
|
||||
<span className="text-xs text-green-700 ml-2 font-medium">€{s.total_collected.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleEndShift(s.id, s.waiter_name)}
|
||||
className="text-xs text-red-500 hover:text-red-700 ml-1 font-medium"
|
||||
title="Τέλος βάρδιας"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStartShift && (
|
||||
<StartShiftModal
|
||||
waiters={waitersWithoutShift}
|
||||
onClose={() => setShowStartShift(false)}
|
||||
onStart={handleStartShift}
|
||||
/>
|
||||
)}
|
||||
{closeDetails && (
|
||||
<CloseConfirmModal
|
||||
details={closeDetails}
|
||||
onClose={() => 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 (
|
||||
<img
|
||||
src={waiter.avatarUrl}
|
||||
alt={waiter.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
|
||||
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const parts = waiter.name.trim().split(' ')
|
||||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(waiter.name),
|
||||
color: 'white',
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 0 2px var(--cardBg, white)',
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => 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 */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
|
||||
background: s.accent,
|
||||
borderRadius: '14px 0 0 14px',
|
||||
}} />
|
||||
|
||||
{/* Header: name + status pill */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{
|
||||
fontSize: 34, fontWeight: 700, lineHeight: 1,
|
||||
letterSpacing: -0.5,
|
||||
color: '#111315',
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
}}>{name}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
height: 26, padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
background: s.accent,
|
||||
color: 'white',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags row */}
|
||||
<div style={{ marginTop: 8, height: 22, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{hasPendingPrint && (
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: '#92400e', color: '#fcd34d',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
⏳ Εκκρεμής εκτύπωση
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
|
||||
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
|
||||
<div style={{
|
||||
fontSize: 22, marginTop: 2,
|
||||
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
|
||||
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
|
||||
color: '#111315',
|
||||
}}>
|
||||
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}>— —</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter row */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 10,
|
||||
borderTop: '1px solid ' + s.tintStrong,
|
||||
height: 36,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{waiters.length === 0 ? (
|
||||
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
|
||||
) : showMulti ? (
|
||||
<>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: '#2b2f33',
|
||||
background: 'white', border: '1px solid #dfe2e6',
|
||||
borderRadius: 999, padding: '2px 8px',
|
||||
}}>Multiple ({waiters.length})</span>
|
||||
</>
|
||||
) : (
|
||||
waiters.map((w, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<WaiterBubble waiter={w} size={24} />
|
||||
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BusinessDayPanel />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`btn text-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
|
||||
{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 (
|
||||
<TableCardV1
|
||||
key={table.id}
|
||||
name={table.label || `T${table.number}`}
|
||||
status={tableStatus}
|
||||
amount={amount}
|
||||
openedAt={order?.opened_at ?? null}
|
||||
waiters={waiterNames}
|
||||
hasPendingPrint={hasPendingPrint}
|
||||
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Draft Orders Panel ─────────────────────────────────────────────── */}
|
||||
{pendingPrintOrders.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-orange-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-orange-100" style={{ background: '#fff7ed' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span style={{ fontSize: 20 }}>⏳</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-orange-900">Εκκρεμείς Εκτυπώσεις</h2>
|
||||
<p className="text-xs text-orange-700 mt-0.5">
|
||||
{pendingPrintOrders.length} παραγγελί{pendingPrintOrders.length !== 1 ? 'ες' : 'α'} δεν έχ{pendingPrintOrders.length !== 1 ? 'ουν' : 'ει'} σταλεί στην κουζίνα/μπαρ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ background: '#c2410c', borderColor: '#c2410c' }}
|
||||
onClick={retryAllOrders}
|
||||
disabled={retryingId !== null}
|
||||
>
|
||||
{retryingId !== null ? 'Αποστολή…' : 'Αποστολή Όλων'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-orange-50">
|
||||
{pendingPrintOrders.map(({ table, order }) => {
|
||||
const unprinted = order.items.filter(i => i.status === 'active' && !i.printed)
|
||||
const tableName = table.label || `T${table.number}`
|
||||
return (
|
||||
<div key={order.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm"
|
||||
style={{ background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' }}>
|
||||
{tableName}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{unprinted.length} αντικείμενο{unprinted.length !== 1 ? 'α' : ''} εκκρεμούν
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{unprinted.map(i => i.product?.name || `#${i.product_id}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
className="btn btn-secondary text-xs"
|
||||
onClick={() => navigate(`/orders/${order.id}`)}
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary text-xs"
|
||||
style={{ background: '#c2410c', borderColor: '#c2410c' }}
|
||||
onClick={() => retrySingleOrder(order.id)}
|
||||
disabled={retryingId === order.id}
|
||||
>
|
||||
{retryingId === order.id ? '…' : 'Εκτύπωση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
manager_dashboard/src/pages/ManagementPage.jsx
Normal file
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 0 }}>
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4,
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
paddingBottom: 0,
|
||||
marginBottom: 24,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{activeTab === 'products' && <ProductsTab />}
|
||||
{activeTab === 'tables' && <TablesConfigTab />}
|
||||
{activeTab === 'staff' && <StaffTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1610
manager_dashboard/src/pages/OperationsPage.jsx
Normal file
@@ -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 */}
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Προσωπικό</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{order.waiters.map(w => (
|
||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||
@@ -239,13 +239,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
))}
|
||||
{isOpen && !readOnly && (
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
className="text-sm border border-gray-300 rounded-full pl-3 pr-8 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
|
||||
defaultValue=""
|
||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||
>
|
||||
<option value="">+ Πρόσθεσε</option>
|
||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
||||
<option key={w.id} value={w.id}>{w.username}</option>
|
||||
<option key={w.id} value={w.id}>{w.nickname || w.full_name || w.username}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
@@ -395,8 +398,11 @@ export default function ReportsPage() {
|
||||
|
||||
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||||
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||||
{tab === 'shifts-history' && <ShiftsHistoryTab />}
|
||||
{tab === 'printers' && <PrintersTab />}
|
||||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||
{tab === 'product-perf' && <ProductPerformanceTab />}
|
||||
{tab === 'traffic' && <TrafficTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -934,3 +940,324 @@ function HistoryTab({ filters, setFilters }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Σερβιτόρος</label>
|
||||
<select className={SELECT + ' w-44'} value={waiterId} onChange={e => setWaiterId(e.target.value)}>
|
||||
<option value="">Όλοι</option>
|
||||
{waiters.map(w => <option key={w.id} value={w.id}>{w.full_name || w.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 h-10 cursor-pointer select-none text-sm text-gray-700">
|
||||
<input type="checkbox" className="w-4 h-4 rounded accent-primary-700"
|
||||
checked={activeOnly} onChange={e => setActiveOnly(e.target.checked)} />
|
||||
Μόνο ενεργές
|
||||
</label>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
{rows.length > 0 && (
|
||||
<button onClick={() => csvDownload(csvRows, `shifts_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||||
Εξαγωγή CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν βάρδιες.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Σερβιτόρος</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Έναρξη</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Λήξη</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Αρχικά (€)</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Εισπράχθηκαν (€)</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Προς Απόδοση (€)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600">Κατάσταση</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-800">{r.waiter_name}</td>
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{fmtDt(r.started_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{r.ended_at ? fmtDt(r.ended_at) : '—'}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700">
|
||||
{r.starting_cash != null ? `€${r.starting_cash.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-gray-800">
|
||||
€{(r.total_collected || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-primary-700">
|
||||
€{(r.net_to_deliver || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.is_active
|
||||
? <span className="inline-flex items-center gap-1 text-xs font-semibold text-green-700 bg-green-50 px-2 py-0.5 rounded-full">● Ενεργή</span>
|
||||
: <span className="text-xs text-gray-400">Έκλεισε</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t-2 border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-3 font-bold text-gray-800">Σύνολο</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-gray-800">€{grandTotal.toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right font-bold text-primary-700">€{grandDeliver.toFixed(2)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Ταξινόμηση</label>
|
||||
<select className={SELECT + ' w-44'} value={sortBy} onChange={e => setSortBy(e.target.value)}>
|
||||
<option value="qty_sold">Τεμάχια</option>
|
||||
<option value="revenue">Έσοδα</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
{rows.length > 0 && (
|
||||
<button onClick={() => csvDownload(csvRows, `products_${fromDt.slice(0,10)}.csv`)} className={BTN_SEC}>
|
||||
Εξαγωγή CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<p className="text-center text-gray-400 py-12">Δεν βρέθηκαν δεδομένα.</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="text-center px-3 py-3 font-semibold text-gray-500 w-10">#</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-gray-600">Προϊόν</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Τεμάχια</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Παραγγελίες</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Έσοδα (€)</th>
|
||||
<th className="px-4 py-3 w-48" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{rows.map((r, i) => {
|
||||
const barPct = Math.round((r[sortBy] / maxVal) * 100)
|
||||
return (
|
||||
<tr key={r.product_id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2.5 text-center text-gray-400 font-mono text-xs">{i + 1}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-800">{r.product_name}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 font-mono">{r.qty_sold}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-500">{r.order_count}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{r.revenue.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div style={{ height: 6, borderRadius: 999, background: '#e5e7eb', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${barPct}%`, height: '100%', borderRadius: 999,
|
||||
background: sortBy === 'revenue' ? '#7c3aed' : '#2563eb',
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Από</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={fromDt} onChange={e => setFromDt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide block mb-1">Έως</label>
|
||||
<DateTimeInput className={CTRL + ' w-52'} value={toDt} onChange={e => setToDt(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => 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'}`}>Ώρα</button>
|
||||
<button onClick={() => 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'}`}>Ημέρα</button>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className={BTN_SEC}>Ανανέωση</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Φόρτωση…</p>}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Orders chart */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Παραγγελίες ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{activeData.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-12 text-right shrink-0">{label(d)}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div style={{ flex: 1, height: 18, background: '#f3f4f6', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: d.orders === 0 ? '0%' : `${Math.max(2, (d.orders / maxOrders) * 100)}%`,
|
||||
height: '100%', background: '#2563eb', borderRadius: 4,
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-700 w-8 text-right">{d.orders}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue chart */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4">Έσοδα ανά {view === 'hour' ? 'ώρα' : 'ημέρα'}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{activeData.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-12 text-right shrink-0">{label(d)}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div style={{ flex: 1, height: 18, background: '#f3f4f6', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: d.revenue === 0 ? '0%' : `${Math.max(2, (d.revenue / maxRevenue) * 100)}%`,
|
||||
height: '100%', background: '#7c3aed', borderRadius: 4,
|
||||
transition: 'width 300ms ease',
|
||||
}} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-700 w-14 text-right">€{d.revenue.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
@@ -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 (
|
||||
<div style={{ width: '100%' }}>
|
||||
<h1 className="text-xl font-bold text-gray-800" style={{ marginBottom: 20 }}>Ρυθμίσεις</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4,
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
marginBottom: 28,
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'app-info' && <AppInfoTab />}
|
||||
{activeTab === 'colours' && <ColoursTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
541
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -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 (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => !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,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: checked ? 23 : 3,
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Ζώνη Ώρας</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Η ζώνη ώρας που χρησιμοποιεί το backend για χρονοσφραγίδες. Αν οι ώρες έναρξης βάρδιας εμφανίζονται λανθασμένες, ρυθμίστε αυτό να ταιριάζει με την τοπική σας ζώνη.
|
||||
</p>
|
||||
</div>
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={currentTz}
|
||||
onChange={e => 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 => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
{updateMut.isPending && <span className="text-xs text-gray-400">Αποθήκευση…</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Ζώνη ώρας browser: <span className="font-medium text-gray-600">{browserTz}</span>
|
||||
{browserTz !== currentTz && (
|
||||
<span className="ml-2 text-amber-600 font-medium">⚠ Διαφέρει από τη ρύθμιση backend</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Η αλλαγή ζώνης ώρας αποθηκεύεται και εφαρμόζεται στο frontend αμέσως. Για πλήρη εφαρμογή στον backend server (χρονοσφραγίδες), απαιτείται επανεκκίνηση του container.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Αυτόματο Κλείδωμα Διαχειριστή</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Αν δεν υπάρξει δραστηριότητα για το παρακάτω διάστημα, η οθόνη κλειδώνει και ζητάει PIN.
|
||||
Το 0 απενεργοποιεί το αυτόματο κλείδωμα.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center gap-4">
|
||||
<select
|
||||
value={timeout}
|
||||
onChange={e => 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 => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{timeout > 0 && (
|
||||
<span className="text-xs text-green-700 font-medium bg-green-50 border border-green-200 rounded-lg px-3 py-1.5">
|
||||
Κλείδωμα μετά από {timeout} {timeout === 1 ? 'λεπτό' : 'λεπτά'} αδράνειας
|
||||
</span>
|
||||
)}
|
||||
{timeout === 0 && (
|
||||
<span className="text-xs text-gray-500">Μόνο χειροκίνητο κλείδωμα (κουμπί 🔒)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Ρυθμίσεις Βάρδιας</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους</p>
|
||||
</div>
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Αυτόματη Έναρξη Βάρδιας</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να ξεκινούν μόνοι τους τη βάρδια τους</p>
|
||||
</div>
|
||||
<Toggle checked={selfStart === 'true'} onChange={() => toggle('shifts.waiter_self_start', selfStart)} disabled={updateMut.isPending} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Αυτόματο Κλείσιμο Βάρδιας</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Οι σερβιτόροι μπορούν να κλείνουν μόνοι τους τη βάρδια τους</p>
|
||||
</div>
|
||||
<Toggle checked={selfEnd === 'true'} onChange={() => toggle('shifts.waiter_self_end', selfEnd)} disabled={updateMut.isPending} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => 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 || '+'}</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '110%', left: 0, zIndex: 200,
|
||||
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)', padding: 8,
|
||||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 2, width: 180,
|
||||
}}>
|
||||
{RESTAURANT_EMOJIS.map(e => (
|
||||
<button key={e} type="button" onClick={() => { onChange(e); setOpen(false) }} style={{
|
||||
fontSize: 20, background: value === e ? '#eff3ff' : 'none',
|
||||
border: 'none', borderRadius: 6, padding: '4px 0', cursor: 'pointer',
|
||||
}}>{e}</button>
|
||||
))}
|
||||
<button type="button" onClick={() => { onChange(''); setOpen(false) }} style={{
|
||||
fontSize: 11, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', borderRadius: 6,
|
||||
}}>✕ clear</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: '14px 20px', borderTop: '1px solid #f4f4f2' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#5a6169', marginBottom: 8, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Εμφάνιση σημαιών στις κάρτες τραπεζιών
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{options.map(o => (
|
||||
<button key={o.value} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Σημαίες Τραπεζιών</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Χρησιμοποιούνται για να επισημαίνετε καταστάσεις στα τραπέζια</p>
|
||||
</div>
|
||||
<button onClick={() => 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',
|
||||
}}>+ Νέα</button>
|
||||
</div>
|
||||
<FlagDisplayModeSection />
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
|
||||
<EmojiPicker value={newForm.emoji} onChange={v => setNewForm(f => ({ ...f, emoji: v }))} />
|
||||
<input placeholder="Όνομα σημαίας" value={newForm.name} onChange={e => 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' }} />
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{FLAG_COLORS.map(c => (
|
||||
<button key={c} onClick={() => 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' }} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => 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' }}>Αποθήκευση</button>
|
||||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||||
{!isLoading && flags.length === 0 && (
|
||||
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν σημαίες ακόμα.</p>
|
||||
)}
|
||||
{flags.map(flag => (
|
||||
<div key={flag.id} style={{ ...rowStyle, opacity: flag.is_active ? 1 : 0.45 }}>
|
||||
{editingId === flag.id ? (
|
||||
<div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<EmojiPicker value={editForm.emoji} onChange={v => setEditForm(f => ({ ...f, emoji: v }))} />
|
||||
<input value={editForm.name} onChange={e => 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' }} />
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{FLAG_COLORS.map(c => (
|
||||
<button key={c} onClick={() => 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' }} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => 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' }}>✓</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: flag.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0 }}>
|
||||
{flag.emoji || '🏷️'}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: 500, color: '#111315' }}>{flag.name}</span>
|
||||
{!flag.is_active && <span style={{ fontSize: 11, color: '#9ca3af', fontStyle: 'italic' }}>Ανενεργή</span>}
|
||||
<button onClick={() => startEdit(flag)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}>Επεξεργασία</button>
|
||||
{flag.is_active && (
|
||||
<button onClick={() => deleteMut.mutate(flag.id)} style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">Γρήγορα Μηνύματα</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Πρότυπα μηνυμάτων για γρήγορη αποστολή στο προσωπικό</p>
|
||||
</div>
|
||||
<button onClick={() => 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',
|
||||
}}>+ Νέο</button>
|
||||
</div>
|
||||
{showNew && (
|
||||
<div style={{ padding: '14px 20px', background: '#f9fafb', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<input placeholder="Κείμενο μηνύματος…" value={newBody} onChange={e => setNewBody(e.target.value)}
|
||||
style={{ flex: 1, height: 36, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 13, fontFamily: 'inherit' }} />
|
||||
<button onClick={() => 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' }}>Αποθήκευση</button>
|
||||
<button onClick={() => setShowNew(false)} style={{ height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 13, cursor: 'pointer' }}>Άκυρο</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||||
{!isLoading && templates.length === 0 && (
|
||||
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Δεν υπάρχουν πρότυπα ακόμα.</p>
|
||||
)}
|
||||
{templates.map((t, idx) => (
|
||||
<div key={t.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
|
||||
<span style={{ width: 22, fontSize: 12, color: '#9ca3af', fontWeight: 600, flexShrink: 0 }}>{idx + 1}.</span>
|
||||
{editingId === t.id ? (
|
||||
<>
|
||||
<input value={editBody} onChange={e => setEditBody(e.target.value)}
|
||||
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }} />
|
||||
<button onClick={() => 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' }}>✓</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
style={{ height: 32, padding: '0 10px', borderRadius: 6, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, cursor: 'pointer' }}>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ flex: 1, fontSize: 14, color: '#111315' }}>{t.body}</span>
|
||||
<button onClick={() => { 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' }}>Επεξεργασία</button>
|
||||
<button onClick={() => deleteMut.mutate(t.id)}
|
||||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626' }}>Διαγραφή</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System info */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="text-gray-500">Uptime</div>
|
||||
<div className="font-medium text-gray-800">{formatUptime(status?.uptime_seconds ?? 0)}</div>
|
||||
<div className="text-gray-500">Άδεια χρήσης</div>
|
||||
<div className={`font-medium ${status?.licensed ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
|
||||
</div>
|
||||
<div className="text-gray-500">Κατάσταση</div>
|
||||
<div className={`font-medium ${status?.locked ? 'text-red-600' : 'text-green-700'}`}>
|
||||
{status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
|
||||
</div>
|
||||
{status?.expires_at && (
|
||||
<>
|
||||
<div className="text-gray-500">Λήξη άδειας</div>
|
||||
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printers */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||||
</div>
|
||||
{(!status?.printers || status.printers.length === 0) && (
|
||||
<p className="px-5 py-6 text-center text-gray-400 text-sm">Δεν βρέθηκαν εκτυπωτές.</p>
|
||||
)}
|
||||
{status?.printers?.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">{p.name}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${p.reachable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
<button onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ShiftSettingsSection />
|
||||
<AutoLockSection />
|
||||
<TimezoneSection />
|
||||
<FlagDefsSection />
|
||||
<QuickTemplatesSection />
|
||||
|
||||
{user?.role === 'sysadmin' && (
|
||||
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
|
||||
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
|
||||
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-primary text-sm">Ξεκλείδωμα</button>
|
||||
<button onClick={() => client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-danger text-sm">Κλείδωμα</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
511
manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff', borderRadius: 20, padding: 28, width: '100%', maxWidth: 400,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Pick a Colour</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Preview swatch — checkerboard behind so alpha is visible */}
|
||||
<div style={{
|
||||
width: '100%', height: 56, borderRadius: 12, marginBottom: 20,
|
||||
border: '1px solid #e5e7eb', overflow: 'hidden', position: 'relative',
|
||||
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: '12px 12px',
|
||||
backgroundPosition: '0 0,0 6px,6px -6px,-6px 0',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, background: preview,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontFamily: 'monospace', color: alpha > 0.5 ? '#fff' : '#374151',
|
||||
textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
|
||||
}}>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colour picker + hex input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Colour</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<input
|
||||
type="color"
|
||||
value={hex}
|
||||
onChange={e => setHex(e.target.value)}
|
||||
style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hex}
|
||||
onChange={e => {
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opacity slider — always visible */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>Opacity</div>
|
||||
<div style={{ fontSize: 12, fontFamily: 'monospace', color: '#6b7280' }}>{Math.round(alpha * 100)}%</div>
|
||||
</div>
|
||||
{/* Gradient track so you can see what you're dragging */}
|
||||
<div style={{
|
||||
position: 'relative', height: 28,
|
||||
background: `linear-gradient(to right, transparent, ${hex})`,
|
||||
borderRadius: 8, border: '1px solid #e5e7eb',
|
||||
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%),linear-gradient(to right,transparent,${hex})`,
|
||||
backgroundSize: '10px 10px,10px 10px,10px 10px,10px 10px,100% 100%',
|
||||
backgroundPosition: '0 0,0 5px,5px -5px,-5px 0,0 0',
|
||||
}}>
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.01}
|
||||
value={alpha}
|
||||
onChange={e => setAlpha(parseFloat(e.target.value))}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, width: '100%', height: '100%',
|
||||
opacity: 0, cursor: 'pointer', margin: 0,
|
||||
}}
|
||||
/>
|
||||
{/* thumb indicator */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', transform: 'translate(-50%,-50%)',
|
||||
left: `${alpha * 100}%`,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: preview, border: '2px solid #fff',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick swatches */}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Quick select</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(QUICK_SWATCHES[slot] || []).map(c => {
|
||||
const p = parseColour(c)
|
||||
const built = buildColour(p.hex, p.alpha)
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
title={c}
|
||||
onClick={() => 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)',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: c }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid #f3f4f6', display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1, height: 40, borderRadius: 10, border: '1px solid #e5e7eb',
|
||||
background: '#f9fafb', fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}
|
||||
>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Single colour slot row ──────────────────────────────────────────────────
|
||||
|
||||
function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
|
||||
<button
|
||||
onClick={() => 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)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'monospace', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mini mock table card (for preview) ──────────────────────────────────────
|
||||
|
||||
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
|
||||
position: 'relative', flexShrink: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Table name + group */}
|
||||
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<span style={{
|
||||
fontSize: 17, fontWeight: 800, color: cfg.nameText,
|
||||
lineHeight: 1, letterSpacing: -0.5,
|
||||
}}>{mockName}</span>
|
||||
<span style={{
|
||||
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
|
||||
color: cfg.nameText + '80',
|
||||
textTransform: 'uppercase',
|
||||
}}>{groupName}</span>
|
||||
</div>
|
||||
{/* Status badge — tight equal padding on all sides */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 7, left: 7,
|
||||
background: cfg.badgeBg,
|
||||
borderRadius: 4, padding: '2px 5px',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div style={{
|
||||
background: panelBg, borderRadius: 16, padding: 16,
|
||||
border: '1px solid ' + (isDark ? '#253245' : '#cbd5e1'),
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: labelCol, marginBottom: 12, letterSpacing: 0.3 }}>
|
||||
{panelLabel}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||
{mockCards.map((mc, i) => (
|
||||
<MockCard
|
||||
key={i}
|
||||
cfg={colours[mode][mc.status]}
|
||||
label={STATUS_LABELS_MOCK[mc.status]}
|
||||
mockName={mc.name}
|
||||
groupName={mc.group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
|
||||
|
||||
function StatusBlock({ mode, status, label, colours, onOpen }) {
|
||||
const cfg = colours[mode][status]
|
||||
return (
|
||||
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ width: 88, flexShrink: 0 }}>
|
||||
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Click a swatch to edit</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
|
||||
{SLOTS.map(slot => (
|
||||
<ColourSlotRow
|
||||
key={slot.key}
|
||||
mode={mode}
|
||||
status={status}
|
||||
slotKey={slot.key}
|
||||
label={slot.label}
|
||||
value={cfg[slot.key]}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mode section (light or dark) ────────────────────────────────────────────
|
||||
|
||||
function ModeSection({ mode, colours, onOpen }) {
|
||||
const label = mode === 'light' ? '☀️ Light Mode' : '🌙 Dark Mode'
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#111827', marginBottom: 14 }}>{label}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{STATUSES.map(s => (
|
||||
<StatusBlock
|
||||
key={s.key}
|
||||
mode={mode}
|
||||
status={s.key}
|
||||
label={s.label}
|
||||
colours={colours}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#111827', marginBottom: 4 }}>UI Personalization</h2>
|
||||
<p style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Customise how the Waiter App looks. Changes are saved to the server and sync to all devices automatically.
|
||||
{saving && <span style={{ marginLeft: 8, color: '#9ca3af' }}>Saving…</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Section: Waiter App — Table Colour Schemes */}
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827', marginBottom: 4 }}>
|
||||
Waiter App — Table Colour Schemes
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#6b7280' }}>
|
||||
Each table card has four colour slots. Click any colour swatch below to open the colour picker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live previews side by side */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 32 }}>
|
||||
<PreviewPanel colours={colours} mode="light" />
|
||||
<PreviewPanel colours={colours} mode="dark" />
|
||||
</div>
|
||||
|
||||
{/* Light + Dark mode settings */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
|
||||
<ModeSection mode="light" colours={colours} onOpen={openModal} />
|
||||
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset all button at bottom */}
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
height: 40, padding: '0 20px', borderRadius: 10,
|
||||
border: '1.5px solid #fca5a5', background: '#fff5f5',
|
||||
color: '#dc2626', fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Colour picker modal */}
|
||||
{modal && (
|
||||
<ColourPickerModal
|
||||
value={modal.value}
|
||||
slot={modal.slot}
|
||||
onClose={() => setModal(null)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h1 className="text-xl font-bold text-gray-800">Ρυθμίσεις</h1>
|
||||
|
||||
{/* System info */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="font-semibold text-gray-700">Σύστημα</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="text-gray-500">Uptime</div>
|
||||
<div className="font-medium text-gray-800">{formatUptime(status?.uptime_seconds ?? 0)}</div>
|
||||
<div className="text-gray-500">Άδεια χρήσης</div>
|
||||
<div className={`font-medium ${status?.licensed ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{status?.licensed ? 'Ενεργή' : 'Ανενεργή'}
|
||||
</div>
|
||||
<div className="text-gray-500">Κατάσταση</div>
|
||||
<div className={`font-medium ${status?.locked ? 'text-red-600' : 'text-green-700'}`}>
|
||||
{status?.locked ? 'Κλειδωμένο' : 'Λειτουργικό'}
|
||||
</div>
|
||||
{status?.expires_at && (
|
||||
<>
|
||||
<div className="text-gray-500">Λήξη άδειας</div>
|
||||
<div className="font-medium text-gray-800">{new Date(status.expires_at).toLocaleDateString('el-GR')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Printers */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div className="px-5 py-4">
|
||||
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
|
||||
</div>
|
||||
|
||||
{(!status?.printers || status.printers.length === 0) && (
|
||||
<p className="px-5 py-6 text-center text-gray-400 text-sm">Δεν βρέθηκαν εκτυπωτές.</p>
|
||||
)}
|
||||
|
||||
{status?.printers?.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-800">{p.name}</p>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${p.reachable ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{p.reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sysadmin-only section */}
|
||||
{user?.role === 'sysadmin' && (
|
||||
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
|
||||
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
|
||||
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => client.post('/api/system/unlock').then(() => { toast.success('Ξεκλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-primary text-sm"
|
||||
>
|
||||
Ξεκλείδωμα
|
||||
</button>
|
||||
<button
|
||||
onClick={() => client.post('/api/system/lock').then(() => { toast.success('Κλειδώθηκε'); qc.invalidateQueries({ queryKey: ['system-status'] }) })}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
Κλείδωμα
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
||||
<div className="flex items-center justify-end">
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +283,7 @@ export default function WaitersPage() {
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
|
||||
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||
</span>
|
||||
<button onClick={() => { 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">Επεξεργασία</button>
|
||||
<button onClick={() => { 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">Επεξεργασία</button>
|
||||
{w.role === 'waiter' && (
|
||||
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
|
||||
)}
|
||||
@@ -279,39 +299,79 @@ export default function WaitersPage() {
|
||||
{/* Add waiter modal */}
|
||||
{addModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
|
||||
|
||||
{/* Avatar picker */}
|
||||
<div className="flex items-center gap-4">
|
||||
{newAvatarPreview ? (
|
||||
<img src={newAvatarPreview} alt="preview" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 28, color: '#9ca3af' }}>👤</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={newAvatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setNewAvatarFile(file)
|
||||
setNewAvatarPreview(URL.createObjectURL(file))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => newAvatarInputRef.current?.click()} type="button" className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8">
|
||||
{newAvatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||
</button>
|
||||
{newAvatarPreview && (
|
||||
<button type="button" onClick={() => { 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">
|
||||
Αφαίρεση
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Πλήρες όνομα</label>
|
||||
<label className="label">Πλήρες όνομα *</label>
|
||||
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη</label>
|
||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
||||
<label className="label">Παρατσούκλι (nickname) *</label>
|
||||
<input className="input" placeholder="π.χ. Γιώργος" value={newForm.nickname} onChange={e => setNewForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Κινητό τηλέφωνο</label>
|
||||
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ρόλος</label>
|
||||
<label className="label">Όνομα χρήστη *</label>
|
||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ρόλος *</label>
|
||||
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
|
||||
<option value="waiter">Σερβιτόρος</option>
|
||||
<option value="manager">Διαχειριστής</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label mb-2">PIN</label>
|
||||
<label className="label mb-2">PIN *</label>
|
||||
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => 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 ? 'Δημιουργία…' : 'Δημιουργία'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,8 +381,8 @@ export default function WaitersPage() {
|
||||
{/* Edit profile modal */}
|
||||
{editModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.username}</h2>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.full_name || editModal.username}</h2>
|
||||
|
||||
{/* Avatar section */}
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -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 ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||
</button>
|
||||
{editModal.avatar_url && (
|
||||
<button
|
||||
@@ -359,29 +419,36 @@ export default function WaitersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη</label>
|
||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
||||
<label className="label">Πλήρες όνομα *</label>
|
||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πλήρες όνομα</label>
|
||||
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Παρατσούκλι (nickname)</label>
|
||||
<label className="label">Παρατσούκλι (nickname) *</label>
|
||||
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Κινητό τηλέφωνο</label>
|
||||
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Όνομα χρήστη *</label>
|
||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ρόλος *</label>
|
||||
<select className="input" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}>
|
||||
<option value="waiter">Σερβιτόρος</option>
|
||||
<option value="manager">Διαχειριστής</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
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 })}
|
||||
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 ? 'Αποθήκευση…' : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
356
manager_dashboard/src/pages/TablesConfigTab.jsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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 => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange(c)}
|
||||
className="w-7 h-7 rounded-full border-2 transition-all"
|
||||
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center justify-end">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer mr-auto">
|
||||
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
|
||||
Εμφάνιση ανενεργών
|
||||
</label>
|
||||
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέα ζώνη</button>
|
||||
<button onClick={() => setAddModal(true)} className="btn btn-primary text-sm">+ Νέο τραπέζι</button>
|
||||
</div>
|
||||
|
||||
{/* Zone tabs */}
|
||||
<div className="flex gap-1 flex-wrap border-b border-gray-200 pb-0">
|
||||
{[
|
||||
{ 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 => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => 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 && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||
{tab.label}
|
||||
<span className="ml-0.5 text-xs text-gray-400">
|
||||
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zone header (when viewing a specific zone) */}
|
||||
{activeTab !== 'all' && activeTab !== 'ungrouped' && (() => {
|
||||
const g = groups.find(g => g.id === activeTab)
|
||||
if (!g) return null
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">{g.name}</span>
|
||||
{g.prefix && <span className="ml-2 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-mono">{g.prefix}</span>}
|
||||
</div>
|
||||
<button onClick={() => setGroupModal(g)} className="text-xs text-gray-400 hover:text-gray-600 underline">Επεξεργασία ζώνης</button>
|
||||
<button onClick={() => setBatchModal(g)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Tables list */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
{visibleTables.length === 0 && (
|
||||
<p className="px-4 py-8 text-sm text-gray-400 text-center">
|
||||
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
|
||||
</p>
|
||||
)}
|
||||
{visibleTables.map((t, idx) => (
|
||||
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-50 bg-gray-50' : ''}`}>
|
||||
<span className="text-xs text-gray-400 font-mono w-6 text-right">{idx + 1}</span>
|
||||
<p className="flex-1 font-medium text-gray-800">{t.label || `Τραπέζι ${t.number}`}</p>
|
||||
{t.group && (
|
||||
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded hidden sm:inline">
|
||||
{t.group.name}
|
||||
</span>
|
||||
)}
|
||||
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
|
||||
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-8">Επεξεργασία</button>
|
||||
{t.is_active
|
||||
? <button
|
||||
onClick={() => !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"
|
||||
>Απενεργ.</button>
|
||||
: <button onClick={() => 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">Ενεργοπ.</button>
|
||||
}
|
||||
<button
|
||||
onClick={() => !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"
|
||||
>Διαγραφή</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add single table */}
|
||||
{addModal && (
|
||||
<TableModal
|
||||
title="Νέο τραπέζι"
|
||||
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
|
||||
groups={groups}
|
||||
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onClose={() => setAddModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit table */}
|
||||
{editModal && (
|
||||
<TableModal
|
||||
title="Επεξεργασία τραπεζιού"
|
||||
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
|
||||
groups={groups}
|
||||
onSave={(f) => 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 && (
|
||||
<BatchModal
|
||||
group={batchModal}
|
||||
onSave={(body) => batchCreate.mutate(body)}
|
||||
onClose={() => setBatchModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Group/Zone form */}
|
||||
{groupModal !== null && (
|
||||
<GroupModal
|
||||
group={groupModal}
|
||||
onSave={(data) => saveGroup.mutate(data)}
|
||||
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
|
||||
onClose={() => setGroupModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
title={confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'}
|
||||
message={confirmDelete.hard
|
||||
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
|
||||
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'}
|
||||
confirmLabel={confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
|
||||
confirmClass="btn-danger"
|
||||
onConfirm={() => deleteTable.mutate(confirmDelete)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableModal({ title, initial, groups, onSave, onClose }) {
|
||||
const [form, setForm] = useState(initial)
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">{title}</h2>
|
||||
<div>
|
||||
<label className="label">Όνομα τραπεζιού</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="π.χ. BS-TBL-1 ή Βεράντα 3"
|
||||
value={form.label}
|
||||
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό για αυτόματη αρίθμηση.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Ζώνη</label>
|
||||
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
|
||||
<option value="">— Χωρίς ζώνη —</option>
|
||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={() => onSave(form)} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchModal({ group, onSave, onClose }) {
|
||||
const [count, setCount] = useState(5)
|
||||
const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '')
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Μαζική προσθήκη τραπεζιών</h2>
|
||||
{group && <p className="text-sm text-gray-500">Ζώνη: <span className="font-medium text-gray-700">{group.name}</span></p>}
|
||||
<div>
|
||||
<label className="label">Πρόθεμα ονόματος</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="π.χ. BS-TBL- → BS-TBL-1, BS-TBL-2…"
|
||||
value={prefix}
|
||||
onChange={e => setPrefix(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πλήθος</label>
|
||||
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => 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})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}</h2>
|
||||
<div>
|
||||
<label className="label">Όνομα ζώνης *</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Πρόθεμα (για μαζική δημιουργία)</label>
|
||||
<input className="input font-mono" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="π.χ. BS" />
|
||||
<p className="text-xs text-gray-400 mt-1">Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Χρώμα ζώνης</label>
|
||||
<ZoneColorPicker value={color} onChange={setColor} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">Διαγραφή</button>}
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button onClick={() => onSave({ name, prefix: prefix || null, color: color || null })} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
94
manager_dashboard/src/store/tableColourStore.js
Normal file
@@ -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
|
||||
3080
sysadmin_panel/package-lock.json
generated
Normal file
16
waiter_pwa/README.md
Normal file
@@ -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.
|
||||
1
waiter_pwa/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
92
waiter_pwa/dev-dist/sw.js
Normal file
@@ -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: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3395
waiter_pwa/dev-dist/workbox-5a5d9309.js
Normal file
29
waiter_pwa/eslint.config.js
Normal file
@@ -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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>waiter_pwa</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 <Navigate to="/login" replace />
|
||||
return children
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="page page--centered" style={{ gap: 12 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36,
|
||||
border: '3px solid var(--border)',
|
||||
borderTopColor: 'var(--accent)',
|
||||
borderRadius: '50%',
|
||||
animation: 'gate-spin 0.7s linear infinite',
|
||||
}} />
|
||||
<span style={{ color: 'var(--muted)', fontSize: 14 }}>Φόρτωση…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rehydrates user object from token on every app load
|
||||
// ─── Gate Screens ─────────────────────────────────────────────────────────────
|
||||
|
||||
function GateCard({ emoji, title, subtitle, children }) {
|
||||
return (
|
||||
<div className="page page--centered" style={{ gap: 24, padding: 32 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 52, marginBottom: 12 }}>{emoji}</div>
|
||||
<p style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 6 }}>{title}</p>
|
||||
{subtitle && <p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.5 }}>{subtitle}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <button style={{ ...base, ...styles[variant] }} onClick={onClick} disabled={disabled}>{children}</button>
|
||||
}
|
||||
|
||||
function ClosedScreen({ onRefresh, onLogout }) {
|
||||
return (
|
||||
<GateCard emoji="🔒" title="Εστιατόριο κλειστό"
|
||||
subtitle={'Δεν υπάρχει ενεργή ημέρα λειτουργίας.\nΖητήστε από τον διαχειριστή να ανοίξει την ημέρα.'}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
|
||||
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
|
||||
</div>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingManagerScreen({ onRefresh, onLogout }) {
|
||||
return (
|
||||
<GateCard emoji="⏳" title="Αναμονή για έναρξη βάρδιας"
|
||||
subtitle="Ζητήστε από τον διαχειριστή να ξεκινήσει τη βάρδια σας.">
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
|
||||
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
|
||||
</div>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<GateCard emoji="👋" title={`Καλώς ήρθες, ${username}!`}
|
||||
subtitle="Θέλεις να ξεκινήσεις τη βάρδια σου;">
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 320,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 16, padding: 20,
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
}}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--muted)', display: 'block', marginBottom: 6 }}>
|
||||
Αρχικά μετρητά (προαιρετικό)
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 700 }}>€</span>
|
||||
<input
|
||||
type="number" step="0.01" min="0" placeholder="0.00"
|
||||
value={startingCash}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{
|
||||
fontSize: 13, color: 'var(--danger)',
|
||||
background: 'var(--danger-dim)', borderRadius: 8, padding: '8px 12px',
|
||||
}}>{error}</p>
|
||||
)}
|
||||
|
||||
<GateBtn onClick={handleStart} disabled={starting}>
|
||||
{starting ? 'Εκκίνηση…' : '▶ Έναρξη Βάρδιας'}
|
||||
</GateBtn>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{ fontSize: 13, color: 'var(--muted)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
onClick={onLogout}
|
||||
>
|
||||
Αποσύνδεση
|
||||
</button>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 <Navigate to="/login" replace />
|
||||
|
||||
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 <Spinner />
|
||||
|
||||
if (gateStatus === 'closed') return <ClosedScreen onRefresh={checkGate} onLogout={handleLogout} />
|
||||
if (gateStatus === 'waiting_manager') return <WaitingManagerScreen onRefresh={checkGate} onLogout={handleLogout} />
|
||||
if (gateStatus === 'needs_start') {
|
||||
return (
|
||||
<StartShiftScreen
|
||||
username={user.username}
|
||||
onStart={handleStartShift}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<BrowserRouter>
|
||||
<ThemeApplier />
|
||||
<ColourLoader />
|
||||
<AuthRehydrator />
|
||||
<OfflineListener />
|
||||
<NotificationProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route path="/tables" element={<ProtectedRoute><TableListPage /></ProtectedRoute>} />
|
||||
<Route path="/tables/:tableId" element={<ProtectedRoute><TableDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/tables/:tableId/add" element={<ProtectedRoute><AddItemsPage /></ProtectedRoute>} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||
</Routes>
|
||||
</NotificationProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
5
waiter_pwa/src/assets/icons/backspace.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="#1C274C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="#1C274C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
7
waiter_pwa/src/assets/icons/categories.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.24 2H5.34C3.15 2 2 3.15 2 5.33V7.23C2 9.41 3.15 10.56 5.33 10.56H7.23C9.41 10.56 10.56 9.41 10.56 7.23V5.33C10.57 3.15 9.42 2 7.24 2Z" fill="currentColor"/>
|
||||
<path opacity="0.4" d="M18.6695 2H16.7695C14.5895 2 13.4395 3.15 13.4395 5.33V7.23C13.4395 9.41 14.5895 10.56 16.7695 10.56H18.6695C20.8495 10.56 21.9995 9.41 21.9995 7.23V5.33C21.9995 3.15 20.8495 2 18.6695 2Z" fill="currentColor"/>
|
||||
<path d="M18.6695 13.4302H16.7695C14.5895 13.4302 13.4395 14.5802 13.4395 16.7602V18.6602C13.4395 20.8402 14.5895 21.9902 16.7695 21.9902H18.6695C20.8495 21.9902 21.9995 20.8402 21.9995 18.6602V16.7602C21.9995 14.5802 20.8495 13.4302 18.6695 13.4302Z" fill="currentColor"/>
|
||||
<path opacity="0.4" d="M7.24 13.4302H5.34C3.15 13.4302 2 14.5802 2 16.7602V18.6602C2 20.8502 3.15 22.0002 5.33 22.0002H7.23C9.41 22.0002 10.56 20.8502 10.56 18.6702V16.7702C10.57 14.5802 9.42 13.4302 7.24 13.4302Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
7
waiter_pwa/src/assets/icons/categories2.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="233px" height="233px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
waiter_pwa/src/assets/icons/flags.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 747 B |
2
waiter_pwa/src/assets/icons/merge.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="currentColor" width="800px" height="800px" viewBox="-4 -2 24 24" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin" class="jam jam-merge-f"><path d='M9.033 5.817v2.028c0 .074-.003.148-.008.221a1 1 0 0 0 .462.637l3.086 1.846a3 3 0 0 1 1.46 2.575v1.059a3.001 3.001 0 1 1-2-.024v-1.035a1 1 0 0 0-.487-.858L8.46 10.42a3 3 0 0 1-.444-.324 3 3 0 0 1-.443.324l-3.086 1.846a1 1 0 0 0-.487.858v1.047a3.001 3.001 0 1 1-2 0v-1.047a3 3 0 0 1 1.46-2.575l3.086-1.846a1 1 0 0 0 .462-.637A3.006 3.006 0 0 1 7 7.845V5.829a3.001 3.001 0 1 1 2.033-.012z' /></svg>
|
||||
|
After Width: | Height: | Size: 689 B |
5
waiter_pwa/src/assets/icons/notifications.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="#292D32"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
waiter_pwa/src/assets/icons/print.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
|
||||
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
|
||||
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
waiter_pwa/src/assets/icons/transfer.svg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
13
waiter_pwa/src/assets/icons/waiter.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 237.888 237.888" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M197.047,59.153C185.153,23.771,153.764,0,118.938,0C82.628,0,50.816,25.12,39.779,62.506
|
||||
c-2.614,8.849-3.94,18.078-3.94,27.434c0,49.588,37.278,89.931,83.1,89.931c45.827,0,83.11-40.343,83.11-89.931
|
||||
C202.049,79.352,200.365,68.991,197.047,59.153z M118.938,159.87c-34.793,0-63.1-31.371-63.1-69.931
|
||||
c0-6.583,0.827-13.078,2.453-19.346h71.861l9.571-20.909l10.073,20.909h29.791c1.626,6.253,2.461,12.736,2.461,19.346
|
||||
C182.049,128.499,153.737,159.87,118.938,159.87z"/>
|
||||
<polygon points="64.61,180.791 64.61,237.888 118.61,221.853 172.61,237.888 172.61,180.791 118.61,196.829 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 939 B |
46
waiter_pwa/src/components/Icons.jsx
Normal file
@@ -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 (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TransferIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 17h13M4 17l4-4M4 17l4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M20 7H7M20 7l-4-4M20 7l-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MergeIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 6H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h3M16 6h3a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-3M12 12v6M12 12l-3-3M12 12l3-3M9 18h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WaiterIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="7" r="3" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M5 21v-1a7 7 0 0 1 14 0v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
|
||||
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
|
||||
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
871
waiter_pwa/src/components/OrderDrawer.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: 40, borderRadius: 20,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => 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)' }}>−</button>
|
||||
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 15, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
|
||||
<button onClick={() => 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)' }}>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckCircle({ selected }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
|
||||
background: selected ? '#f59e0b' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioDot({ selected }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
|
||||
background: 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && <div style={{ width: 10, height: 10, borderRadius: '50%', background: '#f59e0b' }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ selected, onClick, children, right, left, style = {} }) {
|
||||
return (
|
||||
<div onClick={onClick} style={{
|
||||
padding: '12px 14px',
|
||||
background: selected ? 'rgba(245,158,11,0.12)' : 'var(--bg2)',
|
||||
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : 'var(--border)'}`,
|
||||
borderRadius: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
minHeight: 56,
|
||||
...style,
|
||||
}}>
|
||||
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggleSingle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={opt.allow_multiple ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||
{selected
|
||||
? <Stepper value={qty} onChange={v => setQuickState(s => ({ ...s, [opt.id]: v }))} />
|
||||
: <button
|
||||
onClick={e => { 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' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
|
||||
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div>
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||
{opt.allow_multiple && !selected && (
|
||||
<button
|
||||
onClick={e => { 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' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{selected && opt.allow_multiple && (
|
||||
<Stepper value={sel.qty} onChange={v => {
|
||||
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 && (
|
||||
<button onClick={e => { 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' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)} €` : 'Included'}
|
||||
{subLabel && <span style={{ color: '#f59e0b', fontWeight: 600 }}> · {subLabel}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{selected && open && hasSubs && (
|
||||
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '6px 2px 2px' }}>Επιλογή</div>
|
||||
{opt.sub_choices.map((sub, si) => {
|
||||
const isSel = sel.subName === sub.name
|
||||
return (
|
||||
<Row key={si} selected={isSel}
|
||||
onClick={() => setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))}
|
||||
left={<RadioDot selected={isSel} />}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div>}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared: single ingredient row ─────────────────────────────────────────────
|
||||
|
||||
function IngredientRow({ ing, removedState, setRemovedState }) {
|
||||
const removed = !!removedState[ing.id]
|
||||
return (
|
||||
<Row selected={false}
|
||||
onClick={() => setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
|
||||
right={
|
||||
<div style={{
|
||||
height: 34, padding: '0 14px', borderRadius: 17,
|
||||
background: removed ? 'var(--danger)' : 'var(--bg3)',
|
||||
border: `1px solid ${removed ? 'var(--danger)' : 'var(--border)'}`,
|
||||
color: removed ? '#fff' : 'var(--text)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
|
||||
</div>
|
||||
}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: removed ? 'var(--muted)' : 'var(--text)', textDecoration: removed ? 'line-through' : 'none', transition: 'all 120ms' }}>{ing.name}</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, padding: '0 2px 10px' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: complete ? 'var(--text)' : '#ef4444' }}>{ps.name}</div>
|
||||
{!complete && <div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: 0.6 }}>Απαιτείται</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{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 (
|
||||
<div key={ch.id}>
|
||||
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
|
||||
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--muted)' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{ch.name}</div>
|
||||
</Row>
|
||||
|
||||
{isSel && hasSubs && (
|
||||
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: `2px solid ${subMissing ? '#ef4444' : 'rgba(245,158,11,0.3)'}`, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '4px 2px 2px' }}>— απαιτείται επιλογή</p>}
|
||||
{ch.sub_choices.map((sub, si) => {
|
||||
const subSel = subChoices[ch.id]?.name === sub.name
|
||||
return (
|
||||
<Row key={si} selected={subSel} onClick={() => setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={<RadioDot selected={subSel} />}
|
||||
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showShared && (
|
||||
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>{ps.shared_subset.name}</div>
|
||||
{ps.shared_subset.choices.map((sub, si) => {
|
||||
const subSel = sharedSubs[ps.id]?.name === sub.name
|
||||
return (
|
||||
<Row key={si} selected={subSel} onClick={() => setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={<RadioDot selected={subSel} />}
|
||||
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν αγαπημένα για αυτό το προϊόν.</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{favorites.map((fav, fi) => {
|
||||
if (fav.type === 'quick') {
|
||||
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} />
|
||||
}
|
||||
if (fav.type === 'ingredient') {
|
||||
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
}
|
||||
if (fav.type === 'option') {
|
||||
return <ExtraOptionRow key={`opt-${fav.item.id}`} opt={fav.item} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
|
||||
}
|
||||
if (fav.type === 'pref') {
|
||||
return (
|
||||
<PrefSetBlock key={`pref-${fav.item.id}`} ps={fav.item}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Quick Options ────────────────────────────────────────────────────────
|
||||
|
||||
function QuickTab({ product, quickState, setQuickState }) {
|
||||
const quickOptions = product.quick_options || []
|
||||
if (quickOptions.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{quickOptions.map(opt => (
|
||||
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Extras ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
|
||||
const options = product.options || []
|
||||
if (options.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{options.map(opt => (
|
||||
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Υλικά (Ingredients) ─────────────────────────────────────────────────
|
||||
|
||||
function IngredientsTab({ product, removedState, setRemovedState }) {
|
||||
const ingredients = product.ingredients || []
|
||||
if (ingredients.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Πατήστε για να αφαιρέσετε υλικό από το πιάτο.
|
||||
</div>
|
||||
{ingredients.map(ing => (
|
||||
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Προτιμήσεις ─────────────────────────────────────────────────────────
|
||||
|
||||
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
|
||||
const preferenceSets = product.preference_sets || []
|
||||
if (preferenceSets.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{preferenceSets.map(ps => (
|
||||
<PrefSetBlock key={ps.id} ps={ps}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Notes ────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesTab({ note, setNote }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
Οτιδήποτε ειδικό για την κουζίνα.
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: 14, fontSize: 15, fontFamily: 'inherit', color: 'var(--text)', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 20, marginBottom: 8 }}>Γρήγορες σημειώσεις</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{QUICK_NOTES.map(q => (
|
||||
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
|
||||
style={{ height: 36, padding: '0 14px', borderRadius: 18, background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text)', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
|
||||
+ {q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 : (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{title}</div>
|
||||
<button onClick={() => onJumpTab(tab)} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>}
|
||||
</div>
|
||||
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isEmpty ? (
|
||||
<div style={{ padding: '40px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: 14 }}>
|
||||
Δεν έχει γίνει καμία προσαρμογή. Χρησιμοποιήστε τις καρτέλες για να διαμορφώσετε το προϊόν.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Section title="Προτιμήσεις" tab="prefs" lines={byGroup.prefs} />
|
||||
<Section title="Γρήγορες Επιλογές" tab="quick" lines={byGroup.quick} />
|
||||
<Section title="Extras" tab="extras" lines={byGroup.extras} />
|
||||
<Section title="Αφαιρέθηκαν" tab="ingredients" lines={byGroup.removed} />
|
||||
{note && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Σημείωση</div>
|
||||
<button onClick={() => onJumpTab('notes')} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 14px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 10, fontSize: 14, color: 'var(--text)', lineHeight: 1.4, whiteSpace: 'pre-wrap' }}>{note}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 */}
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 260ms ease',
|
||||
zIndex: 40,
|
||||
}} />
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||
height: '92svh',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 320ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 41,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 -8px 40px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Grab handle */}
|
||||
<div style={{ padding: '10px 0 4px', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{product.image_url && (
|
||||
<img src={`${import.meta.env.VITE_API_URL || ''}${product.image_url}`} alt=""
|
||||
style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'cover', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', lineHeight: 1.2 }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>{product.base_price.toFixed(2)} €</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="var(--text)" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs bar */}
|
||||
<div style={{ marginTop: 12, borderBottom: '1px solid var(--border)', overflowX: 'auto', scrollbarWidth: 'none', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', padding: '0 12px', gap: 2, minWidth: 'max-content' }}>
|
||||
{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 (
|
||||
<button key={t.id} onClick={() => 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 && (
|
||||
<span style={{
|
||||
minWidth: 18, height: 18, padding: '0 5px',
|
||||
borderRadius: 9,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 16px 20px', background: 'var(--bg)', WebkitOverflowScrolling: 'touch' }}>
|
||||
{activeTab === 'favorites' && (
|
||||
<FavoritesTab
|
||||
product={product}
|
||||
quickState={quickState} setQuickState={setQuickState}
|
||||
extrasState={extrasState} setExtrasState={setExtrasState}
|
||||
expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra}
|
||||
removedState={removedState} setRemovedState={setRemovedState}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'quick' && <QuickTab product={product} quickState={quickState} setQuickState={setQuickState} />}
|
||||
{activeTab === 'extras' && <ExtrasTab product={product} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />}
|
||||
{activeTab === 'ingredients' && <IngredientsTab product={product} removedState={removedState} setRemovedState={setRemovedState} />}
|
||||
{activeTab === 'prefs' && <PrefsTab product={product} prefs={prefs} setPrefs={setPrefs} subChoices={subChoices} setSubChoices={setSubChoices} sharedSubs={sharedSubs} setSharedSubs={setSharedSubs} />}
|
||||
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
|
||||
{activeTab === 'summary' && <SummaryTab product={product} summaryLines={summaryLines} note={note} onJumpTab={setActiveTab} />}
|
||||
</div>
|
||||
|
||||
{/* Footer: qty stepper + ΠΡΟΣΘΗΚΗ */}
|
||||
<div style={{ padding: '12px 16px 20px', background: 'var(--bg2)', borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, boxShadow: '0 -4px 16px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', height: 52, borderRadius: 26, background: 'var(--bg3)', border: '1px solid var(--border)', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<button onClick={() => 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)' }}>−</button>
|
||||
<div style={{ minWidth: 32, textAlign: 'center', fontSize: 17, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{qty}</div>
|
||||
<button onClick={() => setQty(q => q + 1)} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: 'pointer', color: 'var(--text)' }}>+</button>
|
||||
</div>
|
||||
<button onClick={handleAdd} disabled={!canAdd} style={{
|
||||
flex: 1, height: 52, borderRadius: 26,
|
||||
background: canAdd ? 'var(--accent)' : 'var(--bg3)',
|
||||
border: 'none', color: canAdd ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
fontSize: 16, fontWeight: 700, fontFamily: 'inherit',
|
||||
cursor: canAdd ? 'pointer' : 'not-allowed',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 20px',
|
||||
transition: 'background 150ms ease',
|
||||
}}>
|
||||
<span>ΠΡΟΣΘΗΚΗ</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{totalPrice.toFixed(2)} €</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''}`}
|
||||
onClick={selectable && !isPaid && !isCancelled ? () => 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' }}
|
||||
>
|
||||
<div className="order-item__row">
|
||||
{selectable && !isPaid && !isCancelled && (
|
||||
@@ -26,8 +63,11 @@ function ItemRow({ item, selectable, selected, onToggle }) {
|
||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||
<span className="order-item__qty">×{item.quantity}</span>
|
||||
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
||||
{isPaid && <span className="badge badge--paid">Πληρωμένο</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Ακυρώθηκε</span>}
|
||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||
{!isPaid && !isCancelled && !item.printed && (
|
||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
)}
|
||||
</div>
|
||||
{opts.map((o, i) => <div key={i} className="order-item__modifier">+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}</div>)}
|
||||
{removed.map((r, i) => <div key={i} className="order-item__modifier">- {r}</div>)}
|
||||
@@ -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 (
|
||||
<div className="order-summary">
|
||||
{activeItems.length === 0 && <p style={{ color: '#64748b', textAlign: 'center' }}>Δεν υπάρχουν αντικείμενα</p>}
|
||||
{activeItems.map(item => (
|
||||
{activeItems.map((item, idx) => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectable={selectable}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
onToggle={onToggle}
|
||||
onLongPress={onLongPressItem}
|
||||
isLast={idx === activeItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div className="order-summary__total">
|
||||
<span>Σύνολο</span>
|
||||
<span>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#64748b' }}>
|
||||
<span>Πληρωμένο</span>
|
||||
<span style={{ color: '#22c55e' }}>{fmtPrice(paidTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<span>Εκκρεμεί</span>
|
||||
<span style={{ color: '#f59e0b', fontWeight: 700 }}>{fmtPrice(total - paidTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ export default function PinPad({ onSubmit, loading }) {
|
||||
{[1,2,3,4,5,6,7,8,9].map(d => (
|
||||
<button key={d} onClick={() => press(String(d))} className="pin-btn">{d}</button>
|
||||
))}
|
||||
<button onClick={backspace} className="pin-btn pin-btn--secondary">⌫</button>
|
||||
<button onClick={backspace} className="pin-btn pin-btn--secondary">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="currentColor"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => press('0')} className="pin-btn">0</button>
|
||||
<button onClick={submit} className="pin-btn pin-btn--confirm" disabled={loading || pin.length === 0}>
|
||||
{loading ? '…' : '✓'}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import ItemOptionsModal from './ItemOptionsModal'
|
||||
import OrderDrawer from './OrderDrawer'
|
||||
|
||||
function CategoriesIcon({ width = 20, height = 20 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="product-grid">
|
||||
{products.map(product => {
|
||||
const initials = product.name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
return (
|
||||
<button key={product.id} className="product-btn" onClick={() => onOpen(product)}>
|
||||
<div className="product-btn__thumb">
|
||||
<div className="product-btn__thumb-inner">
|
||||
{product.image_url
|
||||
? <img src={`${API_URL}${product.image_url}`} alt="" className="product-btn__img" />
|
||||
: <span className="product-btn__initials">{initials}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-btn__info">
|
||||
<span className="product-btn__name">{product.name}</span>
|
||||
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} €</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
{/* View All button — always first */}
|
||||
<div className="category-tabs__sticky">
|
||||
<button
|
||||
className="cat-tab cat-tab--viewall"
|
||||
onClick={() => setViewAllOpen(true)}
|
||||
title="Εμφάνιση όλων"
|
||||
>
|
||||
⊞
|
||||
<CategoriesIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{categories.map(cat => {
|
||||
<div className="category-tabs__scroll-wrap">
|
||||
<div className="category-tabs__fade" />
|
||||
<div className="category-tabs__scroll">
|
||||
{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 ? '#1c1400' : 'var(--muted)'
|
||||
: isActive ? 'var(--accent-fg)' : 'var(--muted)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="cat-tab"
|
||||
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
|
||||
onClick={() => setActiveCat(cat.id)}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="product-grid">
|
||||
{filtered.map(product => (
|
||||
<button key={product.id} className="product-btn" onClick={() => setSelectedProduct(product)}>
|
||||
<span className="product-btn__name">{product.name}</span>
|
||||
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} €</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p style={{ color: '#64748b', gridColumn: '1/-1', textAlign: 'center', padding: 32 }}>
|
||||
{/* Product area — flat grid or accordion depending on sub-cats */}
|
||||
<div className="product-area">
|
||||
{!hasSubcats ? (
|
||||
// No sub-categories: original flat grid
|
||||
<>
|
||||
<ProductGrid products={flatProducts} onOpen={openDrawer} />
|
||||
{flatProducts.length === 0 && (
|
||||
<p style={{ color: '#64748b', textAlign: 'center', padding: 32 }}>
|
||||
Δεν υπάρχουν προϊόντα
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Has sub-categories: accordion view
|
||||
<div className="subcat-accordion">
|
||||
{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 (
|
||||
<div key={key} className="subcat-general">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const accentColor = section.color ?? activeParent?.color ?? null
|
||||
|
||||
return (
|
||||
<div key={key} className="subcat-section">
|
||||
<button
|
||||
className={`subcat-header ${isOpen ? 'subcat-header--open' : ''}`}
|
||||
onClick={() => toggleSub(key)}
|
||||
>
|
||||
{accentColor && <span className="subcat-header__pill" style={{ background: accentColor }} />}
|
||||
<span className="subcat-header__name">{section.name}</span>
|
||||
<span className="subcat-header__count">{sectionProducts.length}</span>
|
||||
<svg
|
||||
className="subcat-header__chevron"
|
||||
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="subcat-body">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All modal */}
|
||||
{/* View All modal — top-level categories only */}
|
||||
{viewAllOpen && (
|
||||
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
|
||||
<div
|
||||
@@ -81,7 +237,7 @@ export default function ProductPicker({ categories, products, onAdd }) {
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="cat-all-grid">
|
||||
{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 }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProduct && (
|
||||
<ItemOptionsModal
|
||||
product={selectedProduct}
|
||||
onAdd={onAdd}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
<OrderDrawer
|
||||
product={drawerProduct}
|
||||
isOpen={!!drawerProduct}
|
||||
onClose={closeDrawer}
|
||||
onAdd={item => { onAdd(item); closeDrawer() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button className={cardClass} onClick={onClick}>
|
||||
<span className="table-card__number">{displayName}</span>
|
||||
<span className="table-card__status">{statusLabel}</span>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="table-card-v2"
|
||||
style={{ background: cfg.cardBg }}
|
||||
onClick={handleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Top-left: table name + area */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '65%' }}>
|
||||
<span style={{
|
||||
fontSize: 'clamp(22px, 5.5vw, 36px)',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.05,
|
||||
color: cfg.nameText,
|
||||
letterSpacing: -0.5,
|
||||
}}>
|
||||
{displayName}
|
||||
</span>
|
||||
{groupName && (
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.8,
|
||||
color: cfg.nameText + '80',
|
||||
marginTop: 1,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{groupName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom-left: status badge */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 11, left: 11,
|
||||
background: cfg.badgeBg,
|
||||
borderRadius: 5,
|
||||
padding: '2px 8px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
color: cfg.badgeText,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{STATUS_LABELS[statusKey]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom-right: flag circles, stacked, up to 3 visible */}
|
||||
{flags.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 8, right: 10,
|
||||
display: 'flex', flexDirection: 'column-reverse', gap: 4,
|
||||
}}>
|
||||
{flags.slice(0, 3).map(f => (
|
||||
<div key={f.id} style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'rgba(98,149,243,0.9)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
{f.emoji || '🏷️'}
|
||||
</div>
|
||||
))}
|
||||
{flags.length > 3 && (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'rgba(98,149,243,0.9)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: '#fff',
|
||||
}}>
|
||||
+{flags.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Flag name tooltip on long-press (only when no onLongPress handler) */}
|
||||
{showTip && flags.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
||||
boxShadow: '0 4px 16px var(--shadow)',
|
||||
minWidth: 160,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{flags.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 15 }}>{f.emoji || '🏷️'}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text)' }}>{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
181
waiter_pwa/src/components/UserMenu.jsx
Normal file
@@ -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 (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
title="Μενού χρήστη"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
|
||||
>
|
||||
{/* Break indicator dot */}
|
||||
{activeBreak && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: 'var(--accent)', flexShrink: 0,
|
||||
animation: 'tab-pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{user?.username}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-menu-dropdown">
|
||||
{/* ── Shift info (waiters only) ─────────────────────── */}
|
||||
{isWaiter && shift && (
|
||||
<>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg3)',
|
||||
borderRadius: 10,
|
||||
margin: '4px 8px 2px',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 4 }}>
|
||||
Βάρδια ενεργή
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text)', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Από {formatTime(shift.started_at)}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{formatDuration(shift.started_at)}</span>
|
||||
</div>
|
||||
{shift.starting_cash != null && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Αρχικά: €{shift.starting_cash.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
{activeBreak && (
|
||||
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 4, fontWeight: 600 }}>
|
||||
☕ Σε διάλειμμα από {formatTime(activeBreak.started_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Break button */}
|
||||
<button
|
||||
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
|
||||
onClick={handleBreak}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="user-menu-item__icon">{activeBreak ? '▶' : '☕'}</span>
|
||||
<span>{activeBreak ? 'Τέλος Διαλείμματος' : 'Διάλειμμα'}</span>
|
||||
</button>
|
||||
|
||||
{/* End shift button */}
|
||||
{selfEndAllowed ? (
|
||||
<button
|
||||
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
|
||||
onClick={handleEndShift}
|
||||
disabled={busy}
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<span className="user-menu-item__icon">⏹</span>
|
||||
<span>Τέλος Βάρδιας</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ padding: '8px 16px', fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
Ζητήστε από τον διαχειριστή να κλείσει τη βάρδια
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Theme toggle ──────────────────────────────────── */}
|
||||
<button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}>
|
||||
<span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span>
|
||||
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
|
||||
</button>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
|
||||
<span className="user-menu-item__icon">⏏</span>
|
||||
<span>Αποσύνδεση</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
waiter_pwa/src/context/NotificationContext.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
background: '#1e1b4b', border: '1px solid #6366f1',
|
||||
borderRadius: 14, padding: '12px 14px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
animation: 'slideIn 0.25s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: 22, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{message.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{message.sender_name}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
|
||||
{message.body}
|
||||
</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
|
||||
Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => 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 ✓</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent }}>
|
||||
{children}
|
||||
|
||||
{/* Floating banner stack (max 3 visible) */}
|
||||
{pendingMessages.length > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
|
||||
padding: '0 12px',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
|
||||
{pendingMessages.slice(0, 3).map(msg => (
|
||||
<div key={msg.id} style={{ pointerEvents: 'all' }}>
|
||||
<NotificationBanner message={msg} onAck={ackMessage} />
|
||||
</div>
|
||||
))}
|
||||
{pendingMessages.length > 3 && (
|
||||
<div style={{
|
||||
textAlign: 'center', fontSize: 12, color: '#94a3b8',
|
||||
pointerEvents: 'all',
|
||||
}}>
|
||||
+{pendingMessages.length - 3} ακόμα μηνύματα
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
/* "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-dim: #78350f;
|
||||
--accent-fg: #1c1000;
|
||||
--accent-dim: #6b3a00;
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--danger-dim: #7f1d1d;
|
||||
--border: #334155;
|
||||
--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,11 +135,11 @@ 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: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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}>←</button>
|
||||
<span className="top-bar__title">Αποτέλεσμα εκτύπωσης</span>
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`, { replace: true })}>←</button>
|
||||
<span className="top-bar__title">Πρόβλημα εκτύπωσης</span>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: 20 }}>
|
||||
<div style={{
|
||||
background: '#7f1d1d', borderRadius: 14, padding: '16px 18px',
|
||||
border: '1px solid #ef4444',
|
||||
}}>
|
||||
<p style={{ fontWeight: 700, fontSize: 16, color: '#fca5a5', marginBottom: 8 }}>
|
||||
⚠ Πρόβλημα εκτύπωσης
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: '#fca5a5' }}>
|
||||
Η παραγγελία αποθηκεύτηκε αλλά ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.
|
||||
Τα αντικείμενα παραμένουν ως "σχέδιο" — δεν έχουν σταλεί στην κουζίνα/μπαρ.
|
||||
</p>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, padding: 20, overflowY: 'auto' }}>
|
||||
<div style={{ background: '#7f1d1d', borderRadius: 14, padding: '14px 16px', border: '1px solid #ef4444' }}>
|
||||
<p style={{ fontWeight: 700, fontSize: 15, color: '#fca5a5', marginBottom: 6 }}>⚠ Η παραγγελία αποθηκεύτηκε</p>
|
||||
<p style={{ fontSize: 13, color: '#fca5a5', lineHeight: 1.5 }}>Ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.</p>
|
||||
</div>
|
||||
|
||||
{printAck.results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
background: r.success ? '#14532d' : '#7f1d1d',
|
||||
border: `1px solid ${r.success ? '#22c55e' : '#ef4444'}`,
|
||||
borderRadius: 12, padding: '12px 16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22 }}>{r.success ? '✓' : '✗'}</span>
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, background: r.success ? '#14532d' : '#431407', border: `1px solid ${r.success ? '#22c55e' : '#c2410c'}`, borderRadius: 12, padding: '10px 14px' }}>
|
||||
<span style={{ fontSize: 20 }}>{r.success ? '✓' : '✗'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontWeight: 600, fontSize: 15, color: r.success ? '#86efac' : '#fca5a5' }}>
|
||||
{r.printer_name}
|
||||
</p>
|
||||
{r.error && (
|
||||
<p style={{ fontSize: 12, color: '#fca5a5', marginTop: 2 }}>{r.error}</p>
|
||||
)}
|
||||
<p style={{ fontWeight: 600, fontSize: 14, color: r.success ? '#86efac' : '#fdba74' }}>{r.printer_name}</p>
|
||||
{!r.success && <p style={{ fontSize: 12, color: '#fdba74', marginTop: 2 }}>Εκτυπωτής μη προσβάσιμος</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => navigate(`/tables/${tableId}`)}
|
||||
>
|
||||
Επιστροφή στο τραπέζι
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={async () => {
|
||||
setPrintAck(null)
|
||||
setCart([])
|
||||
navigate(`/tables/${tableId}`)
|
||||
}}
|
||||
>
|
||||
Εντάξει, συνέχεια
|
||||
</button>
|
||||
<p style={{ fontSize: 12, color: '#64748b', textAlign: 'center', margin: '4px 0' }}>Επιλέξτε πώς να συνεχίσετε:</p>
|
||||
<button className="btn btn--primary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, opacity: retrying ? 0.7 : 1 }} onClick={retryNow} disabled={retrying}>
|
||||
<span style={{ fontSize: 22 }}>🔄</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>{retrying ? 'Επανάληψη…' : 'Επανάληψη τώρα'}</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.8, margin: 0, marginTop: 2 }}>Δοκιμή αποστολής στον εκτυπωτή ξανά</p>
|
||||
</div>
|
||||
</button>
|
||||
<button className="btn btn--secondary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }} onClick={saveAsDraft}>
|
||||
<span style={{ fontSize: 22 }}>📋</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Αποθήκευση ως προσχέδιο</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα μένουν στο τραπέζι με πορτοκαλί ένδειξη</p>
|
||||
</div>
|
||||
</button>
|
||||
<button style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, background: '#1e293b', border: '1px solid #334155', borderRadius: 12, color: '#cbd5e1', cursor: 'pointer' }} onClick={leaveAndContinue}>
|
||||
<span style={{ fontSize: 22 }}>🕐</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Συνέχεια (προσχέδιο)</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα εμφανίζονται ως εκκρεμή στο dashboard</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="page">
|
||||
<div className="page" style={{ position: 'relative' }}>
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`)}>←</button>
|
||||
<span className="top-bar__title">Προσθήκη</span>
|
||||
<button className="icon-btn" onClick={handleBack}>←</button>
|
||||
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
||||
{/* Cart icon with badge — opens side drawer */}
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={() => document.getElementById('cart-panel').scrollIntoView({ behavior: 'smooth' })}
|
||||
onClick={() => setCartOpen(true)}
|
||||
>
|
||||
🛒
|
||||
{cart.length > 0 && <span className="cart-badge">{cart.length}</span>}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
{cart.length > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
minWidth: 18, height: 18, borderRadius: 9,
|
||||
background: 'var(--accent)', color: 'var(--accent-fg)',
|
||||
fontSize: 11, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
}}>{cart.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Product picker takes all remaining space */}
|
||||
{categories.length > 0 && (
|
||||
<ProductPicker categories={categories} products={products} onAdd={addToCart} />
|
||||
)}
|
||||
|
||||
<div id="cart-panel" className="cart-panel">
|
||||
<h3 className="cart-panel__title">Σταδιακά ({cart.length})</h3>
|
||||
|
||||
{cart.length === 0 && (
|
||||
<p style={{ color: '#64748b', textAlign: 'center' }}>Προσθέστε αντικείμενα</p>
|
||||
)}
|
||||
|
||||
{cart.map(item => (
|
||||
<div key={item._key} className="cart-row">
|
||||
<span>{getProductName(item.product_id)} ×{item.quantity}</span>
|
||||
<button className="icon-btn icon-btn--danger" onClick={() => removeFromCart(item._key)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
{/* Success flash when all printers OK */}
|
||||
{printAck?.allOk && (
|
||||
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
|
||||
<div style={{
|
||||
background: '#14532d', border: '1px solid #22c55e',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
color: '#86efac', fontWeight: 600, fontSize: 14, textAlign: 'center',
|
||||
background: 'var(--bg2)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '10px 12px 14px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
|
||||
{/* Floating compact cart — shown only when there are items */}
|
||||
{cart.length > 0 && (
|
||||
<div
|
||||
onClick={() => 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 (
|
||||
<div key={item._key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{p?.name ?? `#${item.product_id}`}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
|
||||
+{hiddenCount} ακόμα — δείτε όλα →
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full-width send button */}
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : 'Αποστολή Παραγγελίας'}
|
||||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||||
</button>
|
||||
|
||||
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
|
||||
{printAck?.allOk && (
|
||||
<div style={{ marginTop: 8, background: '#14532d', border: '1px solid #22c55e', borderRadius: 10, padding: '8px 14px', color: '#86efac', fontWeight: 600, fontSize: 13, textAlign: 'center' }}>
|
||||
✓ Εκτυπώθηκε επιτυχώς — μεταφορά…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={() => 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 */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0,
|
||||
width: 'min(88vw, 380px)',
|
||||
background: 'var(--bg)',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
transform: cartOpen ? 'translateX(0)' : 'translateX(100%)',
|
||||
transition: 'transform 280ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 51,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '-8px 0 32px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Παραγγελία</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'}</div>
|
||||
</div>
|
||||
<button onClick={() => 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)' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{cart.length === 0 ? (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '40px 0', fontSize: 14 }}>Η παραγγελία είναι κενή.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{cart.map(item => {
|
||||
const product = getProduct(item.product_id)
|
||||
const summaryLines = buildItemSummary(item)
|
||||
const sections = buildItemSections(item, product)
|
||||
return (
|
||||
<CartItem
|
||||
key={item._key}
|
||||
item={item}
|
||||
product={product}
|
||||
summaryLines={summaryLines}
|
||||
sections={sections}
|
||||
onEdit={() => openEditDrawer(item)}
|
||||
onRemove={() => removeFromCart(item._key)}
|
||||
onChangeQty={qty => changeCartQty(item._key, qty)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer footer */}
|
||||
<div style={{ padding: '12px 12px 20px', borderTop: '1px solid var(--border)', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%' }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Edit drawer */}
|
||||
{editItem && (
|
||||
<OrderDrawer
|
||||
product={editItem.product}
|
||||
isOpen={!!editItem}
|
||||
onClose={() => setEditItem(null)}
|
||||
onAdd={handleEditSave}
|
||||
initialState={editItem.drawerState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
|
||||
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
|
||||
}
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||||
}
|
||||
|
||||
function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onChangeQty }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const hasDetails = sections.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{/* Whole header row is always clickable to expand (qty stepper is always available) */}
|
||||
<div
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Chevron — always shown */}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms', flexShrink: 0, color: 'var(--muted)' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product?.name ?? `#${item.product_id}`}
|
||||
</div>
|
||||
{!expanded && hasDetails && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{summaryLines[0]}{summaryLines.length > 1 ? ` +${summaryLines.length - 1}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity on the right */}
|
||||
<span style={{ color: '#f59e0b', fontSize: 13, fontWeight: 700, fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||||
|
||||
{/* Edit — stop propagation so it doesn't toggle expand */}
|
||||
<button onClick={e => { 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 }}>
|
||||
Επεξ.
|
||||
</button>
|
||||
{/* Remove */}
|
||||
<button onClick={e => { e.stopPropagation(); onRemove() }} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ paddingBottom: 10 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
{/* Divider between sections */}
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type={line._sub ? 'quick' : sec.type} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1 }}>
|
||||
{sec.type === 'note' ? line.name : (
|
||||
<>
|
||||
{line.name}
|
||||
{line._qty > 1 && (
|
||||
<span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>
|
||||
)}
|
||||
{line.price_delta !== 0 && line.price_delta != null && (
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 4 }}>
|
||||
({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} €)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Quick qty row ── */}
|
||||
<div style={{ margin: '8px 12px 0', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
|
||||
padding: '10px 12px 2px',
|
||||
}}>
|
||||
<button
|
||||
onClick={e => { 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 ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
) : '−'}
|
||||
</button>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', minWidth: 28, textAlign: 'center' }}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { 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',
|
||||
}}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 16px',
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
position: 'relative',
|
||||
transition: 'border-color 0.15s',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: '50%', flexShrink: 0,
|
||||
background: waiter.avatar_url ? 'transparent' : 'var(--bg3)',
|
||||
border: `2px solid ${waiter.on_shift ? '#22c55e' : 'var(--border)'}`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{waiter.avatar_url
|
||||
? <img src={`${API_URL}${waiter.avatar_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: initials
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Name block */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', wordBreak: 'break-word', whiteSpace: 'normal', lineHeight: 1.3 }}>
|
||||
{waiter.full_name || waiter.nickname || '—'}
|
||||
</div>
|
||||
{waiter.nickname && waiter.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{waiter.nickname}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* On-shift dot */}
|
||||
{waiter.on_shift && (
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 6px #22c55e88',
|
||||
}} title="Σε βάρδια" />
|
||||
)}
|
||||
|
||||
<span style={{ color: 'var(--muted)', fontSize: 18, flexShrink: 0 }}>›</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="page">
|
||||
{/* Static header — never scrolls */}
|
||||
<div style={{ flexShrink: 0, padding: '40px 16px 20px', textAlign: 'center' }}>
|
||||
<h1 className="app-title" style={{ marginBottom: 10 }}>TableServe</h1>
|
||||
<p className="app-subtitle">Ποιος είσαι;</p>
|
||||
</div>
|
||||
|
||||
{/* Scrollable card list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 16px 40px' }}>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||
{loadingWaiters ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση…</p>
|
||||
) : waiters.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'stretch' }}>
|
||||
{sorted.map(w => (
|
||||
<WaiterCard key={w.id} waiter={w} onClick={() => { setError(''); setSelectedWaiter(w) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── PIN screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
const initials = (selectedWaiter.full_name || selectedWaiter.nickname || '?')
|
||||
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="page page--centered">
|
||||
<div className="login-box">
|
||||
<h1 className="app-title">TableServe</h1>
|
||||
<p className="app-subtitle">Σύστημα Παραγγελιών</p>
|
||||
|
||||
{savedUsername ? (
|
||||
<p className="login-greeting">Καλωσόρισες, <strong>{savedUsername}</strong></p>
|
||||
) : (
|
||||
<input
|
||||
className="text-input"
|
||||
placeholder="Όνομα χρήστη"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* Selected waiter mini-card */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: '10px 14px', marginBottom: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||
background: selectedWaiter.avatar_url ? 'transparent' : 'var(--bg3)',
|
||||
border: `2px solid ${selectedWaiter.on_shift ? '#22c55e' : 'var(--border)'}`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 15, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{selectedWaiter.avatar_url
|
||||
? <img src={`${API_URL}${selectedWaiter.avatar_url}`} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: initials
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>
|
||||
{selectedWaiter.full_name || selectedWaiter.nickname}
|
||||
</div>
|
||||
{selectedWaiter.nickname && selectedWaiter.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{selectedWaiter.nickname}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelectedWaiter(null); setError('') }}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--muted)', cursor: 'pointer', fontSize: 13, padding: '4px 8px' }}
|
||||
>
|
||||
Αλλαγή
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, marginBottom: 12 }}>Εισάγετε PIN</p>
|
||||
|
||||
<PinPad onSubmit={handlePin} loading={loading} />
|
||||
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
|
||||
{savedUsername && (
|
||||
<button className="link-btn" onClick={switchUser}>
|
||||
Δεν είσαι εσύ;
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title" style={{ marginBottom: 16 }}>Ειδοποιήσεις</h2>
|
||||
{messages.length === 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 24 }}>Δεν υπάρχουν ειδοποιήσεις</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto', flex: 1 }}>
|
||||
{messages.map(msg => {
|
||||
const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })()
|
||||
return (
|
||||
<div key={msg.id} style={{
|
||||
padding: '12px 4px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||||
opacity: msg._acked ? 0.5 : 1,
|
||||
}}>
|
||||
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{msg.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>
|
||||
{msg.sender_name}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>Τραπέζι: {tableIds.join(', ')}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
{/* Status overview card */}
|
||||
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: '16px 16px 0 0',
|
||||
padding: '16px 20px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div className="modal-handle" style={{ marginBottom: 12 }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{order ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Σύνολο</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Πληρωμένο</span>
|
||||
<span style={{ fontWeight: 600, color: '#22c55e' }}>{fmtPrice(paid)}</span>
|
||||
</div>
|
||||
{due > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Υπόλοιπο</span>
|
||||
<span style={{ fontWeight: 700, color: '#f59e0b' }}>{fmtPrice(due)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
|
||||
)}
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{flags.map(f => (
|
||||
<div key={f.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: (f.color || '#6295F3') + '22',
|
||||
border: `1px solid ${f.color || '#6295F3'}`,
|
||||
borderRadius: 20, padding: '4px 10px',
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>{f.emoji || '🏷️'}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: f.color || '#6295F3' }}>{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
style={{ width: '100%', marginTop: 14 }}
|
||||
onClick={() => { onClose(); onNavigate() }}
|
||||
>
|
||||
Άνοιγμα τραπεζιού
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick actions card */}
|
||||
<div style={{
|
||||
background: 'var(--bg2)', borderRadius: '0 0 16px 16px',
|
||||
padding: '8px 20px 24px',
|
||||
borderTop: '2px solid var(--border)',
|
||||
}}>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>
|
||||
ACTIONS
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{QUICK_ACTIONS.map((a, i) => {
|
||||
const disabled = !order && a.key !== 'flags'
|
||||
return (
|
||||
<button
|
||||
key={a.key}
|
||||
disabled={disabled}
|
||||
onClick={() => { 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',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
|
||||
background: a.iconBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: a.color,
|
||||
}}>
|
||||
<a.Icon width="18" height="18" />
|
||||
</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
|
||||
{!disabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<span className="top-bar__title">Τραπέζια</span>
|
||||
<span className="top-bar__user">{user?.username}</span>
|
||||
<button className="icon-btn" onClick={handleLogout} title="Αποσύνδεση">⏏</button>
|
||||
|
||||
<button
|
||||
onClick={() => { 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',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{(unreadCount || 0) > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{offline && <ConnectionBanner />}
|
||||
@@ -95,7 +328,6 @@ export default function TableListPage() {
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Zone filter */}
|
||||
<div ref={zoneRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className={`filter-tab ${zoneActive ? 'filter-tab--active' : ''}`}
|
||||
@@ -106,16 +338,16 @@ export default function TableListPage() {
|
||||
{zoneOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '110%', right: 0, zIndex: 100,
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', minWidth: 180, padding: 8,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12,
|
||||
boxShadow: '0 4px 16px var(--shadow)', minWidth: 180, padding: 8,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => 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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
|
||||
<div className="table-grid">
|
||||
{filtered.map(t => (
|
||||
{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 (
|
||||
<TableCard
|
||||
key={t.id}
|
||||
table={t}
|
||||
order={getOrder(t.id)}
|
||||
currentUserId={user?.id}
|
||||
onClick={() => navigate(`/tables/${t.id}`)}
|
||||
order={order}
|
||||
isMine={isMyOrder(order)}
|
||||
flags={tableFlags}
|
||||
groupName={grp?.name || ''}
|
||||
onClick={() => navigate(destination)}
|
||||
onLongPress={() => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="fab" onClick={load} title="Ανανέωση">↺</button>
|
||||
</div>
|
||||
|
||||
{showNotifs && (
|
||||
<NotificationDrawer
|
||||
messages={recentMessages || []}
|
||||
onClose={() => setShowNotifs(false)}
|
||||
onAck={ackMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{quickModal && (
|
||||
<TableQuickModal
|
||||
table={quickModal.table}
|
||||
order={quickModal.order}
|
||||
flags={quickModal.flags}
|
||||
onClose={() => setQuickModal(null)}
|
||||
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
|
||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
waiter_pwa/src/store/shiftStore.js
Normal file
@@ -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
|
||||
90
waiter_pwa/src/store/tableColourStore.js
Normal file
@@ -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
|
||||
12
waiter_pwa/src/store/themeStore.js
Normal file
@@ -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
|
||||