Compare commits
9 Commits
603fd45eaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d2dd9f1c6 | |||
| b311f4c5b6 | |||
| a3aa71348e | |||
| c9ad78ec71 | |||
| 8e27b7666e | |||
| 1fd7d16ec9 | |||
| 240abb2e73 | |||
| bb39088464 | |||
| defc49f84f |
1
CLAUDE_DESIGN/.design-canvas.state.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}},"desktop":{"labels":{"desktop-main":"1440×900 — full operational view, mid-shift"}}}}
|
||||||
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>
|
||||||
39
CLAUDE_DESIGN/Table Grid Densities.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Table Grid Densities — 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;800&family=Geist+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: #f4f4f2;
|
||||||
|
color: #111315;
|
||||||
|
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: #dfe2e6; 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="ios-frame.jsx"></script>
|
||||||
|
<script type="text/babel" src="tables-data.jsx"></script>
|
||||||
|
<script type="text/babel" src="table-cards-densities.jsx"></script>
|
||||||
|
<script type="text/babel" src="tables-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,
|
||||||
|
});
|
||||||
789
CLAUDE_DESIGN/design-canvas.jsx
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
|
||||||
|
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||||
|
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||||
|
// Artboards are reorderable (grip-drag), deletable, 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}',
|
||||||
|
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||||
|
// right. Single flex row; when the artboard's on-screen width is too
|
||||||
|
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||||
|
// ~4ch via the container query) and the buttons stay on the row.
|
||||||
|
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||||
|
' display:flex;align-items:center;container-type:inline-size}',
|
||||||
|
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||||
|
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||||
|
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||||
|
'.dc-grip:active{cursor:grabbing}',
|
||||||
|
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||||
|
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||||
|
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||||
|
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||||
|
// until the card is moused.
|
||||||
|
'@container (max-width: 110px){',
|
||||||
|
' .dc-labeltext{display:none}',
|
||||||
|
' .dc-grip{opacity:0}',
|
||||||
|
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||||
|
'}',
|
||||||
|
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||||
|
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||||
|
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||||
|
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||||
|
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}',
|
||||||
|
'.dc-expand,.dc-delete{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;',
|
||||||
|
' font:inherit;transition:background .12s,color .12s}',
|
||||||
|
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||||
|
'.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}',
|
||||||
|
'.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;',
|
||||||
|
' font-size:12px;font-weight:500}',
|
||||||
|
'.dc-delete.dc-confirm:hover{background:#b5563a}',
|
||||||
|
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||||
|
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||||
|
// DCViewport on every transform update and inherits to all descendants —
|
||||||
|
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||||
|
// it the same way.
|
||||||
|
//
|
||||||
|
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||||
|
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||||
|
// after counter-scaling its on-screen width exactly matches the card's —
|
||||||
|
// that's what lets the container query + text-overflow behave against the
|
||||||
|
// card's visible edge at every zoom level.
|
||||||
|
//
|
||||||
|
// The section head uses CSS zoom instead of transform so its layout box
|
||||||
|
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||||
|
// constant-screen-size title would overflow into the (shrinking) world-
|
||||||
|
// space gap and overlap the artboard headers at low zoom.
|
||||||
|
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||||
|
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||||
|
'.dc-sectionhead{zoom:var(--dc-inv-zoom,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, hidden
|
||||||
|
// artboards, focused artboard). Order/titles/labels/hidden 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 abs = [];
|
||||||
|
React.Children.forEach(sec.props.children, (ab) => {
|
||||||
|
if (!ab || ab.type !== DCArtboard) return;
|
||||||
|
const aid = ab.props.id ?? ab.props.label;
|
||||||
|
if (aid) abs.push([aid, ab]);
|
||||||
|
});
|
||||||
|
// hidden is scoped to one source revision — when the agent regenerates
|
||||||
|
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||||
|
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||||
|
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||||
|
const srcIds = [];
|
||||||
|
abs.forEach(([aid, ab]) => {
|
||||||
|
if (hidden.includes(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 });
|
||||||
|
// Persist viewport across reloads so the user lands back where they were
|
||||||
|
// after an agent edit or browser refresh. The sandbox origin is already
|
||||||
|
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||||
|
const tfKey = 'dc-viewport:' + location.pathname;
|
||||||
|
const saveT = React.useRef(0);
|
||||||
|
|
||||||
|
const lastPostedScale = React.useRef();
|
||||||
|
const apply = React.useCallback(() => {
|
||||||
|
const { x, y, scale } = tf.current;
|
||||||
|
const el = worldRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||||
|
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||||
|
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||||
|
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||||
|
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||||
|
if (lastPostedScale.current !== scale) {
|
||||||
|
lastPostedScale.current = scale;
|
||||||
|
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||||
|
}
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
saveT.current = setTimeout(() => {
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
}, 200);
|
||||||
|
}, [tfKey]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const flush = () => {
|
||||||
|
clearTimeout(saveT.current);
|
||||||
|
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||||
|
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||||
|
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||||
|
// window doesn't drop the last pan/zoom.
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||||
|
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||||
|
const onHostMsg = (e) => {
|
||||||
|
const d = e.data;
|
||||||
|
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||||
|
} else if (d && d.type === '__dc_probe') {
|
||||||
|
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||||
|
// fires on the iframe's native 'load', which for canvases with
|
||||||
|
// images/fonts is after our mount-time announce, so re-announce.
|
||||||
|
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||||
|
// even if it's unchanged — the host just reset dcScale to 1.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onHostMsg);
|
||||||
|
// Announce canvas mode so the host toolbar proxies its % control here
|
||||||
|
// instead of scaling the iframe element (which would just shrink the
|
||||||
|
// viewport window of an infinite canvas). The apply() that follows emits
|
||||||
|
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||||
|
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||||
|
// effect's restore-path apply() may already have posted the restored
|
||||||
|
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||||
|
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||||
|
lastPostedScale.current = undefined;
|
||||||
|
apply();
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
window.removeEventListener('message', onHostMsg);
|
||||||
|
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 sec = (ctx && sid && ctx.section(sid)) || {};
|
||||||
|
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||||
|
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||||
|
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||||
|
const srcKey = allIds.join('\x1f');
|
||||||
|
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||||
|
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||||
|
|
||||||
|
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]));
|
||||||
|
|
||||||
|
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||||
|
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||||
|
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||||
|
// belonging to the section above. paddingBottom below is just enough for
|
||||||
|
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||||
|
// the title sits tight against its own row at every zoom.
|
||||||
|
return (
|
||||||
|
<div data-dc-section={sid}
|
||||||
|
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||||
|
<div style={{ padding: '0 60px' }}>
|
||||||
|
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||||
|
<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>
|
||||||
|
<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 })}
|
||||||
|
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||||
|
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||||
|
srcKey,
|
||||||
|
}))}
|
||||||
|
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, onDelete }) {
|
||||||
|
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||||
|
const id = rawId ?? rawLabel;
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
const delRef = React.useRef(null);
|
||||||
|
const [confirming, setConfirming] = React.useState(false);
|
||||||
|
|
||||||
|
// Two-click delete: first click arms the button (turns into an inline
|
||||||
|
// "Delete?" pill), second click commits. Any pointerdown outside the
|
||||||
|
// button disarms.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!confirming) return;
|
||||||
|
const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); };
|
||||||
|
document.addEventListener('pointerdown', off, true);
|
||||||
|
return () => document.removeEventListener('pointerdown', off, true);
|
||||||
|
}, [confirming]);
|
||||||
|
|
||||||
|
// 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-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dc-labelrow">
|
||||||
|
<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>
|
||||||
|
<div className="dc-btns">
|
||||||
|
<button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
|
||||||
|
onClick={() => { if (confirming) onDelete(); else setConfirming(true); }}
|
||||||
|
title={confirming ? 'Click again to delete' : 'Delete'}>
|
||||||
|
{confirming
|
||||||
|
? <>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6"/></svg>
|
||||||
|
Delete?
|
||||||
|
</>
|
||||||
|
: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6M5 5.5v3M7 5.5v3"/></svg>}
|
||||||
|
</button>
|
||||||
|
<button className="dc-expand" onClick={onFocus} 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>
|
||||||
|
</div>
|
||||||
|
<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) => {
|
||||||
|
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||||
|
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||||
|
const n = sectionOrder.length;
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||||
|
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||||
|
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.filter((sid) => sectionMeta[sid].slotIds.length).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 });
|
||||||
375
CLAUDE_DESIGN/table-cards-densities.jsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
// Table cards at 5 densities. All share the same data model — each card type
|
||||||
|
// just renders a subset, sized for fast reading at-a-glance.
|
||||||
|
|
||||||
|
const { TABLE_STATUS, TABLE_BADGES } = window;
|
||||||
|
|
||||||
|
// ---------- shared bits ----------------------------------------------------
|
||||||
|
function fmtAmount(n) {
|
||||||
|
if (n == null || n === 0) return '0.00';
|
||||||
|
return n.toFixed(2);
|
||||||
|
}
|
||||||
|
// Splits "12.34" into ["12", ".34"] so we can typeset cents smaller
|
||||||
|
function splitAmount(n) {
|
||||||
|
const s = fmtAmount(n);
|
||||||
|
const [whole, cents] = s.split('.');
|
||||||
|
return [whole, '.' + cents];
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarHash(name) {
|
||||||
|
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a'];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
|
return palette[h % palette.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaiterDot({ name, size = 22, ring }) {
|
||||||
|
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%',
|
||||||
|
background: avatarHash(name),
|
||||||
|
color: 'white', fontSize: size * 0.42, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
|
||||||
|
}}>{initials}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedAvatars({ waiters, size = 22, ring }) {
|
||||||
|
if (!waiters?.length) return null;
|
||||||
|
if (waiters.length >= 3) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||||
|
{waiters.slice(0, 2).map((w, i) => (
|
||||||
|
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.35 }}>
|
||||||
|
<WaiterDot name={w} size={size} ring={ring} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{
|
||||||
|
marginLeft: -size * 0.35,
|
||||||
|
height: size, padding: '0 8px',
|
||||||
|
borderRadius: size,
|
||||||
|
background: ring || 'rgba(255,255,255,0.9)',
|
||||||
|
color: '#1a1a1f', fontSize: 11, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
|
||||||
|
}}>+{waiters.length - 2}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
{waiters.map((w, i) => (
|
||||||
|
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.3 }}>
|
||||||
|
<WaiterDot name={w} size={size} ring={ring} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status, size = 'md' }) {
|
||||||
|
const s = TABLE_STATUS[status];
|
||||||
|
const sizes = {
|
||||||
|
sm: { h: 18, px: 7, fs: 10 },
|
||||||
|
md: { h: 22, px: 9, fs: 11 },
|
||||||
|
lg: { h: 26, px: 11, fs: 12 },
|
||||||
|
};
|
||||||
|
const z = sizes[size];
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', height: z.h, padding: `0 ${z.px}px`,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: s.pillBg, color: s.pillFg,
|
||||||
|
fontSize: z.fs, fontWeight: 800,
|
||||||
|
letterSpacing: 0.5, textTransform: 'uppercase',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>{s.label}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeChip({ kind, size = 'md' }) {
|
||||||
|
const b = TABLE_BADGES[kind];
|
||||||
|
if (!b) return null;
|
||||||
|
const sizes = {
|
||||||
|
sm: { h: 20, fs: 11, ic: 12 },
|
||||||
|
md: { h: 24, fs: 12, ic: 14 },
|
||||||
|
lg: { h: 28, fs: 13, ic: 16 },
|
||||||
|
};
|
||||||
|
const z = sizes[size];
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
height: z.h, padding: '0 8px',
|
||||||
|
borderRadius: z.h / 2,
|
||||||
|
background: 'rgba(255,255,255,0.95)',
|
||||||
|
color: b.tone,
|
||||||
|
fontSize: z.fs, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: z.ic, lineHeight: 1 }}>{b.icon}</span>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeDot({ kind, size = 16 }) {
|
||||||
|
const b = TABLE_BADGES[kind];
|
||||||
|
if (!b) return null;
|
||||||
|
return (
|
||||||
|
<div title={b.label} style={{
|
||||||
|
width: size, height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.95)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.65,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>{b.icon}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Amount({ value, size = 22, color }) {
|
||||||
|
const [w, c] = splitAmount(value);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: color || 'inherit',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: size }}>{w}</span>
|
||||||
|
<span style={{ fontSize: size * 0.55, opacity: 0.85 }}>{c}€</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- card shell -----------------------------------------------------
|
||||||
|
// All densities share this shell — just different content + dimensions.
|
||||||
|
function CardShell({ status, w, h, children, padding }) {
|
||||||
|
const s = TABLE_STATUS[status];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: w, height: h,
|
||||||
|
background: s.bg, color: s.fg,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: padding,
|
||||||
|
boxShadow: '0 1px 2px rgba(16,20,24,0.05)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 100ms ease',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 1×1 — tiniest. Just NAME. Status is purely the card color.
|
||||||
|
// ===========================================================================
|
||||||
|
function Card1x1({ table, w, h }) {
|
||||||
|
const t = table;
|
||||||
|
// Show one badge dot if present (very subtle, top-right)
|
||||||
|
const badge = t.badges[0];
|
||||||
|
return (
|
||||||
|
<CardShell status={t.status} w={w} h={h} padding={10}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 800, fontSize: 26,
|
||||||
|
letterSpacing: -1,
|
||||||
|
}}>{t.name}</div>
|
||||||
|
{badge && (
|
||||||
|
<div style={{ position: 'absolute', top: 6, right: 6 }}>
|
||||||
|
<BadgeDot kind={badge} size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 2×1 — wider. NAME + status PILL + maybe one badge dot.
|
||||||
|
// ===========================================================================
|
||||||
|
function Card2x1({ table, w, h }) {
|
||||||
|
const t = table;
|
||||||
|
return (
|
||||||
|
<CardShell status={t.status} w={w} h={h} padding={12}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: '100%', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 800, fontSize: 26,
|
||||||
|
letterSpacing: -1, lineHeight: 1,
|
||||||
|
}}>{t.name}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||||||
|
<StatusPill status={t.status} size="sm" />
|
||||||
|
{t.badges.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={14} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 2×2 — square. NAME big + status pill + amount + waiter dots + badges
|
||||||
|
// ===========================================================================
|
||||||
|
function Card2x2({ table, w, h }) {
|
||||||
|
const t = table;
|
||||||
|
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||||||
|
return (
|
||||||
|
<CardShell status={t.status} w={w} h={h} padding={12}>
|
||||||
|
<div style={{ display: 'flex', height: '100%', gap: 8 }}>
|
||||||
|
{/* left column: name + pill (top), amount (bottom) */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 800, fontSize: 30,
|
||||||
|
letterSpacing: -1, lineHeight: 1,
|
||||||
|
}}>{t.name}</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<StatusPill status={t.status} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 'auto', minHeight: 24 }}>
|
||||||
|
{showAmount && <Amount value={t.amount} size={22} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* right column: badges stacked vertically, bottom-aligned */}
|
||||||
|
{t.badges.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column-reverse',
|
||||||
|
gap: 4, alignItems: 'flex-end',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
{t.badges.slice(0, 3).map(b => <BadgeDot key={b} kind={b} size={20} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 4×1 — wide horizontal. NAME · AMOUNT · status pill + waiter dots
|
||||||
|
// ===========================================================================
|
||||||
|
function Card4x1({ table, w, h }) {
|
||||||
|
const t = table;
|
||||||
|
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||||||
|
return (
|
||||||
|
<CardShell status={t.status} w={w} h={h} padding={14}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', height: '100%', gap: 14 }}>
|
||||||
|
{/* name */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 800, fontSize: 30,
|
||||||
|
letterSpacing: -1, lineHeight: 1,
|
||||||
|
minWidth: 70,
|
||||||
|
}}>{t.name}</div>
|
||||||
|
|
||||||
|
{/* amount (or spacer) */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{showAmount && <Amount value={t.amount} size={22} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* badges */}
|
||||||
|
{t.badges.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={20} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* status pill */}
|
||||||
|
<StatusPill status={t.status} size="md" />
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 4×2 — full detail. Name + section + status pill + amount + badges + waiters with names
|
||||||
|
// ===========================================================================
|
||||||
|
function Card4x2({ table, w, h }) {
|
||||||
|
const t = table;
|
||||||
|
const s = TABLE_STATUS[t.status];
|
||||||
|
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
|
||||||
|
// First waiter name (or "Multiple")
|
||||||
|
const waiterCaption = t.waiters.length === 0
|
||||||
|
? 'Unassigned'
|
||||||
|
: t.waiters.length >= 3
|
||||||
|
? `${t.waiters.length} waiters`
|
||||||
|
: t.waiters.map(w => w.split(' ')[0]).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell status={t.status} w={w} h={h} padding={16}>
|
||||||
|
{/* top row: name + section + status pill | amount */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'Geist Mono', monospace",
|
||||||
|
fontWeight: 800, fontSize: 38,
|
||||||
|
letterSpacing: -1.5, lineHeight: 1,
|
||||||
|
}}>{t.name}</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
opacity: 0.7,
|
||||||
|
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||||
|
marginTop: 4,
|
||||||
|
}}>{t.section}</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<StatusPill status={t.status} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||||
|
{showAmount && <Amount value={t.amount} size={38} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* badges block — right-aligned, up to 4 in 2×2 grid, sits above waiter line */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex', justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 10,
|
||||||
|
minHeight: 24,
|
||||||
|
}}>
|
||||||
|
{t.badges.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, max-content)',
|
||||||
|
gridAutoRows: 'min-content',
|
||||||
|
gap: 6,
|
||||||
|
justifyItems: 'end',
|
||||||
|
direction: 'rtl', // fill right column first, then wrap left
|
||||||
|
}}>
|
||||||
|
{t.badges.slice(0, 4).map(b => (
|
||||||
|
<div key={b} style={{ direction: 'ltr' }}>
|
||||||
|
<BadgeChip kind={b} size="sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* bottom: waiters with names */}
|
||||||
|
<div style={{
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.18)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
{t.waiters.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 13, opacity: 0.7, fontWeight: 500 }}>Unassigned</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StackedAvatars waiters={t.waiters} size={26} ring={s.bg} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{waiterCaption}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.TableCards = { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 };
|
||||||
167
CLAUDE_DESIGN/tables-app.jsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// Wrapping screens — phone frame with the grid at each density
|
||||||
|
|
||||||
|
const { IOSDevice } = window;
|
||||||
|
const { TABLES } = window;
|
||||||
|
const { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 } = window.TableCards;
|
||||||
|
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||||
|
|
||||||
|
// Density specs — each one has a column count, gap, and a card renderer.
|
||||||
|
// "1x1" means 4 columns of tiny squares; "4x2" means 1 large card per row.
|
||||||
|
//
|
||||||
|
// The naming reflects relative density: 1x1 = highest density (smallest cards),
|
||||||
|
// 4x2 = lowest density (biggest, most info).
|
||||||
|
const DENSITIES = {
|
||||||
|
'1x1': {
|
||||||
|
label: '1×1 — Highest density',
|
||||||
|
desc: 'Just the name. Status as color.',
|
||||||
|
cols: 4, gap: 8,
|
||||||
|
aspectW: 1, aspectH: 1,
|
||||||
|
Card: Card1x1,
|
||||||
|
},
|
||||||
|
'2x1': {
|
||||||
|
label: '2×1 — Compact',
|
||||||
|
desc: 'Name + status pill.',
|
||||||
|
cols: 2, gap: 10,
|
||||||
|
aspectW: 2, aspectH: 1,
|
||||||
|
Card: Card2x1,
|
||||||
|
},
|
||||||
|
'2x2': {
|
||||||
|
label: '2×2 — Balanced',
|
||||||
|
desc: 'Name, status, amount, waiters.',
|
||||||
|
cols: 2, gap: 12,
|
||||||
|
aspectW: 1, aspectH: 1,
|
||||||
|
Card: Card2x2,
|
||||||
|
},
|
||||||
|
'4x1': {
|
||||||
|
label: '4×1 — Wide row',
|
||||||
|
desc: 'Name, amount, status, waiters.',
|
||||||
|
cols: 1, gap: 10,
|
||||||
|
aspectW: 4, aspectH: 1,
|
||||||
|
Card: Card4x1,
|
||||||
|
},
|
||||||
|
'4x2': {
|
||||||
|
label: '4×2 — Full detail',
|
||||||
|
desc: 'Everything. Section, badges, waiter names.',
|
||||||
|
cols: 1, gap: 12,
|
||||||
|
aspectW: 2, aspectH: 1,
|
||||||
|
Card: Card4x2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top filter bar
|
||||||
|
function FilterBar() {
|
||||||
|
const filters = [
|
||||||
|
{ label: 'All', active: true },
|
||||||
|
{ label: 'Mine' },
|
||||||
|
{ label: 'Free' },
|
||||||
|
{ label: 'Zone (2)' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 16px 14px',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex', gap: 8,
|
||||||
|
borderBottom: '1px solid #edeff1',
|
||||||
|
}}>
|
||||||
|
{filters.map(f => (
|
||||||
|
<button key={f.label} style={{
|
||||||
|
height: 38, padding: '0 16px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: f.active ? '#f5b740' : 'white',
|
||||||
|
border: '1.5px solid ' + (f.active ? '#f5b740' : '#dfe2e6'),
|
||||||
|
color: f.active ? '#3a2a05' : '#5a6169',
|
||||||
|
fontSize: 14, fontWeight: 700,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>{f.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ density }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '54px 16px 10px',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, fontSize: 20, fontWeight: 700, color: '#111315' }}>Tables</div>
|
||||||
|
<button style={{
|
||||||
|
width: 38, height: 38,
|
||||||
|
borderRadius: 19, border: '1px solid #dfe2e6', background: 'white',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="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" stroke="#2b2f33" strokeWidth="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#2b2f33' }}>
|
||||||
|
dimitris
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="#5a6169" strokeWidth="2"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DensityScreen({ densityKey }) {
|
||||||
|
const d = DENSITIES[densityKey];
|
||||||
|
// Compute card width: phone interior is ~370px wide, padding 12px each side
|
||||||
|
const padding = 12;
|
||||||
|
const innerW = 370 - padding * 2;
|
||||||
|
const cardW = (innerW - d.gap * (d.cols - 1)) / d.cols;
|
||||||
|
const cardH = cardW * (d.aspectH / d.aspectW);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
background: '#f4f4f2',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<Header density={d.label} />
|
||||||
|
<FilterBar />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: padding }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${d.cols}, 1fr)`,
|
||||||
|
gap: d.gap,
|
||||||
|
}}>
|
||||||
|
{TABLES.map(t => (
|
||||||
|
<d.Card key={t.name} table={t} w={cardW} h={cardH} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const order = ['1x1', '2x1', '2x2', '4x1', '4x2'];
|
||||||
|
return (
|
||||||
|
<DesignCanvas title="Table grid — 5 density options">
|
||||||
|
<DCSection id="densities" title="Density variants — selectable in user settings">
|
||||||
|
{order.map(k => {
|
||||||
|
const d = DENSITIES[k];
|
||||||
|
return (
|
||||||
|
<DCArtboard key={k} id={k} label={d.label + ' — ' + d.desc} width={460} height={920}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
background: 'transparent',
|
||||||
|
}}>
|
||||||
|
<IOSDevice>
|
||||||
|
<DensityScreen densityKey={k} />
|
||||||
|
</IOSDevice>
|
||||||
|
</div>
|
||||||
|
</DCArtboard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DCSection>
|
||||||
|
</DesignCanvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(<App />);
|
||||||
47
CLAUDE_DESIGN/tables-data.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Table grid data + status palette
|
||||||
|
|
||||||
|
// Statuses — bold colors, high contrast for fast reading
|
||||||
|
const TABLE_STATUS = {
|
||||||
|
free: { label: 'Free', bg: '#e9ebee', fg: '#3a3f45', pillBg: '#d3d6db', pillFg: '#3a3f45' },
|
||||||
|
open: { label: 'Open', bg: '#f5b740', fg: '#3a2a05', pillBg: '#3a2a05', pillFg: '#ffe7b2' },
|
||||||
|
partial: { label: 'Partial', bg: '#3b86e6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
|
||||||
|
paid: { label: 'Paid', bg: '#3aa961', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
|
||||||
|
reserved: { label: 'Reserved', bg: '#8b5cd6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
|
||||||
|
attention: { label: 'Needs you', bg: '#e64545', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.3)', pillFg: '#ffffff' },
|
||||||
|
mine: { label: 'Mine', bg: '#1f1f24', fg: '#ffffff', pillBg: '#f5b740', pillFg: '#3a2a05' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Badge dictionary — icon + tone for each flag
|
||||||
|
const TABLE_BADGES = {
|
||||||
|
cleaning: { label: 'Cleaning', icon: '🧹', tone: '#8a6d2b' },
|
||||||
|
waiter: { label: 'Waiter', icon: '🔔', tone: '#d94b26' },
|
||||||
|
vip: { label: 'VIP', icon: '⭐', tone: '#a76b00' },
|
||||||
|
allergy: { label: 'Allergy', icon: '⚠', tone: '#a5361b' },
|
||||||
|
birthday: { label: 'Birthday', icon: '🎂', tone: '#a8276b' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 24 demo tables across statuses + sections
|
||||||
|
const TABLES = [
|
||||||
|
{ name: 'A-1', section: 'Terrace', status: 'open', amount: 84.50, waiters: ['Marco Riva'], badges: [] },
|
||||||
|
{ name: 'A-2', section: 'Terrace', status: 'mine', amount: 127.20, waiters: ['You'], badges: ['vip'] },
|
||||||
|
{ name: 'A-3', section: 'Terrace', status: 'free', amount: 0, waiters: [], badges: [] },
|
||||||
|
{ name: 'A-4', section: 'Terrace', status: 'attention', amount: 56.00, waiters: ['Luca'], badges: ['waiter'] },
|
||||||
|
{ name: 'A-5', section: 'Terrace', status: 'reserved', amount: 0, waiters: ['Elena'], badges: ['birthday'] },
|
||||||
|
{ name: 'A-6', section: 'Terrace', status: 'paid', amount: 0, waiters: ['Marco Riva'], badges: [] },
|
||||||
|
|
||||||
|
{ name: 'B-1', section: 'Hall', status: 'partial', amount: 38.00, waiters: ['Sofia'], badges: [] },
|
||||||
|
{ name: 'B-2', section: 'Hall', status: 'open', amount: 212.80, waiters: ['Marco', 'Sofia', 'Luca', 'Elena'], badges: ['vip', 'allergy', 'birthday', 'waiter'] },
|
||||||
|
{ name: 'B-3', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: ['cleaning'] },
|
||||||
|
{ name: 'B-4', section: 'Hall', status: 'mine', amount: 16.30, waiters: ['You', 'Billy'], badges: [] },
|
||||||
|
{ name: 'B-5', section: 'Hall', status: 'open', amount: 72.80, waiters: ['Sofia'], badges: ['allergy'] },
|
||||||
|
{ name: 'B-6', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: [] },
|
||||||
|
|
||||||
|
{ name: 'C-1', section: 'Bar', status: 'partial', amount: 24.50, waiters: ['Elena'], badges: [] },
|
||||||
|
{ name: 'C-2', section: 'Bar', status: 'free', amount: 0, waiters: [], badges: [] },
|
||||||
|
{ name: 'C-3', section: 'Bar', status: 'paid', amount: 0, waiters: ['Luca'], badges: [] },
|
||||||
|
{ name: 'C-4', section: 'Bar', status: 'reserved', amount: 0, waiters: ['Sofia'], badges: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
window.TABLE_STATUS = TABLE_STATUS;
|
||||||
|
window.TABLE_BADGES = TABLE_BADGES;
|
||||||
|
window.TABLES = TABLES;
|
||||||
BIN
CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
85
PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Printer Beep Strategy
|
||||||
|
|
||||||
|
## How beeping works on the Jolimark TP850UE
|
||||||
|
|
||||||
|
The printer has a built-in buzzer. Three commands are available:
|
||||||
|
|
||||||
|
| Command | Bytes | Parameters | Notes |
|
||||||
|
|---------|-------|------------|-------|
|
||||||
|
| `BEL` | `0x07` | none | Single short beep, ~50ms. Simplest. |
|
||||||
|
| `ESC BEL n1 n2 n3` | `0x1B 0x07 n1 n2 n3` | n1=on-time (×100ms), n2=off-time (×100ms), n3=count | Full control over length, gap, repetitions. |
|
||||||
|
| `GS BEL n1 n2 n3` | `0x1D 0x07 n1 n2 n3` | n1=count, n2=on-time (×100ms), n3=off-time (×100ms) | Same as ESC BEL but parameter order differs. |
|
||||||
|
|
||||||
|
**Confirmed working on our test:** `ESC BEL` with `n1=2, n2=2, n3=1` = one 200ms beep.
|
||||||
|
Pattern beeps also work: `ESC BEL 1 1 3` = three short beeps in quick succession.
|
||||||
|
|
||||||
|
The beep is triggered by sending these bytes **immediately before or after** the print job —
|
||||||
|
it does not need to be part of a complete print page. You can send a beep-only job
|
||||||
|
(connect, send beep bytes, close) without printing anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to add beeps in the system
|
||||||
|
|
||||||
|
### 1. New kitchen ticket arrives (MOST IMPORTANT)
|
||||||
|
**Where:** `printer_service.py` → `_print_kitchen_ticket()`, just before or after the cut command.
|
||||||
|
**Pattern:** 2 short beeps — signals a new order without being annoying.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 1, 2])) # 2× 100ms beeps
|
||||||
|
p.cut()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Re-print of an existing ticket
|
||||||
|
**Where:** Same function, but only 1 beep to distinguish from a new order.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 2, 1])) # 1× 100ms beep
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Urgent / rush order (future feature)
|
||||||
|
**Where:** If we add an "urgent" flag to orders, trigger a longer or triple beep.
|
||||||
|
```python
|
||||||
|
p._raw(bytes([0x1b, 0x07, 3, 1, 3])) # 3× 300ms beeps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test print
|
||||||
|
**Where:** `send_test_print()` — already sends a test page, add 1 beep so the cook knows
|
||||||
|
to look at the printer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation plan (when ready)
|
||||||
|
|
||||||
|
1. **Add a per-printer setting:** `print.beep_on_ticket` = `true`/`false`
|
||||||
|
(some stations may not want beeping, e.g. a bar printer near customers)
|
||||||
|
|
||||||
|
2. **Add a beep pattern setting:** `print.beep_pattern` = `single` / `double` / `triple`
|
||||||
|
|
||||||
|
3. **In `_print_kitchen_ticket`:** After building the ticket, before `p.cut()`:
|
||||||
|
```python
|
||||||
|
if beep_enabled:
|
||||||
|
p._raw(beep_bytes_for_pattern)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **No separate beep job needed** — bake it into the ticket job. The buzzer fires
|
||||||
|
as the paper is cutting, which is the natural attention signal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings keys to add (future)
|
||||||
|
|
||||||
|
```
|
||||||
|
print.beep_on_ticket "true" / "false" default: "true"
|
||||||
|
print.beep_pattern "single" / "double" / "triple" default: "double"
|
||||||
|
```
|
||||||
|
|
||||||
|
These can go in the Printer management UI (per-printer toggle) and in the
|
||||||
|
Print Settings tab (global default pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Beep bytes are sent over the same TCP socket as print data — no separate connection needed.
|
||||||
|
- The buzzer is hardware-limited; very short intervals (< 50ms) may be ignored.
|
||||||
|
- Beeping does NOT require paper to be loaded or printing to succeed — it fires independently.
|
||||||
|
- If spoof-printing mode is ON, the beep should also be suppressed (no real connection is made).
|
||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
cloud_backend:
|
cloud_backend:
|
||||||
build: ./cloud_backend
|
build: ./cloud_backend
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8011:8001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./cloud_backend/.env
|
- ./cloud_backend/.env
|
||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./local_backend
|
build: ./local_backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8010:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./local_backend/.env
|
- ./local_backend/.env
|
||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./waiter_pwa:/app
|
- ./waiter_pwa:/app
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5183:5173"
|
||||||
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./waiter_pwa/.env
|
- ./waiter_pwa/.env
|
||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./manager_dashboard:/app
|
- ./manager_dashboard:/app
|
||||||
ports:
|
ports:
|
||||||
- "5174:5174"
|
- "5184:5174"
|
||||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./manager_dashboard/.env
|
- ./manager_dashboard/.env
|
||||||
@@ -59,7 +59,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./sysadmin_panel:/app
|
- ./sysadmin_panel:/app
|
||||||
ports:
|
ports:
|
||||||
- "5175:5175"
|
- "5185:5175"
|
||||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
env_file:
|
env_file:
|
||||||
- ./sysadmin_panel/.env
|
- ./sysadmin_panel/.env
|
||||||
|
|||||||
@@ -14,8 +14,19 @@ import models.table # noqa: F401
|
|||||||
import models.printer # noqa: F401
|
import models.printer # noqa: F401
|
||||||
import models.product # noqa: F401
|
import models.product # noqa: F401
|
||||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
||||||
|
import models.business_day # noqa: F401
|
||||||
|
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
|
||||||
|
import models.settings # noqa: F401
|
||||||
|
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
||||||
|
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||||
|
|
||||||
from routers import auth, tables, products, orders, waiters, reports, system
|
from routers import auth, tables, products, orders, waiters, reports, system
|
||||||
|
from routers import business_day as business_day_router
|
||||||
|
from routers import shifts as shifts_router
|
||||||
|
from routers import settings as settings_router
|
||||||
|
from routers import flags as flags_router
|
||||||
|
from routers import messages as messages_router
|
||||||
|
from routers import sse as sse_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -64,6 +75,17 @@ def _run_migrations():
|
|||||||
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
||||||
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
||||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
|
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
|
||||||
|
# Quick options (flat, allow_multiple)
|
||||||
|
"""CREATE TABLE IF NOT EXISTS product_quick_options (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
price REAL NOT NULL DEFAULT 0.0,
|
||||||
|
allow_multiple INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)""",
|
||||||
|
# allow_multiple flag on extras (product_options)
|
||||||
|
"ALTER TABLE product_options ADD COLUMN allow_multiple INTEGER NOT NULL DEFAULT 0",
|
||||||
# Discounts table (future-proofed, schema ready now)
|
# Discounts table (future-proofed, schema ready now)
|
||||||
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -75,6 +97,109 @@ def _run_migrations():
|
|||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
reason TEXT
|
reason TEXT
|
||||||
)""",
|
)""",
|
||||||
|
# Business day scoping on orders
|
||||||
|
"ALTER TABLE orders ADD COLUMN business_day_id INTEGER REFERENCES business_days(id)",
|
||||||
|
# Shift attribution on paid items
|
||||||
|
"ALTER TABLE order_items ADD COLUMN paid_in_shift_id INTEGER REFERENCES waiter_shifts(id)",
|
||||||
|
# Seed default POS settings (INSERT OR IGNORE = no-op if already exists)
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
|
||||||
|
# Table flags
|
||||||
|
"""CREATE TABLE IF NOT EXISTS table_flag_defs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
emoji VARCHAR,
|
||||||
|
color VARCHAR DEFAULT '#6b7280',
|
||||||
|
text_color VARCHAR DEFAULT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Migration: add text_color if upgrading from older schema
|
||||||
|
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
table_id INTEGER NOT NULL REFERENCES tables(id),
|
||||||
|
flag_id INTEGER NOT NULL REFERENCES table_flag_defs(id),
|
||||||
|
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
assigned_by INTEGER REFERENCES users(id)
|
||||||
|
)""",
|
||||||
|
# Staff messaging
|
||||||
|
"""CREATE TABLE IF NOT EXISTS quick_message_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body VARCHAR NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS staff_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
target_waiter_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
table_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS staff_message_acks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id INTEGER NOT NULL REFERENCES staff_messages(id),
|
||||||
|
waiter_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
acked_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)""",
|
||||||
|
# Seed default flag definitions
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '⭐', '#8b5cf6', 5)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
|
||||||
|
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
|
||||||
|
# Seed default quick message templates
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
|
||||||
|
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
|
||||||
|
# Product lifecycle status (active / archived)
|
||||||
|
"ALTER TABLE products ADD COLUMN lifecycle_status VARCHAR NOT NULL DEFAULT 'active'",
|
||||||
|
# Favorite flags + ordering on all product sub-item types
|
||||||
|
"ALTER TABLE product_quick_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_quick_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_ingredients ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_ingredients ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_preference_sets ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE product_preference_sets ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Sub-category support
|
||||||
|
"ALTER TABLE categories ADD COLUMN parent_id INTEGER REFERENCES categories(id)",
|
||||||
|
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Auto-expand flag for sub-categories on the PWA accordion
|
||||||
|
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Printer protocol field
|
||||||
|
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
|
||||||
|
# Compact (half-width) display flag for quick options
|
||||||
|
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
|
||||||
|
# Print layout + per-type font settings
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
|
||||||
|
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
|
||||||
|
# Offline/emergency payment tracking
|
||||||
|
"ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR",
|
||||||
|
"ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR",
|
||||||
|
"ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0",
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -87,6 +212,9 @@ def _run_migrations():
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
import asyncio
|
||||||
|
from services.sse_bus import init_loop
|
||||||
|
init_loop(asyncio.get_running_loop())
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_run_migrations()
|
_run_migrations()
|
||||||
sync_task = await start_cloud_sync()
|
sync_task = await start_cloud_sync()
|
||||||
@@ -121,3 +249,9 @@ app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
|
|||||||
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
|
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
|
||||||
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
||||||
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
app.include_router(system.router, prefix="/api/system", tags=["system"])
|
||||||
|
app.include_router(business_day_router.router, prefix="/api/business-day", tags=["business-day"])
|
||||||
|
app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"])
|
||||||
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||||
|
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
|
||||||
|
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
||||||
|
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||||
|
|||||||
24
local_backend/models/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDay(Base):
|
||||||
|
__tablename__ = "business_days"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
status = Column(String, default="open", nullable=False) # 'open' | 'closed'
|
||||||
|
opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
opener = relationship("User", foreign_keys=[opened_by_id])
|
||||||
|
closer = relationship("User", foreign_keys=[closed_by_id])
|
||||||
|
shifts = relationship("WaiterShift", back_populates="business_day")
|
||||||
38
local_backend/models/flag.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class TableFlagDef(Base):
|
||||||
|
"""Manager-configurable flag definitions (name, emoji, color)."""
|
||||||
|
__tablename__ = "table_flag_defs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
emoji = Column(String, nullable=True)
|
||||||
|
color = Column(String, nullable=True, default="#6b7280") # hex background
|
||||||
|
text_color = Column(String, nullable=True, default=None) # hex text; None = white
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TableFlagAssignment(Base):
|
||||||
|
"""Active flag on a specific table."""
|
||||||
|
__tablename__ = "table_flag_assignments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||||
|
flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False)
|
||||||
|
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
flag_def = relationship("TableFlagDef", back_populates="assignments")
|
||||||
|
assigned_by_user = relationship("User")
|
||||||
48
local_backend/models/message.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class QuickMessageTemplate(Base):
|
||||||
|
"""Manager-configurable quick message templates."""
|
||||||
|
__tablename__ = "quick_message_templates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
body = Column(String, nullable=False)
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessage(Base):
|
||||||
|
"""A message sent from a manager to one or more waiters."""
|
||||||
|
__tablename__ = "staff_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
|
||||||
|
target_waiter_ids = Column(Text, nullable=False, default="[]")
|
||||||
|
table_ids = Column(Text, nullable=False, default="[]")
|
||||||
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
sender = relationship("User", foreign_keys=[sender_id])
|
||||||
|
acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessageAck(Base):
|
||||||
|
"""Acknowledgement by a specific waiter for a specific message."""
|
||||||
|
__tablename__ = "staff_message_acks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False)
|
||||||
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
acked_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
message = relationship("StaffMessage", back_populates="acks")
|
||||||
|
waiter = relationship("User")
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Order(Base):
|
class Order(Base):
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||||
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
opened_at = Column(DateTime, default=datetime.utcnow)
|
opened_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||||
closed_at = Column(DateTime, nullable=True)
|
closed_at = Column(DateTime, nullable=True)
|
||||||
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||||
|
|
||||||
table = relationship("Table", back_populates="orders")
|
table = relationship("Table", back_populates="orders")
|
||||||
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||||
@@ -32,7 +37,7 @@ class OrderWaiter(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="waiters")
|
order = relationship("Order", back_populates="waiters")
|
||||||
waiter = relationship("User", back_populates="order_assignments")
|
waiter = relationship("User", back_populates="order_assignments")
|
||||||
@@ -51,13 +56,14 @@ class OrderItem(Base):
|
|||||||
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||||
added_at = Column(DateTime, default=datetime.utcnow)
|
added_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
printed = Column(Boolean, default=False, nullable=False)
|
printed = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Payment tracking
|
# Payment tracking
|
||||||
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
paid_at = Column(DateTime, nullable=True)
|
paid_at = Column(DateTime, nullable=True)
|
||||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||||
|
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="items")
|
order = relationship("Order", back_populates="items")
|
||||||
product = relationship("Product", back_populates="order_items")
|
product = relationship("Product", back_populates="order_items")
|
||||||
@@ -71,7 +77,7 @@ class PrintLog(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||||
printed_at = Column(DateTime, default=datetime.utcnow)
|
printed_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||||
success = Column(Boolean, nullable=False)
|
success = Column(Boolean, nullable=False)
|
||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
@@ -87,13 +93,17 @@ class OrderAuditLog(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
event_type = Column(String, nullable=False)
|
event_type = Column(String, nullable=False)
|
||||||
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
||||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED)
|
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
|
||||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||||
payment_method = Column(String, nullable=True)
|
payment_method = Column(String, nullable=True)
|
||||||
note = Column(Text, nullable=True)
|
note = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
# Emergency offline payment fields
|
||||||
|
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
|
||||||
|
offline_at = Column(String, nullable=True) # ISO timestamp from client
|
||||||
|
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
|
||||||
|
|
||||||
order = relationship("Order", back_populates="audit_logs")
|
order = relationship("Order", back_populates="audit_logs")
|
||||||
waiter = relationship("User")
|
waiter = relationship("User")
|
||||||
@@ -113,7 +123,7 @@ class OrderDiscount(Base):
|
|||||||
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
||||||
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
||||||
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
applied_at = Column(DateTime, default=datetime.utcnow)
|
applied_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
reason = Column(Text, nullable=True)
|
reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
order = relationship("Order", back_populates="discounts")
|
order = relationship("Order", back_populates="discounts")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Printer(Base):
|
|||||||
ip_address = Column(String, nullable=False)
|
ip_address = Column(String, nullable=False)
|
||||||
port = Column(Integer, default=9100, nullable=False)
|
port = Column(Integer, default=9100, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||||
|
|
||||||
products = relationship("Product", back_populates="printer_zone")
|
products = relationship("Product", back_populates="printer_zone")
|
||||||
print_logs = relationship("PrintLog", back_populates="printer")
|
print_logs = relationship("PrintLog", back_populates="printer")
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ class Category(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
color = Column(String, nullable=True)
|
color = Column(String, nullable=True)
|
||||||
sort_order = Column(Integer, default=0)
|
sort_order = Column(Integer, default=0)
|
||||||
|
# self-referential: null = top-level, non-null = sub-category of parent
|
||||||
|
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
|
# position of the "General" group (direct products) among sub-categories
|
||||||
|
general_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
# sub-categories only: if True, the accordion section is expanded by default on the PWA
|
||||||
|
auto_expanded = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
products = relationship("Product", back_populates="category")
|
products = relationship("Product", back_populates="category")
|
||||||
|
subcategories = relationship("Category", back_populates="parent", foreign_keys="Category.parent_id")
|
||||||
|
parent = relationship("Category", back_populates="subcategories", remote_side="Category.id", foreign_keys="Category.parent_id")
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
@@ -22,12 +30,15 @@ class Product(Base):
|
|||||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
base_price = Column(Float, nullable=False)
|
base_price = Column(Float, nullable=False)
|
||||||
is_available = Column(Boolean, default=True, nullable=False)
|
is_available = Column(Boolean, default=True, nullable=False)
|
||||||
|
# "active" | "archived" — archived products are kept for order history but hidden from active use
|
||||||
|
lifecycle_status = Column(String, default="active", nullable=False)
|
||||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||||
image_url = Column(String, nullable=True)
|
image_url = Column(String, nullable=True)
|
||||||
sort_order = Column(Integer, default=0, nullable=False)
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
category = relationship("Category", back_populates="products")
|
category = relationship("Category", back_populates="products")
|
||||||
printer_zone = relationship("Printer", back_populates="products")
|
printer_zone = relationship("Printer", back_populates="products")
|
||||||
|
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||||
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
|
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
|
||||||
@@ -41,12 +52,31 @@ class ProductOption(Base):
|
|||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
extra_cost = Column(Float, default=0.0)
|
||||||
|
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||||
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||||
sub_choices = Column(Text, nullable=True)
|
sub_choices = Column(Text, nullable=True)
|
||||||
|
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||||
|
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
product = relationship("Product", back_populates="options")
|
product = relationship("Product", back_populates="options")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuickOption(Base):
|
||||||
|
__tablename__ = "product_quick_options"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
price = Column(Float, default=0.0, nullable=False)
|
||||||
|
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||||
|
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
is_compact = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
product = relationship("Product", back_populates="quick_options")
|
||||||
|
|
||||||
|
|
||||||
class ProductIngredient(Base):
|
class ProductIngredient(Base):
|
||||||
__tablename__ = "product_ingredients"
|
__tablename__ = "product_ingredients"
|
||||||
|
|
||||||
@@ -54,6 +84,8 @@ class ProductIngredient(Base):
|
|||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
extra_cost = Column(Float, default=0.0)
|
extra_cost = Column(Float, default=0.0)
|
||||||
|
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||||
|
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
product = relationship("Product", back_populates="ingredients")
|
product = relationship("Product", back_populates="ingredients")
|
||||||
|
|
||||||
@@ -68,6 +100,8 @@ class ProductPreferenceSet(Base):
|
|||||||
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
||||||
# Shared sub-set shown for all choices that don't have disables_subset=True
|
# Shared sub-set shown for all choices that don't have disables_subset=True
|
||||||
shared_subset = Column(Text, nullable=True)
|
shared_subset = Column(Text, nullable=True)
|
||||||
|
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||||
|
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
product = relationship("Product", back_populates="preference_sets")
|
product = relationship("Product", back_populates="preference_sets")
|
||||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||||
|
|||||||
19
local_backend/models/settings.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class PosSettings(Base):
|
||||||
|
__tablename__ = "pos_settings"
|
||||||
|
|
||||||
|
key = Column(String, primary_key=True)
|
||||||
|
value = Column(String, nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
||||||
|
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
updated_by = relationship("User", foreign_keys=[updated_by_id])
|
||||||
36
local_backend/models/shift.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class WaiterShift(Base):
|
||||||
|
__tablename__ = "waiter_shifts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False)
|
||||||
|
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
starting_cash = Column(Float, nullable=True)
|
||||||
|
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||||
|
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||||
|
breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftBreak(Base):
|
||||||
|
__tablename__ = "shift_breaks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False)
|
||||||
|
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
shift = relationship("WaiterShift", back_populates="breaks")
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -16,7 +20,7 @@ class User(Base):
|
|||||||
nickname = Column(String, nullable=True)
|
nickname = Column(String, nullable=True)
|
||||||
mobile_phone = Column(String, nullable=True)
|
mobile_phone = Column(String, nullable=True)
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||||
@@ -46,7 +50,7 @@ class WaiterZone(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
waiter = relationship("User", back_populates="zone_assignments")
|
waiter = relationship("User", back_populates="zone_assignments")
|
||||||
group = relationship("TableGroup", back_populates="waiter_zones")
|
group = relationship("TableGroup", back_populates="waiter_zones")
|
||||||
@@ -58,7 +62,7 @@ class AssistantAssignment(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
|
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
|
||||||
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
||||||
|
|||||||
137
local_backend/print_size_test.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Font size comparison test — Jolimark TP850UE
|
||||||
|
Usage: python print_size_test.py [IP] [PORT]
|
||||||
|
Default: 10.98.20.25:9100
|
||||||
|
|
||||||
|
Prints a single page showing all available size options side by side,
|
||||||
|
to help decide which sizes to expose in the settings UI.
|
||||||
|
|
||||||
|
Hardware facts:
|
||||||
|
ESC ! (0x1B 0x21 n):
|
||||||
|
0x10 = double-height only (tall + narrow — breaks aspect ratio)
|
||||||
|
0x20 = double-width only (short + wide — breaks aspect ratio)
|
||||||
|
0x30 = double-height + double-width (2x in both axes — correct aspect ratio)
|
||||||
|
There is NO 1.5x in ESC/POS hardware.
|
||||||
|
GS ! (0x1D 0x21 n) can go 3x, 4x … 8x but they are extremely large.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
||||||
|
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||||
|
|
||||||
|
try:
|
||||||
|
from escpos.printer import Network
|
||||||
|
except ImportError:
|
||||||
|
print("escpos not installed. Run: pip install python-escpos")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def gr(text):
|
||||||
|
return text.encode('cp737', errors='replace')
|
||||||
|
|
||||||
|
def raw(p, b):
|
||||||
|
p._raw(b)
|
||||||
|
|
||||||
|
def section(p, title):
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
raw(p, b'\x1b\x45\x00')
|
||||||
|
raw(p, b'\x1b\x61\x01')
|
||||||
|
p._raw(gr(f"--- {title} ---\n"))
|
||||||
|
raw(p, b'\x1b\x61\x00')
|
||||||
|
|
||||||
|
def print_sample(p, esc_bang, gs_size, label_en, label_gr):
|
||||||
|
"""Print one size sample with label."""
|
||||||
|
# Label at normal size
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
raw(p, b'\x1b\x45\x00')
|
||||||
|
p._raw(gr(f"{label_en}:\n"))
|
||||||
|
|
||||||
|
# Apply size via ESC ! and/or GS !
|
||||||
|
if gs_size is not None:
|
||||||
|
raw(p, bytes([0x1d, 0x21, gs_size]))
|
||||||
|
raw(p, bytes([0x1b, 0x21, esc_bang]))
|
||||||
|
|
||||||
|
p._raw(gr(f"Club Sandwich. x1\n"))
|
||||||
|
p._raw(gr(f"* Χωρις αλατι\n"))
|
||||||
|
p._raw(gr(f"+ Extra Bacon x2\n"))
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
raw(p, b'\x1d\x21\x00')
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
raw(p, b'\n')
|
||||||
|
|
||||||
|
def divider(p):
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
p._raw(gr("-" * 48 + "\n"))
|
||||||
|
|
||||||
|
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}...")
|
||||||
|
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||||
|
raw(p, b'\x1b\x40') # ESC @ reset
|
||||||
|
raw(p, b'\x1b\x74\x1d') # CP737 Greek
|
||||||
|
|
||||||
|
raw(p, b'\x1b\x61\x01')
|
||||||
|
raw(p, b'\x1b\x21\x30')
|
||||||
|
raw(p, b'\x1b\x45\x01')
|
||||||
|
p._raw(gr("SIZE COMPARISON TEST\n"))
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
raw(p, b'\x1b\x45\x00')
|
||||||
|
raw(p, b'\x1b\x61\x00')
|
||||||
|
p._raw(gr("Which sizes look good for ticket printing?\n\n"))
|
||||||
|
|
||||||
|
# ── Section 1: The two aspect-ratio-correct options ───────────────────────
|
||||||
|
section(p, "CORRECT ASPECT RATIO")
|
||||||
|
p._raw(gr("\n"))
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x00, gs_size=None,
|
||||||
|
label_en="[1] SMALL (1x1 — normal)",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x30, gs_size=None,
|
||||||
|
label_en="[2] LARGE (2x2 — double height+width)",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
# ── Section 2: The broken single-axis options (for comparison) ────────────
|
||||||
|
divider(p)
|
||||||
|
section(p, "BROKEN ASPECT RATIO (for comparison)")
|
||||||
|
p._raw(gr("These scale only ONE axis — shown so\nyou can confirm they look wrong.\n\n"))
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x10, gs_size=None,
|
||||||
|
label_en="[3] Tall only (2x height, 1x width)",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x20, gs_size=None,
|
||||||
|
label_en="[4] Wide only (1x height, 2x width)",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
# ── Section 3: GS ! options — 3x and beyond ──────────────────────────────
|
||||||
|
divider(p)
|
||||||
|
section(p, "GS! LARGER SIZES (3x3, 4x4)")
|
||||||
|
p._raw(gr("These are technically available but\nvery large. Shown for completeness.\n\n"))
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x00, gs_size=0x22,
|
||||||
|
label_en="[5] GS! 3x3",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
print_sample(p,
|
||||||
|
esc_bang=0x00, gs_size=0x33,
|
||||||
|
label_en="[6] GS! 4x4",
|
||||||
|
label_gr="")
|
||||||
|
|
||||||
|
# ── Conclusion ────────────────────────────────────────────────────────────
|
||||||
|
divider(p)
|
||||||
|
raw(p, b'\x1b\x61\x01')
|
||||||
|
raw(p, b'\x1b\x21\x00')
|
||||||
|
p._raw(gr("CONCLUSION:\n"))
|
||||||
|
p._raw(gr("[1] Small = use for modifiers/notes\n"))
|
||||||
|
p._raw(gr("[2] Large = use for item names/headers\n"))
|
||||||
|
p._raw(gr("No true 1.5x exists in hardware.\n"))
|
||||||
|
p._raw(gr("GS! 3x3/4x4 available if desired.\n"))
|
||||||
|
|
||||||
|
raw(p, b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
print("Done.")
|
||||||
343
local_backend/print_test.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""
|
||||||
|
Printer comprehensive test script — Jolimark TP850UE
|
||||||
|
Usage: python print_test.py [IP] [PORT]
|
||||||
|
Default: 10.98.20.25:9100
|
||||||
|
|
||||||
|
Prints 6 pages:
|
||||||
|
Page 1 — ESC ! modes, Font A, English
|
||||||
|
Page 2 — ESC ! modes, Font B, English
|
||||||
|
Page 3 — ESC ! modes, Font A, Greek
|
||||||
|
Page 4 — ESC ! modes, Font B, Greek
|
||||||
|
Page 5 — GS ! character size multipliers (both fonts)
|
||||||
|
Page 6 — Beep tests + misc (underline, invert, symbols)
|
||||||
|
|
||||||
|
ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
|
||||||
|
Bit 0 (0x01) — Font B instead of Font A
|
||||||
|
Bit 3 (0x08) — Emphasize / Bold
|
||||||
|
Bit 4 (0x10) — Double-height
|
||||||
|
Bit 5 (0x20) — Double-width
|
||||||
|
Bit 7 (0x80) — Underline
|
||||||
|
|
||||||
|
GS ! (0x1D 0x21 n) character size multiplier:
|
||||||
|
Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||||
|
High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||||
|
e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
||||||
|
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||||
|
|
||||||
|
from escpos.printer import Network
|
||||||
|
|
||||||
|
|
||||||
|
# ── Low-level helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _gr(text: str) -> bytes:
|
||||||
|
return text.encode('cp737', errors='replace')
|
||||||
|
|
||||||
|
def _open():
|
||||||
|
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||||
|
p._raw(b'\x1b\x40') # ESC @ — full reset
|
||||||
|
p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
|
||||||
|
return p
|
||||||
|
|
||||||
|
def _t(p, text: str):
|
||||||
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
def _reset(p):
|
||||||
|
"""Reset to: Font A, normal size, no bold, left-align."""
|
||||||
|
p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
|
||||||
|
p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
|
||||||
|
p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
|
||||||
|
p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
|
||||||
|
p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
|
||||||
|
|
||||||
|
def _center(p): p._raw(b'\x1b\x61\x01')
|
||||||
|
def _left(p): p._raw(b'\x1b\x61\x00')
|
||||||
|
|
||||||
|
def _divider(p, char="-", width=48):
|
||||||
|
_left(p)
|
||||||
|
_t(p, char * width + "\n")
|
||||||
|
|
||||||
|
def _page_header(p, title: str):
|
||||||
|
_center(p)
|
||||||
|
p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
|
||||||
|
_t(p, title + "\n")
|
||||||
|
_reset(p)
|
||||||
|
_divider(p, "=")
|
||||||
|
|
||||||
|
|
||||||
|
# ── ESC ! mode table ───────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Each entry: (esc_bang_byte, esc_e_bold, label)
|
||||||
|
# esc_bang_byte sets the mode via ESC ! n
|
||||||
|
# esc_e_bold adds ESC E on top (independent bold layer)
|
||||||
|
# We test every useful combination so you can see the exact visual result.
|
||||||
|
|
||||||
|
ESC_BANG_MODES = [
|
||||||
|
# (byte, extra_bold, label)
|
||||||
|
(0x00, False, "0x00 Normal"),
|
||||||
|
(0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
|
||||||
|
(0x08, False, "0x08 Bold only (bit3)"),
|
||||||
|
(0x10, False, "0x10 Double-height (bit4)"),
|
||||||
|
(0x10, True, "0x10 +ESC E Double-height + Bold"),
|
||||||
|
(0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
|
||||||
|
(0x20, False, "0x20 Double-width (bit5)"),
|
||||||
|
(0x20, True, "0x20 +ESC E Double-width + Bold"),
|
||||||
|
(0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
|
||||||
|
(0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
|
||||||
|
(0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _esc_bang_section(p, english: bool):
|
||||||
|
lang = "EN" if english else "GR"
|
||||||
|
sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
|
||||||
|
sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
|
||||||
|
|
||||||
|
for (byte_val, extra_bold, label) in ESC_BANG_MODES:
|
||||||
|
_left(p)
|
||||||
|
# Print the label in small normal text first
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
_t(p, f"[{label}]\n")
|
||||||
|
|
||||||
|
# Apply mode
|
||||||
|
p._raw(bytes([0x1b, 0x21, byte_val]))
|
||||||
|
if extra_bold:
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
|
||||||
|
_t(p, sample_normal + "\n")
|
||||||
|
_t(p, sample_lower + "\n")
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
_reset(p)
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages 1–4: ESC ! modes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def page_esc_bang(font_b: bool, english: bool):
|
||||||
|
font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
|
||||||
|
lang_label = "GREEK" if not english else "ENGLISH"
|
||||||
|
p = _open()
|
||||||
|
|
||||||
|
# Select font
|
||||||
|
p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
|
||||||
|
|
||||||
|
_page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}")
|
||||||
|
_t(p, f"Font: {font_label}\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
_esc_bang_section(p, english)
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Combinations worth seeing: square multipliers + some asymmetric
|
||||||
|
GS_SIZES = [
|
||||||
|
(0x00, "1x1 normal"),
|
||||||
|
(0x01, "1w x 2h"),
|
||||||
|
(0x10, "2w x 1h"),
|
||||||
|
(0x11, "2x2"),
|
||||||
|
(0x22, "3x3"),
|
||||||
|
(0x33, "4x4"),
|
||||||
|
(0x44, "5x5"),
|
||||||
|
(0x55, "6x6"),
|
||||||
|
(0x02, "1w x 3h"),
|
||||||
|
(0x20, "3w x 1h"),
|
||||||
|
(0x21, "3w x 2h"),
|
||||||
|
(0x12, "2w x 3h"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def page_gs_sizes():
|
||||||
|
p = _open()
|
||||||
|
_page_header(p, "GS! SIZE MULTIPLIERS")
|
||||||
|
_t(p, "GS ! n (0x1D 0x21 n)\n")
|
||||||
|
_t(p, "Low nibble=height, High nibble=width\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
for (byte_val, label) in GS_SIZES:
|
||||||
|
_left(p)
|
||||||
|
# Label in tiny normal text
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, f"[n=0x{byte_val:02X} {label}]\n")
|
||||||
|
|
||||||
|
# Font A sample
|
||||||
|
p._raw(b'\x1b\x4d\x00')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "Aa SAMPLE\n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
|
||||||
|
# Font B sample on same size
|
||||||
|
p._raw(b'\x1b\x4d\x01')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "Bb SMALL\n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
p._raw(b'\x1b\x4d\x00') # back to Font A
|
||||||
|
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# Also show GS ! combined with ESC ! bold
|
||||||
|
_t(p, "\n")
|
||||||
|
_divider(p, "=")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_t(p, "GS! + ESC E bold combined:\n")
|
||||||
|
_divider(p, "=")
|
||||||
|
for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, f"[{label} + bold]\n")
|
||||||
|
p._raw(b'\x1b\x45\x01')
|
||||||
|
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||||
|
_t(p, "BOLD LARGE\n")
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
_t(p, "\n")
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Page 6: Beep + misc ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def page_beep_misc():
|
||||||
|
p = _open()
|
||||||
|
_page_header(p, "BEEP + MISC TESTS")
|
||||||
|
|
||||||
|
# ── Beep section ──
|
||||||
|
_t(p, "BEEP TESTS\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
_t(p, "Sending beeps now...\n\n")
|
||||||
|
|
||||||
|
# BEL — single beep (0x07)
|
||||||
|
_t(p, "[1] BEL single beep (0x07)\n")
|
||||||
|
p._raw(b'\x07')
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# ESC BEL n1 n2 n3 — beep for appointment
|
||||||
|
# n1=beep length (100ms units), n2=intermission (100ms), n3=count
|
||||||
|
_t(p, "[2] ESC BEL: 1 beep, 200ms long\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep
|
||||||
|
time.sleep(0.8)
|
||||||
|
|
||||||
|
_t(p, "[3] ESC BEL: 3 short beeps\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
_t(p, "[4] ESC BEL: 1 long beep (500ms)\n")
|
||||||
|
p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
_t(p, "[5] GS BEL: 2 beeps\n")
|
||||||
|
p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
_t(p, "Beep tests done.\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# ── Underline ──
|
||||||
|
_t(p, "\nUNDERLINE\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
for ul in [1, 2]:
|
||||||
|
p._raw(bytes([0x1b, 0x2d, ul]))
|
||||||
|
_t(p, f"Underline mode {ul}: Hello World 123\n")
|
||||||
|
p._raw(b'\x1b\x2d\x00')
|
||||||
|
_t(p, "\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# ── White-on-black invert ──
|
||||||
|
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
p._raw(b'\x1d\x42\x01')
|
||||||
|
_t(p, " INVERTED NORMAL \n")
|
||||||
|
p._raw(b'\x1d\x21\x11') # 2x2 inverted
|
||||||
|
_t(p, " INVERTED 2x2 \n")
|
||||||
|
p._raw(b'\x1d\x21\x00')
|
||||||
|
p._raw(b'\x1d\x42\x00')
|
||||||
|
_t(p, "Normal after invert\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# ── 90-degree rotation ──
|
||||||
|
_t(p, "\n90-DEGREE ROTATION (ESC V)\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
p._raw(b'\x1b\x56\x01')
|
||||||
|
_t(p, "ROTATED TEXT\n")
|
||||||
|
p._raw(b'\x1b\x56\x00')
|
||||||
|
_t(p, "Normal again\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# ── CP737 useful symbols at normal size ──
|
||||||
|
_t(p, "\nUSEFUL CP737 SYMBOLS\n")
|
||||||
|
_divider(p, "-")
|
||||||
|
symbols = [
|
||||||
|
(0xFB, "tick / checkmark"),
|
||||||
|
(0xFE, "filled square"),
|
||||||
|
(0xF9, "middle dot"),
|
||||||
|
(0xFA, "small bullet"),
|
||||||
|
(0xF8, "degree"),
|
||||||
|
(0xDB, "full block"),
|
||||||
|
(0xDC, "lower half block"),
|
||||||
|
(0xDF, "upper half block"),
|
||||||
|
(0xB0, "light shade"),
|
||||||
|
(0xB1, "medium shade"),
|
||||||
|
(0xB2, "dark shade"),
|
||||||
|
(0xC4, "thin horiz line"),
|
||||||
|
(0xCD, "double horiz line"),
|
||||||
|
(0xBA, "vertical bar"),
|
||||||
|
(0xC9, "top-left corner dbl"),
|
||||||
|
(0xBB, "top-right corner dbl"),
|
||||||
|
(0xC8, "bot-left corner dbl"),
|
||||||
|
(0xBC, "bot-right corner dbl"),
|
||||||
|
]
|
||||||
|
for code, desc in symbols:
|
||||||
|
p._raw(bytes([code, 0x20, code, 0x20, code, 0x20]))
|
||||||
|
_t(p, f" {desc}\n")
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
|
||||||
|
print("Printing 6 pages...\n")
|
||||||
|
|
||||||
|
page_esc_bang(font_b=False, english=True)
|
||||||
|
print("Page 1 done — ESC! modes, Font A, English")
|
||||||
|
|
||||||
|
page_esc_bang(font_b=True, english=True)
|
||||||
|
print("Page 2 done — ESC! modes, Font B, English")
|
||||||
|
|
||||||
|
page_esc_bang(font_b=False, english=False)
|
||||||
|
print("Page 3 done — ESC! modes, Font A, Greek")
|
||||||
|
|
||||||
|
page_esc_bang(font_b=True, english=False)
|
||||||
|
print("Page 4 done — ESC! modes, Font B, Greek")
|
||||||
|
|
||||||
|
page_gs_sizes()
|
||||||
|
print("Page 5 done — GS! size multipliers")
|
||||||
|
|
||||||
|
page_beep_misc()
|
||||||
|
print("Page 6 done — Beep tests + misc")
|
||||||
|
|
||||||
|
print("\nAll done.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.auth import LoginRequest, TokenResponse
|
from schemas.auth import LoginRequest, TokenResponse
|
||||||
|
from pydantic import BaseModel as _PydanticBase
|
||||||
|
|
||||||
|
class LoginByIdRequest(_PydanticBase):
|
||||||
|
waiter_id: int
|
||||||
|
pin: str
|
||||||
from schemas.user import UserOut
|
from schemas.user import UserOut
|
||||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||||
|
|
||||||
@@ -20,6 +25,16 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login-by-id", response_model=TokenResponse)
|
||||||
|
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
|
||||||
|
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||||
|
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
|
||||||
|
token = make_token(user)
|
||||||
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
def refresh(token: str, db: Session = Depends(get_db)):
|
def refresh(token: str, db: Session = Depends(get_db)):
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
@@ -40,3 +55,37 @@ def logout(token: str):
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
def me(user: User = Depends(get_current_user)):
|
def me(user: User = Depends(get_current_user)):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public waiter list (login screen — no auth required) ────────────────────
|
||||||
|
|
||||||
|
from pydantic import BaseModel as _BaseModel
|
||||||
|
|
||||||
|
class PublicWaiterOut(_BaseModel):
|
||||||
|
id: int
|
||||||
|
full_name: str | None
|
||||||
|
nickname: str | None
|
||||||
|
avatar_url: str | None
|
||||||
|
on_shift: bool
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/waiters", response_model=list[PublicWaiterOut])
|
||||||
|
def public_waiter_list(db: Session = Depends(get_db)):
|
||||||
|
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||||
|
on_shift_ids = {
|
||||||
|
row.waiter_id
|
||||||
|
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
PublicWaiterOut(
|
||||||
|
id=w.id,
|
||||||
|
full_name=w.full_name,
|
||||||
|
nickname=w.nickname,
|
||||||
|
avatar_url=w.avatar_url,
|
||||||
|
on_shift=w.id in on_shift_ids,
|
||||||
|
)
|
||||||
|
for w in waiters
|
||||||
|
]
|
||||||
|
|||||||
162
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.order import Order, OrderItem, OrderAuditLog
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
from models.flag import TableFlagAssignment
|
||||||
|
from models.message import StaffMessage, StaffMessageAck
|
||||||
|
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current", response_model=Optional[BusinessDayOut])
|
||||||
|
def get_current_business_day(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def open_business_day(
|
||||||
|
body: OpenBusinessDayRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||||
|
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||||
|
db.add(day)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(day)
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close", response_model=BusinessDayOut)
|
||||||
|
def close_business_day(
|
||||||
|
body: CloseBusinessDayRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not day:
|
||||||
|
raise HTTPException(status_code=404, detail="No open business day")
|
||||||
|
|
||||||
|
open_orders = db.query(Order).filter(
|
||||||
|
Order.business_day_id == day.id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if open_orders and not body.force:
|
||||||
|
# Count orders that have at least one active (unpaid) item — covers both
|
||||||
|
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||||
|
with_pending = sum(
|
||||||
|
1 for o in open_orders
|
||||||
|
if db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == o.id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).first() is not None
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||||
|
"open_orders": len(open_orders),
|
||||||
|
"partially_paid": with_pending,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||||
|
all_unclosed = db.query(Order).filter(
|
||||||
|
Order.business_day_id == day.id,
|
||||||
|
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||||
|
).all()
|
||||||
|
for order in all_unclosed:
|
||||||
|
was_unpaid = order.status in ("open", "partially_paid")
|
||||||
|
order.status = "closed"
|
||||||
|
order.closed_at = now
|
||||||
|
order.closed_by = user.id
|
||||||
|
if was_unpaid:
|
||||||
|
db.add(OrderAuditLog(
|
||||||
|
order_id=order.id,
|
||||||
|
event_type="ORDER_CLOSED",
|
||||||
|
waiter_id=user.id,
|
||||||
|
note="Force-closed at end of business day",
|
||||||
|
))
|
||||||
|
|
||||||
|
active_shifts = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.business_day_id == day.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).all()
|
||||||
|
for shift in active_shifts:
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift.id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||||
|
shift.ended_at = now
|
||||||
|
|
||||||
|
# Clear all table flags and staff messages — fresh slate for the next day
|
||||||
|
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||||
|
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||||
|
db.query(StaffMessage).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
day.status = "closed"
|
||||||
|
day.closed_at = now
|
||||||
|
day.closed_by_id = user.id
|
||||||
|
if body.notes:
|
||||||
|
day.notes = body.notes
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(day)
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
def business_day_history(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||||
|
result = []
|
||||||
|
for day in days:
|
||||||
|
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||||
|
revenue = (
|
||||||
|
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||||
|
.join(Order)
|
||||||
|
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||||
|
.scalar() or 0.0
|
||||||
|
)
|
||||||
|
w_opener = day.opener
|
||||||
|
w_closer = day.closer
|
||||||
|
result.append({
|
||||||
|
"id": day.id,
|
||||||
|
"status": day.status,
|
||||||
|
"opened_at": _dt(day.opened_at),
|
||||||
|
"closed_at": _dt(day.closed_at),
|
||||||
|
"opened_by_id": day.opened_by_id,
|
||||||
|
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||||
|
"closed_by_id": day.closed_by_id,
|
||||||
|
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||||
|
"notes": day.notes,
|
||||||
|
"order_count": order_count,
|
||||||
|
"revenue": round(revenue, 2),
|
||||||
|
})
|
||||||
|
return {"business_days": result}
|
||||||
145
local_backend/routers/flags.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.flag import TableFlagDef, TableFlagAssignment
|
||||||
|
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Flag definitions (manager only) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/defs", response_model=List[FlagDefOut])
|
||||||
|
def list_flag_defs(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(TableFlagDef)
|
||||||
|
if not include_inactive:
|
||||||
|
q = q.filter(TableFlagDef.is_active == True)
|
||||||
|
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_flag_def(
|
||||||
|
body: FlagDefCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = TableFlagDef(**body.model_dump())
|
||||||
|
db.add(flag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(flag)
|
||||||
|
return flag
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
|
||||||
|
def update_flag_def(
|
||||||
|
flag_id: int,
|
||||||
|
body: FlagDefUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||||
|
if not flag:
|
||||||
|
raise HTTPException(status_code=404, detail="Flag not found")
|
||||||
|
for k, v in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(flag, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(flag)
|
||||||
|
return flag
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_flag_def(
|
||||||
|
flag_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||||
|
if not flag:
|
||||||
|
raise HTTPException(status_code=404, detail="Flag not found")
|
||||||
|
# Soft delete — keeps existing assignments readable
|
||||||
|
flag.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
|
||||||
|
|
||||||
|
@router.get("/assignments", response_model=List[FlagAssignmentOut])
|
||||||
|
def get_all_assignments(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
|
||||||
|
return db.query(TableFlagAssignment).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Table flag assignments ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||||
|
def get_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||||
|
def set_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
body: SetTableFlagsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Replace all flags on a table with the given set of flag_ids."""
|
||||||
|
# Validate all flag_ids exist and are active
|
||||||
|
if body.flag_ids:
|
||||||
|
valid = db.query(TableFlagDef).filter(
|
||||||
|
TableFlagDef.id.in_(body.flag_ids),
|
||||||
|
TableFlagDef.is_active == True,
|
||||||
|
).count()
|
||||||
|
if valid != len(body.flag_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
|
||||||
|
|
||||||
|
# Delete existing assignments for this table
|
||||||
|
db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
# Insert new assignments
|
||||||
|
for flag_id in body.flag_ids:
|
||||||
|
db.add(TableFlagAssignment(
|
||||||
|
table_id=table_id,
|
||||||
|
flag_id=flag_id,
|
||||||
|
assigned_by=user.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
result = db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).all()
|
||||||
|
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def clear_table_flags(
|
||||||
|
table_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db.query(TableFlagAssignment).filter(
|
||||||
|
TableFlagAssignment.table_id == table_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})
|
||||||
215
local_backend/routers/messages.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||||
|
from models.user import User
|
||||||
|
from schemas.message import (
|
||||||
|
SendMessageRequest, StaffMessageOut,
|
||||||
|
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||||
|
)
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
|
||||||
|
"""Reload a message with sender and acks eagerly loaded."""
|
||||||
|
return db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).filter(StaffMessage.id == msg_id).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||||
|
sender_name = None
|
||||||
|
try:
|
||||||
|
sender_name = msg.sender.username if msg.sender else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return StaffMessageOut(
|
||||||
|
id=msg.id,
|
||||||
|
sender_id=msg.sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
body=msg.body,
|
||||||
|
target_waiter_ids=msg.target_waiter_ids,
|
||||||
|
table_ids=msg.table_ids,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Quick templates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=List[QuickTemplateOut])
|
||||||
|
def list_templates(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return db.query(QuickMessageTemplate).filter(
|
||||||
|
QuickMessageTemplate.is_active == True
|
||||||
|
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_template(
|
||||||
|
body: QuickTemplateCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = QuickMessageTemplate(**body.model_dump())
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
|
||||||
|
def update_template(
|
||||||
|
template_id: int,
|
||||||
|
body: QuickTemplateUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
for k, v in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(t, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_template(
|
||||||
|
template_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
t.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Staff messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def send_message(
|
||||||
|
body: SendMessageRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
msg = StaffMessage(
|
||||||
|
sender_id=user.id,
|
||||||
|
body=body.body,
|
||||||
|
target_waiter_ids=json.dumps(body.target_waiter_ids),
|
||||||
|
table_ids=json.dumps(body.table_ids or []),
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
db.commit()
|
||||||
|
msg = _load_msg(db, msg.id)
|
||||||
|
out = _message_out(msg)
|
||||||
|
# Broadcast to targeted users (empty list = all connected users)
|
||||||
|
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
|
||||||
|
broadcast_sync(
|
||||||
|
"message_sent",
|
||||||
|
{
|
||||||
|
"id": out.id,
|
||||||
|
"sender_id": out.sender_id,
|
||||||
|
"sender_name": out.sender_name,
|
||||||
|
"body": out.body,
|
||||||
|
"table_ids": out.table_ids,
|
||||||
|
"created_at": out.created_at.isoformat() if out.created_at else None,
|
||||||
|
},
|
||||||
|
user_ids=target_ids,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||||
|
def get_unread_messages(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns messages targeting this waiter that they haven't acked yet.
|
||||||
|
A message targets a waiter if their ID is in target_waiter_ids,
|
||||||
|
OR if target_waiter_ids is empty (broadcast to all).
|
||||||
|
"""
|
||||||
|
all_msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||||
|
acked_ids = {
|
||||||
|
ack.message_id
|
||||||
|
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for msg in all_msgs:
|
||||||
|
if msg.id in acked_ids:
|
||||||
|
continue
|
||||||
|
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||||
|
# Empty list = broadcast to all
|
||||||
|
if not targets or user.id in targets:
|
||||||
|
result.append(_message_out(msg))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent", response_model=List[StaffMessageOut])
|
||||||
|
def get_recent_messages(
|
||||||
|
limit: int = 10,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Last N messages targeting this user (for notification history drawer)."""
|
||||||
|
all_msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||||
|
result = []
|
||||||
|
for msg in all_msgs:
|
||||||
|
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||||
|
if not targets or user.id in targets:
|
||||||
|
result.append(_message_out(msg))
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def ack_message(
|
||||||
|
message_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
|
||||||
|
if not msg:
|
||||||
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
existing = db.query(StaffMessageAck).filter(
|
||||||
|
StaffMessageAck.message_id == message_id,
|
||||||
|
StaffMessageAck.waiter_id == user.id,
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/all", response_model=List[StaffMessageOut])
|
||||||
|
def list_all_messages(
|
||||||
|
limit: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
msgs = db.query(StaffMessage).options(
|
||||||
|
joinedload(StaffMessage.sender),
|
||||||
|
joinedload(StaffMessage.acks),
|
||||||
|
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
|
||||||
|
return [_message_out(m) for m in msgs]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -9,13 +9,31 @@ from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
|||||||
from models.user import User, WaiterZone
|
from models.user import User, WaiterZone
|
||||||
from models.table import Table
|
from models.table import Table
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
|
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class PrintOrderRequest(BaseModel):
|
class PrintOrderRequest(BaseModel):
|
||||||
printer_id: int
|
printer_id: int
|
||||||
|
|
||||||
|
class TransferOrderRequest(BaseModel):
|
||||||
|
target_table_id: int
|
||||||
|
|
||||||
|
class MergeOrderRequest(BaseModel):
|
||||||
|
target_order_id: int
|
||||||
|
|
||||||
|
class SplitItemRequest(BaseModel):
|
||||||
|
quantity: int # how many to split off into a new item row
|
||||||
|
|
||||||
|
class PrintSynopsisRequest(BaseModel):
|
||||||
|
printer_id: int
|
||||||
|
|
||||||
|
class MoveItemsRequest(BaseModel):
|
||||||
|
item_ids: List[int]
|
||||||
|
target_order_id: int
|
||||||
|
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
|
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -79,6 +97,29 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us
|
|||||||
return direct + [o for o in also_opened if o.id not in seen]
|
return direct + [o for o in also_opened if o.id not in seen]
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveOrderSlim(BaseModel):
|
||||||
|
id: int
|
||||||
|
table_id: int
|
||||||
|
status: str
|
||||||
|
waiter_ids: List[int]
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active", response_model=List[ActiveOrderSlim])
|
||||||
|
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
|
||||||
|
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
|
||||||
|
return [
|
||||||
|
ActiveOrderSlim(
|
||||||
|
id=o.id,
|
||||||
|
table_id=o.table_id,
|
||||||
|
status=o.status,
|
||||||
|
waiter_ids=[w.waiter_id for w in o.waiters],
|
||||||
|
)
|
||||||
|
for o in orders
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{order_id}", response_model=OrderOut)
|
@router.get("/{order_id}", response_model=OrderOut)
|
||||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
@@ -91,19 +132,35 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
|||||||
|
|
||||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||||
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not active_day:
|
||||||
|
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
|
||||||
|
|
||||||
|
if user.role == "waiter":
|
||||||
|
active_shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if not active_shift:
|
||||||
|
raise HTTPException(status_code=403, detail="You do not have an active shift")
|
||||||
|
|
||||||
existing = db.query(Order).filter(
|
existing = db.query(Order).filter(
|
||||||
Order.table_id == body.table_id,
|
Order.table_id == body.table_id,
|
||||||
Order.status.in_(["open", "partially_paid"]),
|
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Table already has an open order")
|
raise HTTPException(status_code=400, detail="Table already has an open order")
|
||||||
order = Order(table_id=body.table_id, opened_by=user.id)
|
order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id)
|
||||||
db.add(order)
|
db.add(order)
|
||||||
db.flush()
|
db.flush()
|
||||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
@@ -119,9 +176,13 @@ def add_items(
|
|||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
if not _can_access_order(order, user, db):
|
if not _can_access_order(order, user, db):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
if order.status not in ("open", "partially_paid"):
|
if order.status not in ("open", "partially_paid", "paid"):
|
||||||
raise HTTPException(status_code=400, detail="Order is not open")
|
raise HTTPException(status_code=400, detail="Order is not open")
|
||||||
|
|
||||||
|
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
|
||||||
|
if order.status == "paid":
|
||||||
|
order.status = "partially_paid"
|
||||||
|
|
||||||
new_item_ids = []
|
new_item_ids = []
|
||||||
for item_in in body.items:
|
for item_in in body.items:
|
||||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||||
@@ -150,7 +211,28 @@ def add_items(
|
|||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
|
|
||||||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
|
||||||
|
return {"order": order, "print_results": print_results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/retry-print", response_model=AddItemsResponse)
|
||||||
|
def retry_print(
|
||||||
|
order_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
|
||||||
|
if not unprinted_ids:
|
||||||
|
return {"order": order, "print_results": []}
|
||||||
|
|
||||||
|
print_results = route_and_print_sync(order_id, unprinted_ids, db)
|
||||||
|
db.refresh(order)
|
||||||
return {"order": order, "print_results": print_results}
|
return {"order": order, "print_results": print_results}
|
||||||
|
|
||||||
|
|
||||||
@@ -184,20 +266,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
if not _can_access_order(order, user, db):
|
if not _can_access_order(order, user, db):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
|
||||||
items = db.query(OrderItem).filter(
|
items = db.query(OrderItem).filter(
|
||||||
OrderItem.id.in_(body.item_ids),
|
OrderItem.id.in_(body.item_ids),
|
||||||
OrderItem.order_id == order_id,
|
OrderItem.order_id == order_id,
|
||||||
OrderItem.status == "active",
|
OrderItem.status == "active",
|
||||||
).all()
|
).all()
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
|
active_shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
for item in items:
|
for item in items:
|
||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = now
|
item.paid_at = now
|
||||||
item.payment_method = body.payment_method
|
item.payment_method = body.payment_method
|
||||||
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
total_paid += item.unit_price * item.quantity
|
total_paid += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
db.flush() # write item status changes before counting, since autoflush=False
|
||||||
active_remaining = db.query(OrderItem).filter(
|
active_remaining = db.query(OrderItem).filter(
|
||||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||||
).count()
|
).count()
|
||||||
@@ -207,6 +297,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
||||||
amount=total_paid, payment_method=body.payment_method)
|
amount=total_paid, payment_method=body.payment_method)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||||
return {"status": order.status, "paid_item_ids": paid_ids}
|
return {"status": order.status, "paid_item_ids": paid_ids}
|
||||||
|
|
||||||
|
|
||||||
@@ -220,27 +311,124 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
|||||||
if order.status not in ("paid", "open", "partially_paid"):
|
if order.status not in ("paid", "open", "partially_paid"):
|
||||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||||
order.status = "closed"
|
order.status = "closed"
|
||||||
order.closed_at = datetime.utcnow()
|
order.closed_at = datetime.now(timezone.utc)
|
||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||||
return {"status": "closed"}
|
return {"status": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/pay-offline")
|
||||||
|
def pay_items_offline(
|
||||||
|
order_id: int,
|
||||||
|
body: OfflinePaymentRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sync an emergency payment that was taken while the server was offline.
|
||||||
|
The UUID prevents double-processing. If a payment with the same UUID already
|
||||||
|
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
|
||||||
|
than silently dropped — so managers can reconcile.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Check for duplicate UUID on this order
|
||||||
|
existing_uuid = db.query(OrderAuditLog).filter(
|
||||||
|
OrderAuditLog.order_id == order_id,
|
||||||
|
OrderAuditLog.offline_uuid == body.uuid,
|
||||||
|
).first()
|
||||||
|
is_duplicate = existing_uuid is not None
|
||||||
|
|
||||||
|
from models.shift import WaiterShift
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id.in_(body.item_ids),
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Reject empty payments — client had no offline snapshot for this table
|
||||||
|
if not items and not is_duplicate:
|
||||||
|
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
|
||||||
|
|
||||||
|
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
|
||||||
|
try:
|
||||||
|
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
paid_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
active_shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
total_paid = 0.0
|
||||||
|
paid_ids = []
|
||||||
|
if not is_duplicate:
|
||||||
|
for item in items:
|
||||||
|
item.status = "paid"
|
||||||
|
item.paid_by = user.id
|
||||||
|
item.paid_at = paid_at
|
||||||
|
item.payment_method = body.payment_method
|
||||||
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
|
total_paid += item.unit_price * item.quantity
|
||||||
|
paid_ids.append(item.id)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
active_remaining = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||||
|
).count()
|
||||||
|
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||||
|
else:
|
||||||
|
# Duplicate — compute total for audit record without changing item state
|
||||||
|
total_paid = sum(i.unit_price * i.quantity for i in items)
|
||||||
|
paid_ids = [i.id for i in items]
|
||||||
|
|
||||||
|
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
|
||||||
|
db.add(OrderAuditLog(
|
||||||
|
order_id=order_id,
|
||||||
|
event_type="PAYMENT_OFFLINE",
|
||||||
|
waiter_id=user.id,
|
||||||
|
item_ids=json.dumps(paid_ids),
|
||||||
|
amount=total_paid,
|
||||||
|
payment_method=body.payment_method,
|
||||||
|
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
|
||||||
|
offline_uuid=body.uuid,
|
||||||
|
offline_at=body.offline_at,
|
||||||
|
is_duplicate=1 if is_duplicate else 0,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if not is_duplicate:
|
||||||
|
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": order.status if not is_duplicate else "duplicate",
|
||||||
|
"paid_item_ids": paid_ids,
|
||||||
|
"is_duplicate": is_duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
order.closed_at = datetime.utcnow()
|
order.closed_at = datetime.now(timezone.utc)
|
||||||
order.closed_by = user.id
|
order.closed_by = user.id
|
||||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{order_id}/assign-waiter")
|
@router.put("/{order_id}/assign-waiter")
|
||||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
if not order:
|
if not order:
|
||||||
raise HTTPException(status_code=404, detail="Order not found")
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
@@ -318,3 +506,298 @@ def print_order(
|
|||||||
|
|
||||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||||
return {"status": "printing"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Transfer order to a different table ─────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/transfer")
|
||||||
|
def transfer_order(
|
||||||
|
order_id: int,
|
||||||
|
body: TransferOrderRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if order.status not in ("open", "partially_paid", "paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Order is not active")
|
||||||
|
|
||||||
|
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
|
||||||
|
if not target_table:
|
||||||
|
raise HTTPException(status_code=404, detail="Target table not found")
|
||||||
|
if body.target_table_id == order.table_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
|
||||||
|
|
||||||
|
conflict = db.query(Order).filter(
|
||||||
|
Order.table_id == body.target_table_id,
|
||||||
|
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||||
|
).first()
|
||||||
|
if conflict:
|
||||||
|
raise HTTPException(status_code=400, detail="Target table already has an active order")
|
||||||
|
|
||||||
|
old_table_id = order.table_id
|
||||||
|
order.table_id = body.target_table_id
|
||||||
|
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
|
||||||
|
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Merge another order into this one ───────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/merge")
|
||||||
|
def merge_order(
|
||||||
|
order_id: int,
|
||||||
|
body: MergeOrderRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Merge source order (order_id) INTO target order (body.target_order_id).
|
||||||
|
All items (paid + active) from the source are reassigned to the target.
|
||||||
|
Source waiters are added to the target if not already there.
|
||||||
|
Source order is cancelled with audit note.
|
||||||
|
"""
|
||||||
|
source = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source order not found")
|
||||||
|
if not _can_access_order(source, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if source.status not in ("open", "partially_paid", "paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||||
|
|
||||||
|
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Target order not found")
|
||||||
|
if not _can_access_order(target, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||||
|
if target.status not in ("open", "partially_paid", "paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||||
|
if source.id == target.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
|
||||||
|
|
||||||
|
# Move all items to target order
|
||||||
|
moved_item_ids = []
|
||||||
|
for item in source.items:
|
||||||
|
item.order_id = target.id
|
||||||
|
moved_item_ids.append(item.id)
|
||||||
|
|
||||||
|
# Copy source waiters to target (no duplicates)
|
||||||
|
existing_waiter_ids = {w.waiter_id for w in target.waiters}
|
||||||
|
for ow in source.waiters:
|
||||||
|
if ow.waiter_id not in existing_waiter_ids:
|
||||||
|
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
|
||||||
|
|
||||||
|
# Recompute target status after flush
|
||||||
|
db.flush()
|
||||||
|
active_remaining = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == target.id, OrderItem.status == "active"
|
||||||
|
).count()
|
||||||
|
paid_exists = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == target.id, OrderItem.status == "paid"
|
||||||
|
).count()
|
||||||
|
if active_remaining > 0:
|
||||||
|
target.status = "partially_paid" if paid_exists > 0 else "open"
|
||||||
|
else:
|
||||||
|
target.status = "paid"
|
||||||
|
|
||||||
|
# Cancel source order
|
||||||
|
source.status = "cancelled"
|
||||||
|
source.closed_at = datetime.now(timezone.utc)
|
||||||
|
source.closed_by = user.id
|
||||||
|
|
||||||
|
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||||
|
note=f"Merged into order #{target.id} (table {target.table_id})")
|
||||||
|
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
|
||||||
|
note=f"Items merged from order #{source.id} (table {source.table_id})")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(target)
|
||||||
|
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
|
||||||
|
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Split a stacked item into two rows ──────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
|
||||||
|
def split_item(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
body: SplitItemRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Split qty units off item_id into a new item row.
|
||||||
|
Both rows share all properties (product, price, options, notes).
|
||||||
|
Only active items can be split.
|
||||||
|
"""
|
||||||
|
item = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id == item_id, OrderItem.order_id == order_id
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if item.status != "active":
|
||||||
|
raise HTTPException(status_code=400, detail="Only active items can be split")
|
||||||
|
if body.quantity <= 0 or body.quantity >= item.quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
# Reduce original item
|
||||||
|
item.quantity -= body.quantity
|
||||||
|
|
||||||
|
# Create split-off item
|
||||||
|
new_item = OrderItem(
|
||||||
|
order_id=order_id,
|
||||||
|
product_id=item.product_id,
|
||||||
|
added_by=item.added_by,
|
||||||
|
quantity=body.quantity,
|
||||||
|
unit_price=item.unit_price,
|
||||||
|
selected_options=item.selected_options,
|
||||||
|
removed_ingredients=item.removed_ingredients,
|
||||||
|
notes=item.notes,
|
||||||
|
status="active",
|
||||||
|
printed=item.printed,
|
||||||
|
)
|
||||||
|
db.add(new_item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
db.refresh(new_item)
|
||||||
|
return [item, new_item]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Move selected items to another order ────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/move-items")
|
||||||
|
def move_items(
|
||||||
|
order_id: int,
|
||||||
|
body: MoveItemsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Move specific active items from this order to another open order."""
|
||||||
|
source = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(status_code=404, detail="Source order not found")
|
||||||
|
if not _can_access_order(source, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if source.status not in ("open", "partially_paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||||
|
|
||||||
|
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Target order not found")
|
||||||
|
if not _can_access_order(target, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||||
|
if target.status not in ("open", "partially_paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||||
|
if source.id == target.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Source and target orders are the same")
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id.in_(body.item_ids),
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).all()
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=400, detail="No active items found to move")
|
||||||
|
|
||||||
|
moved_ids = []
|
||||||
|
for item in items:
|
||||||
|
item.order_id = target.id
|
||||||
|
moved_ids.append(item.id)
|
||||||
|
|
||||||
|
# Recompute source status
|
||||||
|
db.flush()
|
||||||
|
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
|
||||||
|
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
|
||||||
|
if src_active == 0 and src_paid == 0:
|
||||||
|
source.status = "open"
|
||||||
|
elif src_active == 0:
|
||||||
|
source.status = "paid"
|
||||||
|
else:
|
||||||
|
source.status = "partially_paid" if src_paid > 0 else "open"
|
||||||
|
|
||||||
|
# Recompute target status
|
||||||
|
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
|
||||||
|
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
|
||||||
|
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
|
||||||
|
|
||||||
|
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
|
||||||
|
note=f"Moved to order #{target.id} (table {target.table_id})")
|
||||||
|
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
|
||||||
|
note=f"Moved from order #{source.id} (table {source.table_id})")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(source)
|
||||||
|
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Print order synopsis ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{order_id}/print-synopsis")
|
||||||
|
def print_synopsis(
|
||||||
|
order_id: int,
|
||||||
|
body: PrintSynopsisRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from models.printer import Printer
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||||
|
|
||||||
|
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||||
|
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||||
|
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||||
|
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
|
||||||
|
|
||||||
|
items_data = []
|
||||||
|
for item in order.items:
|
||||||
|
if item.status == "cancelled":
|
||||||
|
continue
|
||||||
|
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||||
|
items_data.append({
|
||||||
|
"name": product_name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": item.unit_price,
|
||||||
|
"total": item.unit_price * item.quantity,
|
||||||
|
"status": item.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
total = sum(i["total"] for i in items_data)
|
||||||
|
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
|
||||||
|
|
||||||
|
synopsis = {
|
||||||
|
"order_id": order.id,
|
||||||
|
"table_name": table_name,
|
||||||
|
"waiter_name": waiter_name,
|
||||||
|
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||||
|
"items": items_data,
|
||||||
|
"total": total,
|
||||||
|
"paid_total": paid_total,
|
||||||
|
"remaining": total - paid_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
||||||
|
return {"status": "printing"}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||||
from models.order import OrderItem
|
from models.order import OrderItem
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.product import (
|
from schemas.product import (
|
||||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||||
PreferenceSetCreate,
|
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||||
|
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
@@ -21,6 +22,23 @@ router = APIRouter()
|
|||||||
IMAGE_DIR = "/app/data/product_images"
|
IMAGE_DIR = "/app/data/product_images"
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_quick_options(db, product, quick_options):
|
||||||
|
for qo in product.quick_options:
|
||||||
|
db.delete(qo)
|
||||||
|
db.flush()
|
||||||
|
for i, qo in enumerate(quick_options):
|
||||||
|
db.add(ProductQuickOption(
|
||||||
|
product_id=product.id,
|
||||||
|
name=qo.name,
|
||||||
|
price=qo.price,
|
||||||
|
allow_multiple=qo.allow_multiple,
|
||||||
|
sort_order=qo.sort_order if qo.sort_order else i,
|
||||||
|
is_favorite=qo.is_favorite,
|
||||||
|
favorite_sort_order=qo.favorite_sort_order,
|
||||||
|
is_compact=qo.is_compact,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _replace_options(db, product, options):
|
def _replace_options(db, product, options):
|
||||||
for opt in product.options:
|
for opt in product.options:
|
||||||
db.delete(opt)
|
db.delete(opt)
|
||||||
@@ -31,7 +49,10 @@ def _replace_options(db, product, options):
|
|||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
name=opt.name,
|
name=opt.name,
|
||||||
extra_cost=opt.extra_cost,
|
extra_cost=opt.extra_cost,
|
||||||
|
allow_multiple=opt.allow_multiple,
|
||||||
sub_choices=sub_json,
|
sub_choices=sub_json,
|
||||||
|
is_favorite=opt.is_favorite,
|
||||||
|
favorite_sort_order=opt.favorite_sort_order,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +74,8 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
|||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
name=ps.name,
|
name=ps.name,
|
||||||
shared_subset=shared_json,
|
shared_subset=shared_json,
|
||||||
|
is_favorite=ps.is_favorite,
|
||||||
|
favorite_sort_order=ps.favorite_sort_order,
|
||||||
)
|
)
|
||||||
db.add(new_set)
|
db.add(new_set)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -82,8 +105,15 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
|
|||||||
|
|
||||||
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
max_order = db.query(Category).count()
|
# sort_order is among siblings (same parent_id level)
|
||||||
cat = Category(name=body.name, color=body.color, sort_order=max_order)
|
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||||
|
cat = Category(
|
||||||
|
name=body.name,
|
||||||
|
color=body.color,
|
||||||
|
sort_order=sibling_count,
|
||||||
|
parent_id=body.parent_id,
|
||||||
|
general_sort_order=body.general_sort_order,
|
||||||
|
)
|
||||||
db.add(cat)
|
db.add(cat)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(cat)
|
db.refresh(cat)
|
||||||
@@ -99,6 +129,26 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
"""Reorder sub-categories within their parent (sort_order among siblings)."""
|
||||||
|
for item in items:
|
||||||
|
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||||
|
if cat:
|
||||||
|
cat.sort_order = item.sort_order
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
"""Update general_sort_order on parent categories (position of the General group)."""
|
||||||
|
for item in items:
|
||||||
|
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||||
|
if cat:
|
||||||
|
cat.general_sort_order = item.general_sort_order
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||||
@@ -126,7 +176,8 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
|||||||
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
q = db.query(Product)
|
q = db.query(Product)
|
||||||
if not all or user.role not in ("manager", "sysadmin"):
|
if not all or user.role not in ("manager", "sysadmin"):
|
||||||
q = q.filter(Product.is_available == True)
|
# Waiters only see active, available products
|
||||||
|
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
|
||||||
return q.order_by(Product.sort_order, Product.id).all()
|
return q.order_by(Product.sort_order, Product.id).all()
|
||||||
|
|
||||||
|
|
||||||
@@ -141,15 +192,34 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
|||||||
|
|
||||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
|
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
||||||
if data.get("sort_order") == 0:
|
if data.get("sort_order") == 0:
|
||||||
data["sort_order"] = db.query(Product).count()
|
data["sort_order"] = db.query(Product).count()
|
||||||
product = Product(**data)
|
product = Product(**data)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
for i, qo in enumerate(body.quick_options):
|
||||||
|
db.add(ProductQuickOption(
|
||||||
|
product_id=product.id,
|
||||||
|
name=qo.name,
|
||||||
|
price=qo.price,
|
||||||
|
allow_multiple=qo.allow_multiple,
|
||||||
|
sort_order=qo.sort_order if qo.sort_order else i,
|
||||||
|
is_favorite=qo.is_favorite,
|
||||||
|
favorite_sort_order=qo.favorite_sort_order,
|
||||||
|
is_compact=qo.is_compact,
|
||||||
|
))
|
||||||
for opt in body.options:
|
for opt in body.options:
|
||||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||||
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
|
db.add(ProductOption(
|
||||||
|
product_id=product.id,
|
||||||
|
name=opt.name,
|
||||||
|
extra_cost=opt.extra_cost,
|
||||||
|
allow_multiple=opt.allow_multiple,
|
||||||
|
sub_choices=sub_json,
|
||||||
|
is_favorite=opt.is_favorite,
|
||||||
|
favorite_sort_order=opt.favorite_sort_order,
|
||||||
|
))
|
||||||
for ing in body.ingredients:
|
for ing in body.ingredients:
|
||||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||||
_replace_preference_sets(db, product, body.preference_sets)
|
_replace_preference_sets(db, product, body.preference_sets)
|
||||||
@@ -163,8 +233,10 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
|||||||
product = db.query(Product).filter(Product.id == product_id).first()
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
|
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
if body.quick_options is not None:
|
||||||
|
_replace_quick_options(db, product, body.quick_options)
|
||||||
if body.options is not None:
|
if body.options is not None:
|
||||||
_replace_options(db, product, body.options)
|
_replace_options(db, product, body.options)
|
||||||
if body.ingredients is not None:
|
if body.ingredients is not None:
|
||||||
@@ -216,9 +288,14 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
|
|||||||
if has_orders:
|
if has_orders:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
|
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
|
||||||
)
|
)
|
||||||
db.delete(product)
|
db.delete(product)
|
||||||
else:
|
else:
|
||||||
product.is_available = False
|
# If product has order history, archive it; otherwise hard delete
|
||||||
|
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||||
|
if has_orders:
|
||||||
|
product.lifecycle_status = "archived"
|
||||||
|
else:
|
||||||
|
db.delete(product)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from models.table import Table
|
from models.table import Table
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
|
from models.shift import WaiterShift
|
||||||
from schemas.order import OrderOut
|
from schemas.order import OrderOut
|
||||||
from schemas.table import TableOut
|
from schemas.table import TableOut
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager
|
||||||
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shift")
|
@router.get("/shift")
|
||||||
def shift_summary(
|
def shift_summary(
|
||||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
@@ -438,3 +445,213 @@ def print_printer_totals(
|
|||||||
|
|
||||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||||
return {"status": "printing"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shift history report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/shifts")
|
||||||
|
def shifts_report(
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
active_only: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from routers.shifts import compute_shift_total
|
||||||
|
|
||||||
|
q = db.query(WaiterShift)
|
||||||
|
if waiter_id:
|
||||||
|
q = q.filter(WaiterShift.waiter_id == waiter_id)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(WaiterShift.business_day_id == business_day_id)
|
||||||
|
if from_dt:
|
||||||
|
q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt))
|
||||||
|
if to_dt:
|
||||||
|
q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt))
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(WaiterShift.ended_at == None)
|
||||||
|
|
||||||
|
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
||||||
|
waiters_db = {u.id: u for u in db.query(User).all()}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for shift in shifts:
|
||||||
|
w = waiters_db.get(shift.waiter_id)
|
||||||
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||||
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||||
|
result.append({
|
||||||
|
"id": shift.id,
|
||||||
|
"waiter_id": shift.waiter_id,
|
||||||
|
"waiter_name": wname,
|
||||||
|
"business_day_id": shift.business_day_id,
|
||||||
|
"started_at": _dt(shift.started_at),
|
||||||
|
"ended_at": _dt(shift.ended_at),
|
||||||
|
"starting_cash": shift.starting_cash,
|
||||||
|
"total_collected": total,
|
||||||
|
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||||
|
"is_active": shift.ended_at is None,
|
||||||
|
"notes": shift.notes,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"shifts": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Product performance analytics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/products/performance")
|
||||||
|
def product_performance(
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
category_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
from models.product import Product
|
||||||
|
|
||||||
|
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||||
|
if from_dt:
|
||||||
|
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
|
||||||
|
if to_dt:
|
||||||
|
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
|
||||||
|
if business_day_id:
|
||||||
|
q = q.join(Order).filter(Order.business_day_id == business_day_id)
|
||||||
|
|
||||||
|
items = q.all()
|
||||||
|
products_db = {p.id: p for p in db.query(Product).all()}
|
||||||
|
|
||||||
|
summary: dict = {}
|
||||||
|
for item in items:
|
||||||
|
pid = item.product_id
|
||||||
|
product = products_db.get(pid)
|
||||||
|
if category_id and (not product or product.category_id != category_id):
|
||||||
|
continue
|
||||||
|
if pid not in summary:
|
||||||
|
summary[pid] = {
|
||||||
|
"product_id": pid,
|
||||||
|
"product_name": product.name if product else f"#{pid}",
|
||||||
|
"category_id": product.category_id if product else None,
|
||||||
|
"qty_sold": 0,
|
||||||
|
"revenue": 0.0,
|
||||||
|
"order_ids": set(),
|
||||||
|
}
|
||||||
|
summary[pid]["qty_sold"] += item.quantity
|
||||||
|
summary[pid]["revenue"] += item.unit_price * item.quantity
|
||||||
|
summary[pid]["order_ids"].add(item.order_id)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for entry in summary.values():
|
||||||
|
entry["order_count"] = len(entry.pop("order_ids"))
|
||||||
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
|
result.append(entry)
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
||||||
|
return {"products": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table performance analytics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/tables/performance")
|
||||||
|
def table_performance(
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||||
|
if from_dt:
|
||||||
|
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
|
||||||
|
if to_dt:
|
||||||
|
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(Order.business_day_id == business_day_id)
|
||||||
|
orders = q.all()
|
||||||
|
|
||||||
|
tables_db = {t.id: t for t in db.query(Table).all()}
|
||||||
|
|
||||||
|
summary: dict = {}
|
||||||
|
for order in orders:
|
||||||
|
tid = order.table_id
|
||||||
|
if tid not in summary:
|
||||||
|
t = tables_db.get(tid)
|
||||||
|
summary[tid] = {
|
||||||
|
"table_id": tid,
|
||||||
|
"table_name": (t.label or f"T{t.number}") if t else f"#{tid}",
|
||||||
|
"order_count": 0,
|
||||||
|
"revenue": 0.0,
|
||||||
|
"durations": [],
|
||||||
|
}
|
||||||
|
summary[tid]["order_count"] += 1
|
||||||
|
summary[tid]["revenue"] += sum(
|
||||||
|
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||||
|
)
|
||||||
|
if order.closed_at and order.opened_at:
|
||||||
|
summary[tid]["durations"].append(
|
||||||
|
(order.closed_at - order.opened_at).total_seconds() / 60
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for entry in summary.values():
|
||||||
|
durations = entry.pop("durations")
|
||||||
|
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
|
||||||
|
entry["revenue"] = round(entry["revenue"], 2)
|
||||||
|
result.append(entry)
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x["revenue"], reverse=True)
|
||||||
|
return {"tables": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Traffic analysis (hour-of-day / day-of-week)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/traffic")
|
||||||
|
def traffic_analysis(
|
||||||
|
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Order)
|
||||||
|
if from_dt:
|
||||||
|
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
|
||||||
|
if to_dt:
|
||||||
|
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(Order.business_day_id == business_day_id)
|
||||||
|
orders = q.all()
|
||||||
|
|
||||||
|
by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)}
|
||||||
|
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)}
|
||||||
|
|
||||||
|
for order in orders:
|
||||||
|
revenue = sum(
|
||||||
|
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||||
|
)
|
||||||
|
h = order.opened_at.hour
|
||||||
|
d = order.opened_at.weekday()
|
||||||
|
by_hour[h]["orders"] += 1
|
||||||
|
by_hour[h]["revenue"] += revenue
|
||||||
|
by_weekday[d]["orders"] += 1
|
||||||
|
by_weekday[d]["revenue"] += revenue
|
||||||
|
|
||||||
|
for h in by_hour:
|
||||||
|
by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2)
|
||||||
|
for d in by_weekday:
|
||||||
|
by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"by_hour": list(by_hour.values()),
|
||||||
|
"by_weekday": list(by_weekday.values()),
|
||||||
|
}
|
||||||
|
|||||||
92
local_backend/routers/settings.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.settings import PosSettings
|
||||||
|
from schemas.settings import UpdateSettingRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
VALID_SETTINGS = {
|
||||||
|
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||||
|
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||||
|
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||||
|
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||||
|
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||||
|
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
|
||||||
|
# Print layout
|
||||||
|
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||||
|
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
||||||
|
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
|
||||||
|
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
||||||
|
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
|
||||||
|
"print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS",
|
||||||
|
"print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS",
|
||||||
|
"print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS",
|
||||||
|
"print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS",
|
||||||
|
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
|
||||||
|
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
|
||||||
|
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"shifts.waiter_self_start": "true",
|
||||||
|
"shifts.waiter_self_end": "true",
|
||||||
|
"business_day.force_close_allowed": "true",
|
||||||
|
"system.timezone": "Europe/Athens",
|
||||||
|
"ui.table_colours": "",
|
||||||
|
"dev.spoof_printing": "false",
|
||||||
|
"print.ticket_mode": "detailed",
|
||||||
|
"print.divider_style": "dash",
|
||||||
|
"print.font_order_number": "48:1:0",
|
||||||
|
"print.font_meta": "0:0:0",
|
||||||
|
"print.font_item_name": "16:1:0",
|
||||||
|
"print.font_quick": "0:0:0",
|
||||||
|
"print.font_pref": "0:0:0",
|
||||||
|
"print.font_extra": "0:0:0",
|
||||||
|
"print.font_ingredient": "0:0:0",
|
||||||
|
"print.font_item_note": "0:0:0",
|
||||||
|
"print.font_order_note": "0:1:0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def get_all_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
stored = {s.key: s.value for s in db.query(PosSettings).all()}
|
||||||
|
result = {}
|
||||||
|
for key, description in VALID_SETTINGS.items():
|
||||||
|
result[key] = {
|
||||||
|
"value": stored.get(key, DEFAULTS.get(key, "true")),
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key}")
|
||||||
|
def update_setting(
|
||||||
|
key: str,
|
||||||
|
body: UpdateSettingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
if key not in VALID_SETTINGS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||||
|
|
||||||
|
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = body.value
|
||||||
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
|
setting.updated_by_id = user.id
|
||||||
|
else:
|
||||||
|
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
|
||||||
|
db.add(setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return {"key": setting.key, "value": setting.value}
|
||||||
347
local_backend/routers/shifts.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.shift import WaiterShift, ShiftBreak
|
||||||
|
from models.business_day import BusinessDay
|
||||||
|
from models.order import OrderItem
|
||||||
|
from models.settings import PosSettings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.shift import StartShiftRequest, EndShiftRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(dt):
|
||||||
|
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_setting(db: Session, key: str, default: str = "true") -> str:
|
||||||
|
s = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||||
|
return s.value if s else default
|
||||||
|
|
||||||
|
|
||||||
|
def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift_id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||||
|
w = shift.waiter
|
||||||
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||||
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||||
|
return {
|
||||||
|
"id": shift.id,
|
||||||
|
"waiter_id": shift.waiter_id,
|
||||||
|
"waiter_name": wname,
|
||||||
|
"business_day_id": shift.business_day_id,
|
||||||
|
"started_at": _dt(shift.started_at),
|
||||||
|
"ended_at": _dt(shift.ended_at),
|
||||||
|
"starting_cash": shift.starting_cash,
|
||||||
|
"total_collected": total,
|
||||||
|
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||||
|
"is_active": shift.ended_at is None,
|
||||||
|
"notes": shift.notes,
|
||||||
|
"breaks": [
|
||||||
|
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||||
|
for b in shift.breaks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my")
|
||||||
|
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
return _enrich_shift(shift, db) if shift else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start", status_code=status.HTTP_201_CREATED)
|
||||||
|
def start_shift(
|
||||||
|
body: StartShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
target_id = body.waiter_id
|
||||||
|
|
||||||
|
if target_id and target_id != user.id:
|
||||||
|
if user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
||||||
|
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
else:
|
||||||
|
target_id = user.id
|
||||||
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
||||||
|
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not active_day:
|
||||||
|
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
|
||||||
|
|
||||||
|
existing = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == target_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||||
|
|
||||||
|
shift = WaiterShift(
|
||||||
|
waiter_id=target_id,
|
||||||
|
business_day_id=active_day.id,
|
||||||
|
starting_cash=body.starting_cash,
|
||||||
|
)
|
||||||
|
db.add(shift)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/end")
|
||||||
|
def end_shift(
|
||||||
|
body: EndShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
||||||
|
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == user.id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="No active shift found")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
shift.total_collected = compute_shift_total(shift.id, db)
|
||||||
|
shift.ended_at = now
|
||||||
|
if body.notes:
|
||||||
|
shift.notes = body.notes
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
open_break.ended_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
|
||||||
|
def manager_start_shift(
|
||||||
|
body: StartShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
if not body.waiter_id:
|
||||||
|
raise HTTPException(status_code=400, detail="waiter_id is required")
|
||||||
|
|
||||||
|
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
|
||||||
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||||
|
if not active_day:
|
||||||
|
raise HTTPException(status_code=400, detail="No open business day")
|
||||||
|
|
||||||
|
existing = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.waiter_id == body.waiter_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||||
|
|
||||||
|
shift = WaiterShift(
|
||||||
|
waiter_id=body.waiter_id,
|
||||||
|
business_day_id=active_day.id,
|
||||||
|
starting_cash=body.starting_cash,
|
||||||
|
)
|
||||||
|
db.add(shift)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manager/end/{shift_id}")
|
||||||
|
def manager_end_shift(
|
||||||
|
shift_id: int,
|
||||||
|
body: EndShiftRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(
|
||||||
|
WaiterShift.id == shift_id,
|
||||||
|
WaiterShift.ended_at == None,
|
||||||
|
).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Active shift not found")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
shift.total_collected = compute_shift_total(shift.id, db)
|
||||||
|
shift.ended_at = now
|
||||||
|
if body.notes:
|
||||||
|
shift.notes = body.notes
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
open_break.ended_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shift)
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shift_id}/break/start")
|
||||||
|
def start_break(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if shift.ended_at:
|
||||||
|
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if open_break:
|
||||||
|
raise HTTPException(status_code=400, detail="Break already in progress")
|
||||||
|
|
||||||
|
b = ShiftBreak(shift_id=shift_id)
|
||||||
|
db.add(b)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(b)
|
||||||
|
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shift_id}/break/end")
|
||||||
|
def end_break(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
open_break = db.query(ShiftBreak).filter(
|
||||||
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||||
|
).first()
|
||||||
|
if not open_break:
|
||||||
|
raise HTTPException(status_code=404, detail="No active break found")
|
||||||
|
|
||||||
|
open_break.ended_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(open_break)
|
||||||
|
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_shifts(
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
business_day_id: Optional[int] = None,
|
||||||
|
active_only: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(WaiterShift)
|
||||||
|
if waiter_id:
|
||||||
|
q = q.filter(WaiterShift.waiter_id == waiter_id)
|
||||||
|
if business_day_id:
|
||||||
|
q = q.filter(WaiterShift.business_day_id == business_day_id)
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(WaiterShift.ended_at == None)
|
||||||
|
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
||||||
|
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}")
|
||||||
|
def get_shift(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
return _enrich_shift(shift, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}/summary")
|
||||||
|
def get_shift_summary(
|
||||||
|
shift_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||||
|
from models.order import Order
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||||
|
if not shift:
|
||||||
|
raise HTTPException(status_code=404, detail="Shift not found")
|
||||||
|
|
||||||
|
items = db.query(OrderItem).options(
|
||||||
|
joinedload(OrderItem.product),
|
||||||
|
joinedload(OrderItem.order),
|
||||||
|
).filter(
|
||||||
|
OrderItem.paid_in_shift_id == shift_id,
|
||||||
|
OrderItem.status == "paid",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
orders_seen = {}
|
||||||
|
for item in items:
|
||||||
|
oid = item.order_id
|
||||||
|
if oid not in orders_seen:
|
||||||
|
o = item.order
|
||||||
|
orders_seen[oid] = {
|
||||||
|
"order_id": oid,
|
||||||
|
"table_id": o.table_id if o else None,
|
||||||
|
"opened_at": _dt(o.opened_at) if o else None,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
orders_seen[oid]["items"].append({
|
||||||
|
"id": item.id,
|
||||||
|
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit_price": float(item.unit_price),
|
||||||
|
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||||
|
"paid_at": _dt(item.paid_at),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute hours worked
|
||||||
|
started = shift.started_at
|
||||||
|
ended = shift.ended_at
|
||||||
|
duration_minutes = None
|
||||||
|
if started and ended:
|
||||||
|
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||||
|
elif started:
|
||||||
|
from datetime import datetime, timezone as tz
|
||||||
|
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||||
|
|
||||||
|
enriched = _enrich_shift(shift, db)
|
||||||
|
enriched["orders"] = list(orders_seen.values())
|
||||||
|
enriched["duration_minutes"] = duration_minutes
|
||||||
|
return enriched
|
||||||
60
local_backend/routers/sse.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
SSE stream endpoint — one long-lived GET per connected phone.
|
||||||
|
|
||||||
|
Authentication: token passed as query param ?token=<jwt>
|
||||||
|
(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.)
|
||||||
|
|
||||||
|
The client receives a stream of JSON lines:
|
||||||
|
data: {"type": "...", "data": {...}}\n\n
|
||||||
|
|
||||||
|
A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from routers.deps import decode_token
|
||||||
|
from services.sse_bus import subscribe, unsubscribe
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
KEEPALIVE_INTERVAL = 25 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
async def _event_stream(user_id: int):
|
||||||
|
q = await subscribe(user_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
|
||||||
|
yield f"data: {payload}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# keepalive — prevents nginx/proxies from closing idle connections
|
||||||
|
yield ": ping\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await unsubscribe(user_id, q)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def sse_stream(token: str = Query(...)):
|
||||||
|
"""
|
||||||
|
Open an SSE stream for the authenticated user.
|
||||||
|
The phone connects once on login and stays connected.
|
||||||
|
On reconnect (after network drop) it does a full GET first, then reconnects here.
|
||||||
|
"""
|
||||||
|
# decode_token raises HTTPException on invalid/expired — no manual check needed
|
||||||
|
payload = decode_token(token)
|
||||||
|
user_id: int = int(payload["sub"])
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_event_stream(user_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no", # disable nginx buffering
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ from typing import List
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
from schemas.printer import PrinterUpdate, PrinterOut
|
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
||||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import printer_service
|
from services import printer_service
|
||||||
@@ -40,7 +40,16 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
|
|||||||
|
|
||||||
@router.get("/printers", response_model=List[PrinterOut])
|
@router.get("/printers", response_model=List[PrinterOut])
|
||||||
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
return db.query(Printer).filter(Printer.is_active == True).all()
|
return db.query(Printer).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers", response_model=PrinterOut)
|
||||||
|
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = Printer(**body.model_dump())
|
||||||
|
db.add(printer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(printer)
|
||||||
|
return printer
|
||||||
|
|
||||||
|
|
||||||
@router.post("/printers/test")
|
@router.post("/printers/test")
|
||||||
@@ -52,8 +61,17 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
|
|||||||
return {"success": success, "error": error}
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers/test-order")
|
||||||
|
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
|
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
|
||||||
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
||||||
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)):
|
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
if not printer:
|
if not printer:
|
||||||
raise HTTPException(status_code=404, detail="Printer not found")
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
@@ -64,6 +82,16 @@ def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(g
|
|||||||
return printer
|
return printer
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/printers/{printer_id}")
|
||||||
|
def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
|
db.delete(printer)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lock")
|
@router.post("/lock")
|
||||||
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||||
license_state["locked"] = True
|
license_state["locked"] = True
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from schemas.table import (
|
|||||||
TableBatchCreate,
|
TableBatchCreate,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
|||||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||||
sort_order = db.query(TableGroup).count()
|
sort_order = db.query(TableGroup).count()
|
||||||
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
|
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
|
||||||
db.add(group)
|
db.add(group)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
@@ -86,7 +87,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
|||||||
|
|
||||||
active_table_ids = {
|
active_table_ids = {
|
||||||
row[0] for row in db.query(Order.table_id).filter(
|
row[0] for row in db.query(Order.table_id).filter(
|
||||||
Order.status.in_(["open", "partially_paid"])
|
Order.status.in_(["open", "partially_paid", "paid"])
|
||||||
).all()
|
).all()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
|||||||
db.add(table)
|
db.add(table)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(table)
|
db.refresh(table)
|
||||||
|
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
@@ -167,7 +169,7 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
|
|||||||
raise HTTPException(status_code=404, detail="Table not found")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
active_order = db.query(Order).filter(
|
active_order = db.query(Order).filter(
|
||||||
Order.table_id == table_id,
|
Order.table_id == table_id,
|
||||||
Order.status.in_(["open", "partially_paid"])
|
Order.status.in_(["open", "partially_paid", "paid"])
|
||||||
).first()
|
).first()
|
||||||
if active_order:
|
if active_order:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -194,7 +196,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
|
|||||||
raise HTTPException(status_code=404, detail="Table not found")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
active_order = (
|
active_order = (
|
||||||
db.query(Order)
|
db.query(Order)
|
||||||
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
|
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"]))
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from typing import List
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.user import User, AssistantAssignment, WaiterZone
|
from models.user import User, AssistantAssignment, WaiterZone
|
||||||
|
from models.shift import WaiterShift
|
||||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||||
from routers.deps import require_manager
|
from routers.deps import require_manager, get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
|||||||
|
|
||||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/on-shift", response_model=List[UserOut])
|
||||||
|
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||||
|
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
|
||||||
|
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[UserOut])
|
@router.get("/", response_model=List[UserOut])
|
||||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
return db.query(User).filter(User.role == "waiter").all()
|
return db.query(User).filter(User.role == "waiter").all()
|
||||||
@@ -36,7 +44,15 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
|||||||
if db.query(User).filter(User.username == body.username).first():
|
if db.query(User).filter(User.username == body.username).first():
|
||||||
raise HTTPException(status_code=400, detail="Username already exists")
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||||
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
|
new_user = User(
|
||||||
|
username=body.username,
|
||||||
|
pin_hash=pin_hash,
|
||||||
|
role=body.role,
|
||||||
|
is_active=body.is_active,
|
||||||
|
full_name=body.full_name,
|
||||||
|
nickname=body.nickname,
|
||||||
|
mobile_phone=body.mobile_phone,
|
||||||
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
|||||||
14
local_backend/schemas/base.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
from pydantic import PlainSerializer
|
||||||
|
|
||||||
|
# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC.
|
||||||
|
# This serializer appends "Z" so browsers parse them correctly as UTC.
|
||||||
|
UTCDatetime = Annotated[
|
||||||
|
datetime,
|
||||||
|
PlainSerializer(
|
||||||
|
lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(),
|
||||||
|
return_type=str,
|
||||||
|
when_used="json",
|
||||||
|
),
|
||||||
|
]
|
||||||
24
local_backend/schemas/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDayOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
status: str
|
||||||
|
opened_at: UTCDatetime
|
||||||
|
opened_by_id: int
|
||||||
|
closed_at: Optional[UTCDatetime] = None
|
||||||
|
closed_by_id: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenBusinessDayRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CloseBusinessDayRequest(BaseModel):
|
||||||
|
force: bool = False
|
||||||
|
notes: Optional[str] = None
|
||||||
47
local_backend/schemas/flag.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = "#6b7280"
|
||||||
|
text_color: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
text_color: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlagDefOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
emoji: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
text_color: Optional[str] = None
|
||||||
|
sort_order: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FlagAssignmentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
table_id: int
|
||||||
|
flag_id: int
|
||||||
|
flag_def: Optional[FlagDefOut] = None
|
||||||
|
assigned_at: datetime
|
||||||
|
assigned_by: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SetTableFlagsRequest(BaseModel):
|
||||||
|
flag_ids: List[int]
|
||||||
42
local_backend/schemas/message.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
sort_order: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateUpdate(BaseModel):
|
||||||
|
body: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuickTemplateOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
body: str
|
||||||
|
sort_order: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageRequest(BaseModel):
|
||||||
|
body: str
|
||||||
|
target_waiter_ids: List[int] # empty = all active waiters
|
||||||
|
table_ids: Optional[List[int]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class StaffMessageOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
sender_id: int
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
body: str
|
||||||
|
target_waiter_ids: str # raw JSON string — frontend parses
|
||||||
|
table_ids: str
|
||||||
|
created_at: datetime
|
||||||
|
acked_by: List[int] = [] # waiter ids who have acked
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
class SelectedOptionInput(BaseModel):
|
class SelectedOptionInput(BaseModel):
|
||||||
@@ -8,6 +9,9 @@ class SelectedOptionInput(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
price_delta: Optional[float] = None
|
price_delta: Optional[float] = None
|
||||||
extra_cost: Optional[float] = None
|
extra_cost: Optional[float] = None
|
||||||
|
# type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub"
|
||||||
|
# Omitted by old clients — print code falls back gracefully.
|
||||||
|
type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class OrderItemInput(BaseModel):
|
class OrderItemInput(BaseModel):
|
||||||
@@ -40,11 +44,12 @@ class OrderItemOut(BaseModel):
|
|||||||
removed_ingredients: Optional[str] = None
|
removed_ingredients: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
added_at: datetime
|
added_at: UTCDatetime
|
||||||
printed: bool
|
printed: bool
|
||||||
paid_by: Optional[int] = None
|
paid_by: Optional[int] = None
|
||||||
paid_at: Optional[datetime] = None
|
paid_at: Optional[UTCDatetime] = None
|
||||||
payment_method: Optional[str] = None
|
payment_method: Optional[str] = None
|
||||||
|
paid_in_shift_id: Optional[int] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -71,6 +76,13 @@ class PayItemsRequest(BaseModel):
|
|||||||
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
||||||
|
|
||||||
|
|
||||||
|
class OfflinePaymentRequest(BaseModel):
|
||||||
|
uuid: str # client-generated UUID, used for duplicate detection
|
||||||
|
item_ids: List[int]
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline
|
||||||
|
|
||||||
|
|
||||||
class AssignWaiterRequest(BaseModel):
|
class AssignWaiterRequest(BaseModel):
|
||||||
waiter_id: int
|
waiter_id: int
|
||||||
|
|
||||||
@@ -90,7 +102,9 @@ class AuditLogOut(BaseModel):
|
|||||||
amount: Optional[float] = None
|
amount: Optional[float] = None
|
||||||
payment_method: Optional[str] = None
|
payment_method: Optional[str] = None
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: UTCDatetime
|
||||||
|
offline_at: Optional[str] = None
|
||||||
|
is_duplicate: int = 0
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -99,11 +113,12 @@ class OrderOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
table_id: int
|
table_id: int
|
||||||
opened_by: int
|
opened_by: int
|
||||||
opened_at: datetime
|
opened_at: UTCDatetime
|
||||||
status: str
|
status: str
|
||||||
closed_at: Optional[datetime] = None
|
closed_at: Optional[UTCDatetime] = None
|
||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
items: List[OrderItemOut] = []
|
items: List[OrderItemOut] = []
|
||||||
waiters: List[OrderWaiterOut] = []
|
waiters: List[OrderWaiterOut] = []
|
||||||
audit_logs: List[AuditLogOut] = []
|
audit_logs: List[AuditLogOut] = []
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||||
|
|
||||||
|
|
||||||
class PrinterBase(BaseModel):
|
class PrinterBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
ip_address: str
|
ip_address: str
|
||||||
port: int = 9100
|
port: int = 9100
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
protocol: str = "escpos_tcp"
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterCreate(PrinterBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PrinterUpdate(BaseModel):
|
class PrinterUpdate(BaseModel):
|
||||||
@@ -14,6 +22,7 @@ class PrinterUpdate(BaseModel):
|
|||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
protocol: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PrinterOut(PrinterBase):
|
class PrinterOut(PrinterBase):
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ class CategoryCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
general_sort_order: int = 0
|
||||||
|
auto_expanded: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
general_sort_order: Optional[int] = None
|
||||||
|
auto_expanded: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryOut(BaseModel):
|
class CategoryOut(BaseModel):
|
||||||
@@ -20,6 +26,9 @@ class CategoryOut(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
general_sort_order: int = 0
|
||||||
|
auto_expanded: bool = False
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -29,6 +38,42 @@ class CategoryReorderItem(BaseModel):
|
|||||||
sort_order: int
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
class SubcategoryReorderItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
sort_order: int # position among subcategories within the parent
|
||||||
|
|
||||||
|
|
||||||
|
class ParentGeneralReorderItem(BaseModel):
|
||||||
|
id: int # parent category id
|
||||||
|
general_sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick Options ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProductQuickOptionCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
price: float = 0.0
|
||||||
|
allow_multiple: bool = False
|
||||||
|
sort_order: int = 0
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
is_compact: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProductQuickOptionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
name: str
|
||||||
|
price: float = 0.0
|
||||||
|
allow_multiple: bool = False
|
||||||
|
sort_order: int = 0
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
is_compact: bool = False
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Options ──────────────────────────────────────────────────────────────────
|
# ── Options ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class OptionSubChoice(BaseModel):
|
class OptionSubChoice(BaseModel):
|
||||||
@@ -40,6 +85,9 @@ class OptionSubChoice(BaseModel):
|
|||||||
class ProductOptionBase(BaseModel):
|
class ProductOptionBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
allow_multiple: bool = False
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductOptionCreate(ProductOptionBase):
|
class ProductOptionCreate(ProductOptionBase):
|
||||||
@@ -64,7 +112,10 @@ class ProductOptionOut(ProductOptionBase):
|
|||||||
'product_id': data.product_id,
|
'product_id': data.product_id,
|
||||||
'name': data.name,
|
'name': data.name,
|
||||||
'extra_cost': data.extra_cost,
|
'extra_cost': data.extra_cost,
|
||||||
|
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||||
'sub_choices': parsed,
|
'sub_choices': parsed,
|
||||||
|
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||||
|
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -74,6 +125,8 @@ class ProductOptionOut(ProductOptionBase):
|
|||||||
class ProductIngredientBase(BaseModel):
|
class ProductIngredientBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
extra_cost: float = 0.0
|
extra_cost: float = 0.0
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductIngredientCreate(ProductIngredientBase):
|
class ProductIngredientCreate(ProductIngredientBase):
|
||||||
@@ -155,6 +208,8 @@ class PreferenceSetCreate(BaseModel):
|
|||||||
choices: List[PreferenceChoiceCreate] = []
|
choices: List[PreferenceChoiceCreate] = []
|
||||||
default_choice_index: Optional[int] = None # index into choices (0-based)
|
default_choice_index: Optional[int] = None # index into choices (0-based)
|
||||||
shared_subset: Optional[SharedSubset] = None
|
shared_subset: Optional[SharedSubset] = None
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class PreferenceSetOut(BaseModel):
|
class PreferenceSetOut(BaseModel):
|
||||||
@@ -164,6 +219,8 @@ class PreferenceSetOut(BaseModel):
|
|||||||
choices: List[PreferenceChoiceOut] = []
|
choices: List[PreferenceChoiceOut] = []
|
||||||
default_choice_id: Optional[int] = None
|
default_choice_id: Optional[int] = None
|
||||||
shared_subset: Optional[SharedSubset] = None
|
shared_subset: Optional[SharedSubset] = None
|
||||||
|
is_favorite: bool = False
|
||||||
|
favorite_sort_order: int = 0
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -186,6 +243,8 @@ class PreferenceSetOut(BaseModel):
|
|||||||
'choices': list(data.choices),
|
'choices': list(data.choices),
|
||||||
'default_choice_id': data.default_choice_id,
|
'default_choice_id': data.default_choice_id,
|
||||||
'shared_subset': parsed,
|
'shared_subset': parsed,
|
||||||
|
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||||
|
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -197,11 +256,13 @@ class ProductBase(BaseModel):
|
|||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: float
|
base_price: float
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
|
lifecycle_status: str = "active"
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
|
quick_options: List[ProductQuickOptionCreate] = []
|
||||||
options: List[ProductOptionCreate] = []
|
options: List[ProductOptionCreate] = []
|
||||||
ingredients: List[ProductIngredientCreate] = []
|
ingredients: List[ProductIngredientCreate] = []
|
||||||
preference_sets: List[PreferenceSetCreate] = []
|
preference_sets: List[PreferenceSetCreate] = []
|
||||||
@@ -212,8 +273,10 @@ class ProductUpdate(BaseModel):
|
|||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: Optional[float] = None
|
base_price: Optional[float] = None
|
||||||
is_available: Optional[bool] = None
|
is_available: Optional[bool] = None
|
||||||
|
lifecycle_status: Optional[str] = None
|
||||||
printer_zone_id: Optional[int] = None
|
printer_zone_id: Optional[int] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||||
options: Optional[List[ProductOptionCreate]] = None
|
options: Optional[List[ProductOptionCreate]] = None
|
||||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||||
@@ -226,6 +289,7 @@ class ProductReorderItem(BaseModel):
|
|||||||
|
|
||||||
class ProductOut(ProductBase):
|
class ProductOut(ProductBase):
|
||||||
id: int
|
id: int
|
||||||
|
quick_options: List[ProductQuickOptionOut] = []
|
||||||
options: List[ProductOptionOut] = []
|
options: List[ProductOptionOut] = []
|
||||||
ingredients: List[ProductIngredientOut] = []
|
ingredients: List[ProductIngredientOut] = []
|
||||||
preference_sets: List[PreferenceSetOut] = []
|
preference_sets: List[PreferenceSetOut] = []
|
||||||
|
|||||||
16
local_backend/schemas/settings.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class PosSettingOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
updated_at: Optional[UTCDatetime] = None
|
||||||
|
updated_by_id: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettingRequest(BaseModel):
|
||||||
|
value: str
|
||||||
38
local_backend/schemas/shift.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftBreakOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
shift_id: int
|
||||||
|
started_at: UTCDatetime
|
||||||
|
ended_at: Optional[UTCDatetime] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WaiterShiftOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
waiter_id: int
|
||||||
|
waiter_name: Optional[str] = None
|
||||||
|
business_day_id: int
|
||||||
|
started_at: UTCDatetime
|
||||||
|
ended_at: Optional[UTCDatetime] = None
|
||||||
|
starting_cash: Optional[float] = None
|
||||||
|
total_collected: Optional[float] = None
|
||||||
|
net_to_deliver: Optional[float] = None
|
||||||
|
is_active: bool = True
|
||||||
|
notes: Optional[str] = None
|
||||||
|
breaks: List[ShiftBreakOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StartShiftRequest(BaseModel):
|
||||||
|
starting_cash: Optional[float] = None
|
||||||
|
waiter_id: Optional[int] = None # manager use: start shift for a specific waiter
|
||||||
|
|
||||||
|
|
||||||
|
class EndShiftRequest(BaseModel):
|
||||||
|
notes: Optional[str] = None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from schemas.base import UTCDatetime
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
@@ -36,7 +37,7 @@ class WaiterZoneOut(BaseModel):
|
|||||||
|
|
||||||
class UserOut(UserBase):
|
class UserOut(UserBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: UTCDatetime
|
||||||
zone_assignments: List[WaiterZoneOut] = []
|
zone_assignments: List[WaiterZoneOut] = []
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -54,6 +55,6 @@ class AssistantAssignmentOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
primary_waiter_id: int
|
primary_waiter_id: int
|
||||||
assistant_waiter_id: int
|
assistant_waiter_id: int
|
||||||
assigned_at: datetime
|
assigned_at: UTCDatetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from database import SessionLocal
|
|||||||
from models.order import Order, OrderItem, PrintLog
|
from models.order import Order, OrderItem, PrintLog
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
|
from models.settings import PosSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,19 +47,115 @@ def _raw_text(p: Network, text: str):
|
|||||||
p._raw(_gr(text))
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
|
||||||
def _divider(p: Network):
|
_DIVIDER_CHARS = {
|
||||||
|
"dash": "-",
|
||||||
|
"equals": "=",
|
||||||
|
"star": "*",
|
||||||
|
"empty": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PRINT_SETTING_KEYS = [
|
||||||
|
"print.ticket_mode",
|
||||||
|
"print.divider_style",
|
||||||
|
"print.font_order_number",
|
||||||
|
"print.font_meta",
|
||||||
|
"print.font_item_name",
|
||||||
|
"print.font_quick",
|
||||||
|
"print.font_pref",
|
||||||
|
"print.font_extra",
|
||||||
|
"print.font_ingredient",
|
||||||
|
"print.font_item_note",
|
||||||
|
"print.font_order_note",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRINT_SETTING_DEFAULTS = {
|
||||||
|
"print.ticket_mode": "detailed",
|
||||||
|
"print.divider_style": "dash",
|
||||||
|
"print.font_order_number": "48:1:0",
|
||||||
|
"print.font_meta": "0:0:0",
|
||||||
|
"print.font_item_name": "16:1:0",
|
||||||
|
"print.font_quick": "0:0:0",
|
||||||
|
"print.font_pref": "0:0:0",
|
||||||
|
"print.font_extra": "0:0:0",
|
||||||
|
"print.font_ingredient": "0:0:0",
|
||||||
|
"print.font_item_note": "0:0:0",
|
||||||
|
"print.font_order_note": "0:1:0",
|
||||||
|
}
|
||||||
|
|
||||||
|
# SIZE byte values (ESC ! base, no bold bit):
|
||||||
|
# 0 = normal
|
||||||
|
# 16 = double-height (bit4)
|
||||||
|
# 32 = double-width (bit5)
|
||||||
|
# 48 = double-height + double-width (bits 4+5)
|
||||||
|
# Bold applied via ESC E, caps applied in software before encoding.
|
||||||
|
|
||||||
|
def _decode_font(value: str) -> tuple[int, bool, bool]:
|
||||||
|
"""Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag)."""
|
||||||
|
try:
|
||||||
|
parts = str(value).split(":")
|
||||||
|
size = int(parts[0])
|
||||||
|
bold = len(parts) > 1 and parts[1] == "1"
|
||||||
|
caps = len(parts) > 2 and parts[2] == "1"
|
||||||
|
return size, bold, caps
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0, False, False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_print_settings(db: Session) -> dict:
|
||||||
|
rows = db.query(PosSettings).filter(
|
||||||
|
PosSettings.key.in_(_PRINT_SETTING_KEYS)
|
||||||
|
).all()
|
||||||
|
settings = dict(_PRINT_SETTING_DEFAULTS)
|
||||||
|
for row in rows:
|
||||||
|
settings[row.key] = row.value
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _divider(p: Network, style: str = "dash"):
|
||||||
|
char = _DIVIDER_CHARS.get(style, "-")
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(_gr("-" * LINE_WIDTH + "\n"))
|
if char:
|
||||||
|
p._raw(_gr(char * LINE_WIDTH + "\n"))
|
||||||
|
else:
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
|
||||||
def _item_line(name: str, qty: int) -> str:
|
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
||||||
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
|
"""Build a dot-leader line ending with 'xN'.
|
||||||
qty_str = str(qty)
|
line_width must reflect the effective width at the chosen font size
|
||||||
gap = LINE_WIDTH - len(name) - len(qty_str)
|
(double-width fonts halve the available char count to 24)."""
|
||||||
if gap < 3:
|
suffix = f"x{qty}"
|
||||||
return f"{name} {qty_str}"
|
available = line_width - len(name) - len(suffix)
|
||||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
if available < 2:
|
||||||
return f"{name}{dots}{qty_str}"
|
# Name alone is too long — put qty on same line with a single space
|
||||||
|
return f"{name} {suffix}"
|
||||||
|
dots = (". " * ((available // 2) + 1))[:available]
|
||||||
|
return f"{name}{dots}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_font(p: Network, size: int, bold: bool):
|
||||||
|
p._raw(bytes([0x1b, 0x21, size]))
|
||||||
|
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_font(p: Network):
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\x1b\x45\x00')
|
||||||
|
|
||||||
|
|
||||||
|
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
|
||||||
|
align: bytes = b'\x1b\x61\x00'):
|
||||||
|
"""Apply font, optionally capitalize, print text + newline, reset font."""
|
||||||
|
p._raw(align)
|
||||||
|
_apply_font(p, size, bold)
|
||||||
|
out = text.upper() if caps else text
|
||||||
|
_raw_text(p, out + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _greek_date(dt: datetime.datetime) -> str:
|
||||||
|
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
|
||||||
|
return dt.strftime("%H:%M %d-%m-%Y")
|
||||||
|
|
||||||
|
|
||||||
def check_printer(ip: str, port: int) -> bool:
|
def check_printer(ip: str, port: int) -> bool:
|
||||||
@@ -73,7 +170,19 @@ def check_printer(ip: str, port: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_spoof_mode() -> bool:
|
||||||
|
"""Stateless check — opens its own DB session. For use outside route_and_print."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return _is_spoof_mode(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping test print for %s", name)
|
||||||
|
return True, ""
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
@@ -91,71 +200,368 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
|
||||||
|
"""Print a fake order using the current font/layout settings — for settings preview."""
|
||||||
|
if _is_spoof_mode(db):
|
||||||
|
logger.info("Spoof printing ON — dropping test order print")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# ── Fake data structures (no DB writes) ──────────────────────────────────
|
||||||
|
class _Table:
|
||||||
|
label = "O2"
|
||||||
|
number = 2
|
||||||
|
|
||||||
|
class _User:
|
||||||
|
nickname = "bonamin"
|
||||||
|
username = "bonamin"
|
||||||
|
|
||||||
|
class _Order:
|
||||||
|
id = 99
|
||||||
|
table = _Table()
|
||||||
|
opener = _User()
|
||||||
|
table_id = 2
|
||||||
|
opened_by = 1
|
||||||
|
notes = "Χωρις καψαλισμα παρακαλω"
|
||||||
|
|
||||||
|
class _Item:
|
||||||
|
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
|
||||||
|
self.product_id = product_id
|
||||||
|
self.quantity = quantity
|
||||||
|
self.selected_options = selected_options
|
||||||
|
self.removed_ingredients = removed_ingredients
|
||||||
|
self.notes = notes
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
items = [
|
||||||
|
# Item 1: Freddo Espresso — quick options + preference + note
|
||||||
|
_Item(
|
||||||
|
product_id=1001,
|
||||||
|
quantity=2,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
|
||||||
|
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
|
||||||
|
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=None,
|
||||||
|
notes="Πολυ κρυο παρακαλω",
|
||||||
|
),
|
||||||
|
# Item 2: Club Sandwich — extra with sub + removed ingredients
|
||||||
|
_Item(
|
||||||
|
product_id=1002,
|
||||||
|
quantity=1,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||||
|
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||||
|
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||||
|
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||||
|
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
|
||||||
|
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
|
||||||
|
notes=None,
|
||||||
|
),
|
||||||
|
# Item 3: Margherita — quick + extra + removed
|
||||||
|
_Item(
|
||||||
|
product_id=1003,
|
||||||
|
quantity=3,
|
||||||
|
selected_options=_json.dumps([
|
||||||
|
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||||
|
]),
|
||||||
|
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
|
||||||
|
notes=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Patch product lookup so _print_kitchen_ticket gets real names
|
||||||
|
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
|
||||||
|
|
||||||
|
# Monkey-patch db.query for Product only inside this call
|
||||||
|
_orig_query = db.query
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, model):
|
||||||
|
self._model = model
|
||||||
|
self._filter_id = None
|
||||||
|
def filter(self, *args):
|
||||||
|
# extract id from the filter expression value
|
||||||
|
for arg in args:
|
||||||
|
try:
|
||||||
|
self._filter_id = arg.right.value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self
|
||||||
|
def first(self):
|
||||||
|
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
|
||||||
|
class _P:
|
||||||
|
name = _FAKE_NAMES[self._filter_id]
|
||||||
|
return _P()
|
||||||
|
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
|
||||||
|
|
||||||
|
class _PatchedDB:
|
||||||
|
def query(self, model):
|
||||||
|
from models.product import Product as _Product
|
||||||
|
if model is _Product:
|
||||||
|
return _FakeQuery(model)
|
||||||
|
return _orig_query(model)
|
||||||
|
# delegate everything else to real db
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(db, name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
|
||||||
|
p.close()
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Test order print failed for %s:%s — %s", ip, port, e)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
# ── Receipt formatting ───────────────────────────────────────────────────────
|
# ── Receipt formatting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_options(item: OrderItem) -> dict:
|
||||||
|
"""
|
||||||
|
Parse selected_options JSON into grouped dict:
|
||||||
|
{ 'quick': [(name, qty)], 'pref': [(name, sub|None)],
|
||||||
|
'extra': [(name, sub|None, qty)], 'unknown': [name] }
|
||||||
|
Falls back gracefully when type tags are absent (old data).
|
||||||
|
"""
|
||||||
|
result = {"quick": [], "pref": [], "extra": [], "unknown": []}
|
||||||
|
if not item.selected_options:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(item.selected_options)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return result
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(raw):
|
||||||
|
entry = raw[i]
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
name = entry.get("name") or ""
|
||||||
|
etype = entry.get("type")
|
||||||
|
|
||||||
|
# Peek at next entry to collect sub-choice
|
||||||
|
sub = None
|
||||||
|
if i + 1 < len(raw):
|
||||||
|
nxt = raw[i + 1]
|
||||||
|
if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"):
|
||||||
|
sub = nxt.get("name") or ""
|
||||||
|
i += 1 # consume sub
|
||||||
|
|
||||||
|
if etype == "quick":
|
||||||
|
# Collapse repeated quick entries into a single (name, qty) tuple
|
||||||
|
existing = next((q for q in result["quick"] if q[0] == name), None)
|
||||||
|
if existing:
|
||||||
|
result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1)
|
||||||
|
else:
|
||||||
|
result["quick"].append((name, 1))
|
||||||
|
elif etype == "pref":
|
||||||
|
result["pref"].append((name, sub))
|
||||||
|
elif etype == "extra":
|
||||||
|
# Collapse repeated extra entries (same name+sub) → (name, sub, qty)
|
||||||
|
existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None)
|
||||||
|
if existing:
|
||||||
|
result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1)
|
||||||
|
else:
|
||||||
|
result["extra"].append((name, sub, 1))
|
||||||
|
else:
|
||||||
|
# Legacy data without type tag — treat as unknown, display plainly
|
||||||
|
if name:
|
||||||
|
result["unknown"].append(name + (f" · {sub}" if sub else ""))
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
||||||
# Header
|
cfg = _load_print_settings(db)
|
||||||
p._raw(b'\x1b\x61\x01')
|
mode = cfg.get("print.ticket_mode", "detailed")
|
||||||
p._raw(b'\x1b\x21\x38') # bold + double height + double width
|
div = cfg.get("print.divider_style", "dash")
|
||||||
_raw_text(p, f"Παραγγελια #{order.id}\n")
|
compact = (mode == "compact")
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p)
|
|
||||||
|
|
||||||
# Meta
|
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
|
||||||
|
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
|
||||||
|
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
|
||||||
|
sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
|
||||||
|
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
|
||||||
|
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
|
||||||
|
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
|
||||||
|
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
|
||||||
|
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
|
||||||
|
|
||||||
|
# Resolve display names
|
||||||
|
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
|
||||||
|
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
|
||||||
|
now_str = _greek_date(datetime.datetime.now())
|
||||||
|
|
||||||
|
# ── COMPACT header — single line ────────────────────────────────────────
|
||||||
|
if compact:
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
|
_apply_font(p, sz_ord, b_ord)
|
||||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
||||||
_raw_text(p, f"Date: {now}\n")
|
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
||||||
_raw_text(p, f"Table: {order.table_id}\n")
|
_reset_font(p)
|
||||||
_raw_text(p, f"Waiter: {order.opened_by}\n")
|
_divider(p, div)
|
||||||
p._raw(b'\x1b\x21\x00')
|
|
||||||
_divider(p)
|
# ── DETAILED header ──────────────────────────────────────────────────────
|
||||||
|
else:
|
||||||
|
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
||||||
|
align=b'\x1b\x61\x01')
|
||||||
|
_divider(p, div)
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
_apply_font(p, sz_meta, b_meta)
|
||||||
|
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
|
||||||
|
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
||||||
|
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
||||||
|
_reset_font(p)
|
||||||
|
_divider(p, div)
|
||||||
|
|
||||||
|
# ── Items ────────────────────────────────────────────────────────────────
|
||||||
|
# Double-width fonts halve the effective character width
|
||||||
|
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
|
||||||
|
|
||||||
# Items
|
|
||||||
for item in items:
|
for item in items:
|
||||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
name = product.name if product else f"Product #{item.product_id}"
|
raw_name = product.name if product else f"Product #{item.product_id}"
|
||||||
|
item_name = raw_name.upper() if c_item else raw_name
|
||||||
|
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x45\x01') # bold on
|
_apply_font(p, sz_item, b_item)
|
||||||
_raw_text(p, _item_line(name, item.quantity) + "\n")
|
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
||||||
p._raw(b'\x1b\x45\x00') # bold off
|
_reset_font(p)
|
||||||
|
|
||||||
|
opts = _parse_options(item)
|
||||||
|
|
||||||
|
# Quick options (* marker)
|
||||||
|
if opts["quick"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, qty in opts["quick"]:
|
||||||
|
n = name.upper() if c_qk else name
|
||||||
|
parts.append(f"{n} x{qty}" if qty > 1 else n)
|
||||||
|
_apply_font(p, sz_qk, b_qk)
|
||||||
|
_raw_text(p, "* " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, qty in opts["quick"]:
|
||||||
|
n = name.upper() if c_qk else name
|
||||||
|
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
|
||||||
|
_apply_font(p, sz_qk, b_qk)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Preferences (> marker)
|
||||||
|
if opts["pref"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, sub in opts["pref"]:
|
||||||
|
n = name.upper() if c_pr else name
|
||||||
|
s = (sub.upper() if c_pr else sub) if sub else None
|
||||||
|
parts.append(f"{n} · {s}" if s else n)
|
||||||
|
_apply_font(p, sz_pr, b_pr)
|
||||||
|
_raw_text(p, "> " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, sub in opts["pref"]:
|
||||||
|
n = name.upper() if c_pr else name
|
||||||
|
s = (sub.upper() if c_pr else sub) if sub else None
|
||||||
|
line = f"> {n} · {s}" if s else f"> {n}"
|
||||||
|
_apply_font(p, sz_pr, b_pr)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Extras (+ marker)
|
||||||
|
if opts["extra"]:
|
||||||
|
if compact:
|
||||||
|
parts = []
|
||||||
|
for name, sub, qty in opts["extra"]:
|
||||||
|
n = name.upper() if c_ex else name
|
||||||
|
s = (sub.upper() if c_ex else sub) if sub else None
|
||||||
|
part = f"{n} · {s}" if s else n
|
||||||
|
if qty > 1:
|
||||||
|
part += f" · x{qty}"
|
||||||
|
parts.append(part)
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, "+ " + " | ".join(parts) + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
else:
|
||||||
|
for name, sub, qty in opts["extra"]:
|
||||||
|
n = name.upper() if c_ex else name
|
||||||
|
s = (sub.upper() if c_ex else sub) if sub else None
|
||||||
|
line = f"+ {n}"
|
||||||
|
if s:
|
||||||
|
line += f" · {s}"
|
||||||
|
if qty > 1:
|
||||||
|
line += f" · x{qty}"
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, line + "\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Legacy untagged options
|
||||||
|
for entry in opts["unknown"]:
|
||||||
|
_apply_font(p, sz_ex, b_ex)
|
||||||
|
_raw_text(p, f"+ {entry}\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
|
# Removed ingredients (- marker)
|
||||||
if item.removed_ingredients:
|
if item.removed_ingredients:
|
||||||
try:
|
try:
|
||||||
removed_ids = json.loads(item.removed_ingredients)
|
removed = json.loads(item.removed_ingredients)
|
||||||
if removed_ids:
|
if removed:
|
||||||
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
|
names = [n.upper() if c_ing else n for n in removed]
|
||||||
except (json.JSONDecodeError, TypeError):
|
joined = " · ".join(names)
|
||||||
pass
|
_apply_font(p, sz_ing, b_ing)
|
||||||
|
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
|
||||||
if item.selected_options:
|
_reset_font(p)
|
||||||
try:
|
|
||||||
option_ids = json.loads(item.selected_options)
|
|
||||||
if option_ids:
|
|
||||||
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Per-item note
|
||||||
if item.notes:
|
if item.notes:
|
||||||
_raw_text(p, f" (i) {item.notes}\n")
|
note_text = item.notes.upper() if c_note else item.notes
|
||||||
|
_apply_font(p, sz_note, b_note)
|
||||||
|
if compact:
|
||||||
|
_raw_text(p, f"! {note_text}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"\n(!) {note_text}\n\n")
|
||||||
|
_reset_font(p)
|
||||||
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
# Blank line between items in detailed mode
|
||||||
|
if not compact:
|
||||||
|
p._raw(b'\n')
|
||||||
|
|
||||||
_divider(p)
|
_divider(p, div)
|
||||||
|
|
||||||
|
# Order-level notes
|
||||||
if order.notes:
|
if order.notes:
|
||||||
p._raw(b'\x1b\x21\x30')
|
note_text = order.notes.upper() if c_onote else order.notes
|
||||||
_raw_text(p, "Σημειωσεις:\n")
|
_apply_font(p, sz_onote, b_onote)
|
||||||
p._raw(b'\x1b\x21\x10')
|
_raw_text(p, f"Σημ: {note_text}\n")
|
||||||
_raw_text(p, f"{order.notes}\n")
|
_reset_font(p)
|
||||||
p._raw(b'\x1b\x21\x00')
|
if not compact:
|
||||||
_divider(p)
|
_divider(p, div)
|
||||||
|
|
||||||
|
# Footer (detailed only)
|
||||||
|
if not compact:
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, "Τελος Παραγγελιας\n")
|
_raw_text(p, "Τελος Παραγγελιας\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
p.cut()
|
p.cut()
|
||||||
|
|
||||||
@@ -164,6 +570,9 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
|
|
||||||
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
||||||
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping waiter report print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -222,6 +631,9 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
|
|
||||||
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
||||||
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping printer report print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -282,6 +694,9 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
|||||||
|
|
||||||
def print_order_receipt(ip: str, port: int, receipt: dict):
|
def print_order_receipt(ip: str, port: int, receipt: dict):
|
||||||
"""Print a manager-triggered order receipt."""
|
"""Print a manager-triggered order receipt."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping order receipt print")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
@@ -327,6 +742,65 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
|
|||||||
logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e)
|
logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
|
def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
||||||
|
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
||||||
|
if is_spoof_mode():
|
||||||
|
logger.info("Spoof printing ON — dropping order synopsis print")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Τραπεζι: {synopsis['table_name']}\n")
|
||||||
|
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
|
||||||
|
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
|
||||||
|
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
|
||||||
|
|
||||||
|
if active_items:
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
for item in active_items:
|
||||||
|
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
||||||
|
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
if paid_items:
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
for item in paid_items:
|
||||||
|
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
||||||
|
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"ΣΥΝΟΛΟ: {synopsis['total']:.2f}e\n")
|
||||||
|
if synopsis.get('paid_total', 0) > 0:
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"Πληρωμενο: {synopsis['paid_total']:.2f}e\n")
|
||||||
|
_raw_text(p, f"Εκκρεμει: {synopsis['remaining']:.2f}e\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
||||||
|
|
||||||
|
|
||||||
# ── Routing logic ────────────────────────────────────────────────────────────
|
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def route_and_print(order_id: int, item_ids: List[int]):
|
def route_and_print(order_id: int, item_ids: List[int]):
|
||||||
@@ -352,7 +826,21 @@ def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> Lis
|
|||||||
return _do_route_and_print(order_id, item_ids, db)
|
return _do_route_and_print(order_id, item_ids, db)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_spoof_mode(db: Session) -> bool:
|
||||||
|
row = db.query(PosSettings).filter(PosSettings.key == "dev.spoof_printing").first()
|
||||||
|
return row is not None and row.value == "true"
|
||||||
|
|
||||||
|
|
||||||
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||||
|
if _is_spoof_mode(db):
|
||||||
|
logger.info("Spoof printing ON — dropping print job for order %s", order_id)
|
||||||
|
for item_id in item_ids:
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||||
|
if item:
|
||||||
|
item.printed = True
|
||||||
|
db.commit()
|
||||||
|
return [{"printer_name": "spoof", "success": True, "error": None}]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
order = db.query(Order).filter(Order.id == order_id).first()
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
|||||||
84
local_backend/services/sse_bus.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
SSE Event Bus — in-memory broadcaster for Server-Sent Events.
|
||||||
|
|
||||||
|
All routers import `broadcast_sync()` to push events from sync routes.
|
||||||
|
The SSE endpoint imports `subscribe()` / `unsubscribe()` to manage per-client queues.
|
||||||
|
|
||||||
|
Event shape (JSON-serialisable dict):
|
||||||
|
{ "type": "<event_type>", "data": { ... } }
|
||||||
|
|
||||||
|
Supported event types:
|
||||||
|
order_updated — order created / item added / transferred / merged
|
||||||
|
order_paid — items paid on an order
|
||||||
|
order_closed — order closed or cancelled
|
||||||
|
table_list_changed — table added/removed
|
||||||
|
table_flags_changed — flags set/cleared on a table
|
||||||
|
message_sent — new staff message (targeted or broadcast)
|
||||||
|
shift_changed — shift started / ended by manager
|
||||||
|
business_day_changed — business day opened / closed
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Dict, Set
|
||||||
|
|
||||||
|
# Captured once at startup by init_loop() called from lifespan.
|
||||||
|
# Sync route threads use this to schedule coroutines safely.
|
||||||
|
_main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
# waiter_id → set of asyncio.Queue (one per SSE connection for that user)
|
||||||
|
_queues: Dict[int, Set[asyncio.Queue]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
"""Call once from the FastAPI lifespan (async context) to capture the event loop."""
|
||||||
|
global _main_loop
|
||||||
|
_main_loop = loop
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe(user_id: int) -> asyncio.Queue:
|
||||||
|
q: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||||||
|
if user_id not in _queues:
|
||||||
|
_queues[user_id] = set()
|
||||||
|
_queues[user_id].add(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
|
||||||
|
if user_id in _queues:
|
||||||
|
_queues[user_id].discard(q)
|
||||||
|
if not _queues[user_id]:
|
||||||
|
del _queues[user_id]
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
|
||||||
|
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
|
||||||
|
"""
|
||||||
|
if _main_loop is None:
|
||||||
|
return
|
||||||
|
_main_loop.call_soon_threadsafe(
|
||||||
|
_main_loop.create_task,
|
||||||
|
broadcast(event_type, data, user_ids=user_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Push an event to connected clients.
|
||||||
|
user_ids=None → broadcast to ALL connected users
|
||||||
|
user_ids=[...] → send only to those specific user IDs
|
||||||
|
"""
|
||||||
|
payload = json.dumps({"type": event_type, "data": data})
|
||||||
|
targets = (
|
||||||
|
{uid: qs for uid, qs in _queues.items() if uid in user_ids}
|
||||||
|
if user_ids is not None
|
||||||
|
else dict(_queues)
|
||||||
|
)
|
||||||
|
for qs in targets.values():
|
||||||
|
for q in list(qs):
|
||||||
|
try:
|
||||||
|
q.put_nowait(payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass # slow client — drop rather than block
|
||||||
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 useAuthStore from './store/authStore'
|
||||||
import AppLayout from './layouts/AppLayout'
|
import AppLayout from './layouts/AppLayout'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import OperationsPage from './pages/OperationsPage'
|
||||||
import OrderDetailPage from './pages/OrderDetailPage'
|
|
||||||
import ProductsPage from './pages/ProductsPage'
|
|
||||||
import WaitersPage from './pages/WaitersPage'
|
|
||||||
import TablesPage from './pages/TablesPage'
|
import TablesPage from './pages/TablesPage'
|
||||||
|
import OrderDetailPage from './pages/OrderDetailPage'
|
||||||
|
import ManagementPage from './pages/ManagementPage'
|
||||||
import ReportsPage from './pages/ReportsPage'
|
import ReportsPage from './pages/ReportsPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/Settings/SettingsPage'
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const token = useAuthStore(s => s.token)
|
const token = useAuthStore(s => s.token)
|
||||||
@@ -21,12 +20,12 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/operations" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
|
||||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
<Route path="operations" element={<OperationsPage />} />
|
||||||
<Route path="products" element={<ProductsPage />} />
|
|
||||||
<Route path="waiters" element={<WaitersPage />} />
|
|
||||||
<Route path="tables" element={<TablesPage />} />
|
<Route path="tables" element={<TablesPage />} />
|
||||||
|
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||||
|
<Route path="management" element={<ManagementPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</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 })
|
const client = axios.create({ baseURL: BASE_URL })
|
||||||
|
|
||||||
client.interceptors.request.use(config => {
|
client.interceptors.request.use(config => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('manager_token')
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
@@ -14,7 +14,10 @@ client.interceptors.response.use(
|
|||||||
res => res,
|
res => res,
|
||||||
err => {
|
err => {
|
||||||
if (err.response?.status === 401) {
|
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'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import { NavLink } from 'react-router-dom'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/dashboard', icon: '📊', label: 'Dashboard' },
|
{ to: '/operations', icon: '📊', label: 'Διοίκηση' },
|
||||||
{ to: '/tables', icon: '🪑', label: 'Τραπέζια' },
|
{ to: '/tables', icon: '🪑', label: 'Τραπέζια' },
|
||||||
{ to: '/products', icon: '📦', label: 'Προϊόντα' },
|
|
||||||
{ to: '/waiters', icon: '👥', label: 'Σερβιτόροι' },
|
|
||||||
{ to: '/reports', icon: '📋', label: 'Αναφορές' },
|
{ to: '/reports', icon: '📋', label: 'Αναφορές' },
|
||||||
|
{ to: '/management', icon: '🗂️', label: 'Διαχείριση' },
|
||||||
{ to: '/settings', 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;
|
@apply bg-primary-700 hover:bg-primary-800 text-white;
|
||||||
}
|
}
|
||||||
.btn-secondary {
|
.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 {
|
.btn-danger {
|
||||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||||
|
|||||||
@@ -1,38 +1,219 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import Sidebar from '../components/Sidebar'
|
import Sidebar from '../components/Sidebar'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
|
|
||||||
export default function AppLayout() {
|
const SETTINGS_KEY = 'manager_lock_timeout'
|
||||||
const { user, token, login, logout } = useAuthStore()
|
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||||
const [clock, setClock] = useState(new Date())
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (token && !user) {
|
if (token && !user) {
|
||||||
client.get('/auth/me').then(r => login(r.data, token)).catch(() => logout())
|
client.get('/auth/me').then(r => login(r.data, token)).catch(() => logout())
|
||||||
}
|
}
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
|
// ── Clock ────────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setClock(new Date()), 1000)
|
const id = setInterval(() => setClock(new Date()), 1000)
|
||||||
return () => clearInterval(id)
|
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 timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const displayName = user?.username || savedUsername || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Lock overlay — rendered on top of everything */}
|
||||||
|
{locked && displayName && (
|
||||||
|
<LockScreen username={displayName} onUnlock={handleUnlock} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-gray-200 shrink-0">
|
<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>
|
<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>
|
<span className="text-sm text-gray-500">{user?.username}</span>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={handleLogout}
|
||||||
className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
|
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
@@ -49,6 +49,7 @@ const EVENT_LABELS = {
|
|||||||
ORDER_OPENED: 'Άνοιγμα',
|
ORDER_OPENED: 'Άνοιγμα',
|
||||||
ITEMS_ADDED: 'Προσθήκη',
|
ITEMS_ADDED: 'Προσθήκη',
|
||||||
PAYMENT: 'Πληρωμή',
|
PAYMENT: 'Πληρωμή',
|
||||||
|
PAYMENT_OFFLINE: 'Πληρωμή (Offline)',
|
||||||
ORDER_CLOSED: 'Κλείσιμο',
|
ORDER_CLOSED: 'Κλείσιμο',
|
||||||
ORDER_CANCELLED: 'Ακύρωση',
|
ORDER_CANCELLED: 'Ακύρωση',
|
||||||
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
ITEM_CANCELLED: 'Ακύρωση αντ.',
|
||||||
@@ -60,30 +61,47 @@ function AuditTab({ order, waiterMap }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{order.audit_logs.map(log => (
|
{order.audit_logs.map(log => {
|
||||||
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
|
const isDuplicate = log.is_duplicate === 1 || log.is_duplicate === true
|
||||||
|
const isPayment = log.event_type === 'PAYMENT' || log.event_type === 'PAYMENT_OFFLINE'
|
||||||
|
const badgeClass = isDuplicate
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: isPayment ? 'bg-green-100 text-green-700'
|
||||||
|
: log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600'
|
||||||
|
: log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600'
|
||||||
|
: 'bg-blue-100 text-blue-700'
|
||||||
|
// Show offline_at (real payment time) when available, else server created_at
|
||||||
|
const displayTime = log.offline_at ? formatDate(log.offline_at) : formatDate(log.created_at)
|
||||||
|
return (
|
||||||
|
<div key={log.id} className={`flex items-start gap-3 px-4 py-3 ${isDuplicate ? 'bg-red-50' : ''}`}>
|
||||||
<div className="shrink-0 mt-0.5">
|
<div className="shrink-0 mt-0.5">
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${badgeClass}`}>
|
||||||
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
|
|
||||||
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
|
|
||||||
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
|
|
||||||
'bg-blue-100 text-blue-700'
|
|
||||||
}`}>
|
|
||||||
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
{EVENT_LABELS[log.event_type] ?? log.event_type}
|
||||||
</span>
|
</span>
|
||||||
|
{isDuplicate && (
|
||||||
|
<span className="block text-xs text-red-500 font-semibold mt-0.5">ΔΙΠΛΗ</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
<div className="flex-1 min-w-0 text-sm text-gray-700">
|
||||||
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
|
||||||
{log.amount != null && (
|
{log.amount != null && (
|
||||||
<span className="ml-2 font-semibold text-green-700">€{log.amount.toFixed(2)}</span>
|
<span className={`ml-2 font-semibold ${isDuplicate ? 'text-red-600' : 'text-green-700'}`}>
|
||||||
|
€{log.amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{log.payment_method && (
|
{log.payment_method && (
|
||||||
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
|
<div className="text-right shrink-0">
|
||||||
|
<span className="text-xs text-gray-400">{displayTime}</span>
|
||||||
|
{log.offline_at && (
|
||||||
|
<span className="block text-xs text-orange-400">offline</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -122,7 +140,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
onError: () => toast.error('Σφάλμα εκτύπωσης'),
|
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 assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id))
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
@@ -138,13 +156,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
|
|
||||||
const cancelOrder = useMutation({
|
const cancelOrder = useMutation({
|
||||||
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
mutationFn: () => client.delete(`/api/orders/${orderId}`),
|
||||||
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/dashboard') },
|
onSuccess: () => { toast.success('Παραγγελία ακυρώθηκε'); navigate('/tables') },
|
||||||
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
onError: () => toast.error('Σφάλμα ακύρωσης παραγγελίας'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const closeOrder = useMutation({
|
const closeOrder = useMutation({
|
||||||
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
mutationFn: () => client.post(`/api/orders/${orderId}/close`),
|
||||||
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/dashboard') },
|
onSuccess: () => { toast.success('Παραγγελία έκλεισε'); navigate('/tables') },
|
||||||
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
onError: () => toast.error('Σφάλμα κλεισίματος'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -222,7 +240,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
{tab === 'overview' && <>
|
{tab === 'overview' && <>
|
||||||
{/* Waiters */}
|
{/* Waiters */}
|
||||||
<div className="card p-4">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{order.waiters.map(w => (
|
{order.waiters.map(w => (
|
||||||
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
|
||||||
@@ -239,13 +257,13 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
|||||||
))}
|
))}
|
||||||
{isOpen && !readOnly && (
|
{isOpen && !readOnly && (
|
||||||
<select
|
<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=""
|
defaultValue=""
|
||||||
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
|
||||||
>
|
>
|
||||||
<option value="">+ Πρόσθεσε</option>
|
<option value="">+ Πρόσθεσε</option>
|
||||||
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
|
{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>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -377,10 +377,13 @@ export default function ReportsPage() {
|
|||||||
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
|
const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true })
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
['shift', 'Σύνοψη Πληρωμών Βάρδιας'],
|
['shift', 'Πληρωμές Βάρδιας'],
|
||||||
['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'],
|
['shift-orders', 'Παραγγελίες Βάρδιας'],
|
||||||
['printers', 'Σύνοψη εκτυπωτών'],
|
['shifts-history','Ιστορικό Βαρδιών'],
|
||||||
['history', 'Ιστορικό παραγγελιών'],
|
['printers', 'Εκτυπωτές'],
|
||||||
|
['history', 'Ιστορικό Παραγγελιών'],
|
||||||
|
['product-perf', 'Απόδοση Προϊόντων'],
|
||||||
|
['traffic', 'Ανάλυση Κίνησης'],
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -395,8 +398,11 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
{tab === 'shift' && <ShiftTab endpoint="/api/reports/shift" title="Σύνοψη Πληρωμών" />}
|
||||||
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
{tab === 'shift-orders' && <ShiftTab endpoint="/api/reports/shift/orders" title="Σύνοψη Παραγγελιών" />}
|
||||||
|
{tab === 'shifts-history' && <ShiftsHistoryTab />}
|
||||||
{tab === 'printers' && <PrintersTab />}
|
{tab === 'printers' && <PrintersTab />}
|
||||||
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
{tab === 'history' && <HistoryTab filters={historyFilters} setFilters={setHistoryFilters} />}
|
||||||
|
{tab === 'product-perf' && <ProductPerformanceTab />}
|
||||||
|
{tab === 'traffic' && <TrafficTab />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -934,3 +940,324 @@ function HistoryTab({ filters, setFilters }) {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
57
manager_dashboard/src/pages/Settings/SettingsPage.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import AppInfoTab from './tabs/AppInfoTab'
|
||||||
|
import ColoursTab from './tabs/ColoursTab'
|
||||||
|
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||||
|
import PrintFontsTab from './tabs/PrintFontsTab'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'app-info', label: 'App Info' },
|
||||||
|
{ key: 'colours', label: 'UI Personalization' },
|
||||||
|
{ key: 'print-fonts', label: 'Εκτύπωση' },
|
||||||
|
{ key: 'development', label: 'Development' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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 />}
|
||||||
|
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||||
|
{activeTab === 'development' && <DevelopmentTab />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
554
manager_dashboard/src/pages/Settings/tabs/AppInfoTab.jsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
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', text_color: null })
|
||||||
|
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', text_color: null }) },
|
||||||
|
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', text_color: flag.text_color || null, 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>
|
||||||
|
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Χρώμα γραφής:</span>
|
||||||
|
{[{ val: null, label: 'Α', bg: newForm.color || '#6b7280', text: '#ffffff' }, { val: '#000000', label: 'Α', bg: newForm.color || '#6b7280', text: '#000000' }].map(opt => (
|
||||||
|
<button key={opt.label + opt.text} onClick={() => setNewForm(f => ({ ...f, text_color: opt.val }))}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 6, background: opt.bg, color: opt.text, fontSize: 14, fontWeight: 700, border: newForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>{opt.label}</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||||
|
{[{ val: null, text: '#ffffff' }, { val: '#000000', text: '#000000' }].map(opt => (
|
||||||
|
<button key={opt.text} onClick={() => setEditForm(f => ({ ...f, text_color: opt.val }))}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: editForm.color || '#6b7280', color: opt.text, fontSize: 13, fontWeight: 700, border: editForm.text_color === opt.val ? '3px solid #111' : '2px solid #dfe2e6', cursor: 'pointer' }}>Α</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
manager_dashboard/src/pages/Settings/tabs/DevelopmentTab.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
|
||||||
|
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 ? '#dc2626' : '#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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevelopmentTab() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }) },
|
||||||
|
onError: () => toast.error('Failed to update setting'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const spoofOn = settings?.['dev.spoof_printing']?.value === 'true'
|
||||||
|
|
||||||
|
function toggleSpoof(val) {
|
||||||
|
mutation.mutate({ key: 'dev.spoof_printing', value: val ? 'true' : 'false' })
|
||||||
|
toast.success(val ? 'Spoof printing ON — printers are silenced' : 'Spoof printing OFF — printers active')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <p style={{ color: '#6b7280', fontSize: 14 }}>Loading…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 560 }}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fef2f2', border: '1px solid #fca5a5',
|
||||||
|
borderRadius: 10, padding: '12px 16px', marginBottom: 24,
|
||||||
|
fontSize: 13, color: '#991b1b',
|
||||||
|
}}>
|
||||||
|
These settings are intended for testing only. Do not leave them enabled in production.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'white', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10, padding: '16px 20px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#111827' }}>
|
||||||
|
Spoof Printer Mode
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 3 }}>
|
||||||
|
All print jobs are silently dropped. Devices behave as if printing succeeded — no errors, nothing printed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={spoofOn} onChange={toggleSpoof} disabled={mutation.isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spoofOn && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: '10px 14px',
|
||||||
|
background: '#fff7ed', border: '1px solid #fed7aa',
|
||||||
|
borderRadius: 8, fontSize: 13, color: '#92400e', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
Spoof mode is active — printers are silenced.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
688
manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../../../api/client'
|
||||||
|
|
||||||
|
// ── Font option definitions ────────────────────────────────────────────────
|
||||||
|
// Value encodes: "SIZE:BOLD:CAPS"
|
||||||
|
// SIZE: ESC ! base byte — 0=normal, 16=tall, 32=wide, 48=tall+wide
|
||||||
|
// BOLD: 0|1 CAPS: 0|1
|
||||||
|
const FONT_SIZE_OPTIONS = [
|
||||||
|
{ size: '0', label: 'Μικρά' },
|
||||||
|
{ size: '16', label: 'Ψηλά' },
|
||||||
|
{ size: '32', label: 'Πλατιά' },
|
||||||
|
{ size: '48', label: 'Ψηλά και Πλατιά' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function encodeFont(size, bold, caps) {
|
||||||
|
return `${size}:${bold ? '1' : '0'}:${caps ? '1' : '0'}`
|
||||||
|
}
|
||||||
|
function decodeFont(val) {
|
||||||
|
if (!val) return { size: '0', bold: false, caps: false }
|
||||||
|
const [size, bold, caps] = val.split(':')
|
||||||
|
return { size: size ?? '0', bold: bold === '1', caps: caps === '1' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIVIDER_OPTIONS = [
|
||||||
|
{ value: 'dash', label: 'Παύλες ( - )', chars: '-------------------' },
|
||||||
|
{ value: 'equals', label: 'Ίσον ( = )', chars: '===================' },
|
||||||
|
{ value: 'star', label: 'Αστερίσκοι ( * )', chars: '*******************' },
|
||||||
|
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONT_DEFAULTS = {
|
||||||
|
'print.font_order_number': '48:1:0',
|
||||||
|
'print.font_meta': '0:0:0',
|
||||||
|
'print.font_item_name': '16:1:0',
|
||||||
|
'print.font_quick': '0:0:0',
|
||||||
|
'print.font_pref': '0:0:0',
|
||||||
|
'print.font_extra': '0:0:0',
|
||||||
|
'print.font_ingredient': '0:0:0',
|
||||||
|
'print.font_item_note': '0:0:0',
|
||||||
|
'print.font_order_note': '0:1:0',
|
||||||
|
'print.divider_style': 'dash',
|
||||||
|
'print.ticket_mode': 'detailed',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preview ────────────────────────────────────────────────────────────────
|
||||||
|
const PREVIEW_W = 200
|
||||||
|
const PREVIEW_H = 50
|
||||||
|
|
||||||
|
const sizeStyle = {
|
||||||
|
'0': { fontSize: 13, scaleY: 1, scaleX: 1 },
|
||||||
|
'16': { fontSize: 13, scaleY: 1.9, scaleX: 1 },
|
||||||
|
'32': { fontSize: 13, scaleY: 1, scaleX: 1.9 },
|
||||||
|
'48': { fontSize: 13, scaleY: 1.9, scaleX: 1.9 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function FontPreview({ size, bold, caps }) {
|
||||||
|
const s = sizeStyle[size] ?? sizeStyle['0']
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#1a1a1a', borderRadius: 8,
|
||||||
|
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
color: '#f5f5f5',
|
||||||
|
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||||
|
fontSize: s.fontSize,
|
||||||
|
fontWeight: bold ? 800 : 400,
|
||||||
|
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'block',
|
||||||
|
}}>
|
||||||
|
{caps ? 'SAMPLE' : 'Sample'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle button (shared) ─────────────────────────────────────────────────
|
||||||
|
function ToggleBtn({ active, onClick, disabled, label }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
height: 36, padding: '0 14px', borderRadius: 8, flexShrink: 0,
|
||||||
|
border: `1.5px solid ${active ? '#3758c9' : '#dfe2e6'}`,
|
||||||
|
background: active ? '#eff3ff' : 'white',
|
||||||
|
color: active ? '#3758c9' : '#6b7280',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: `2px solid ${active ? '#3758c9' : '#9ca3af'}`,
|
||||||
|
background: active ? '#3758c9' : 'white',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{active && <span style={{ color: 'white', fontSize: 10, lineHeight: 1 }}>✓</span>}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single font row ────────────────────────────────────────────────────────
|
||||||
|
function FontRow({ field, value, onChange, isPending, nested = false }) {
|
||||||
|
const { size, bold, caps } = decodeFont(value)
|
||||||
|
|
||||||
|
function handleSize(e) { onChange(field.key, encodeFont(e.target.value, bold, caps)) }
|
||||||
|
function handleBold() { onChange(field.key, encodeFont(size, !bold, caps)) }
|
||||||
|
function handleCaps() { onChange(field.key, encodeFont(size, bold, !caps)) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: nested ? '10px 20px 10px 36px' : '14px 20px',
|
||||||
|
borderBottom: '1px solid #f4f4f2',
|
||||||
|
background: nested ? '#fafafa' : 'white',
|
||||||
|
}}>
|
||||||
|
{nested && (
|
||||||
|
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}>└</span>
|
||||||
|
)}
|
||||||
|
{/* Label */}
|
||||||
|
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||||
|
<span style={{ fontSize: nested ? 13 : 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
{field.sub && (
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>{field.sub}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size dropdown */}
|
||||||
|
<select
|
||||||
|
value={size}
|
||||||
|
onChange={handleSize}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', padding: '0 10px', fontSize: 13,
|
||||||
|
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FONT_SIZE_OPTIONS.map(o => (
|
||||||
|
<option key={o.size} value={o.size}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Bold toggle */}
|
||||||
|
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
|
||||||
|
|
||||||
|
{/* Caps toggle */}
|
||||||
|
<ToggleBtn active={caps} onClick={handleCaps} disabled={isPending} label="ΚΕΦΑΛΑΙΑ" />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<FontPreview size={size} bold={bold} caps={caps} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subgroup header row ────────────────────────────────────────────────────
|
||||||
|
function SubgroupHeader({ label }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 20px 6px',
|
||||||
|
borderBottom: '1px solid #f4f4f2',
|
||||||
|
background: '#f9fafb',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Divider row ────────────────────────────────────────────────────────────
|
||||||
|
function DividerRow({ value, onChange, isPending }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
padding: '14px 20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
|
||||||
|
Στυλ Διαχωριστικού
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af' }}>Ανάμεσα στις ενότητες κάθε ticket</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange('print.divider_style', e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', padding: '0 10px', fontSize: 13,
|
||||||
|
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DIVIDER_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* spacer to align with bold+caps column */}
|
||||||
|
<div style={{ width: 194, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div style={{
|
||||||
|
background: '#1a1a1a', borderRadius: 8,
|
||||||
|
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{value === 'empty'
|
||||||
|
? <span style={{ color: '#6b7280', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif' }}>(κενή γραμμή)</span>
|
||||||
|
: <span style={{ color: '#f5f5f5', fontSize: 12, fontFamily: 'Arial, Helvetica, sans-serif', letterSpacing: 2 }}>
|
||||||
|
{DIVIDER_OPTIONS.find(o => o.value === value)?.chars}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ticket mode section ────────────────────────────────────────────────────
|
||||||
|
function TicketModeSection({ value, onChange, isPending, printers }) {
|
||||||
|
const [selectedPrinter, setSelectedPrinter] = useState(null)
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
// Auto-select first active printer
|
||||||
|
useEffect(() => {
|
||||||
|
if (printers.length > 0 && !selectedPrinter) {
|
||||||
|
const first = printers.find(p => p.is_active) ?? printers[0]
|
||||||
|
setSelectedPrinter(first.id)
|
||||||
|
}
|
||||||
|
}, [printers])
|
||||||
|
|
||||||
|
async function handleTestOrder() {
|
||||||
|
if (!selectedPrinter) return
|
||||||
|
setPrinting(true)
|
||||||
|
try {
|
||||||
|
const res = await client.post(`/api/system/printers/test-order?printer_id=${selectedPrinter}`)
|
||||||
|
if (res.data.success) toast.success('Test order στάλθηκε!')
|
||||||
|
else toast.error(`Σφάλμα: ${res.data.error}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Σφάλμα επικοινωνίας')
|
||||||
|
} finally {
|
||||||
|
setPrinting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<h2 className="font-semibold text-gray-700">Τύπος Εκτύπωσης</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Επιλέξτε πόσο λεπτομερές θα είναι κάθε ticket κουζίνας.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, padding: '16px 20px', flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: 'detailed',
|
||||||
|
title: 'Αναλυτικό',
|
||||||
|
desc: 'Κάθε επιλογή σε ξεχωριστή γραμμή. Περισσότερος χώρος, μέγιστη ευκρίνεια.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compact',
|
||||||
|
title: 'Συμπαγές',
|
||||||
|
desc: 'Ίδιου τύπου επιλογές στην ίδια γραμμή, διαχωρισμένες με |. Λιγότερο χαρτί.',
|
||||||
|
},
|
||||||
|
].map(opt => {
|
||||||
|
const active = value === opt.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
onClick={() => onChange('print.ticket_mode', opt.key)}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||||
|
borderRadius: 10, cursor: 'pointer',
|
||||||
|
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
|
||||||
|
background: active ? '#eff3ff' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 4 }}>
|
||||||
|
{opt.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>{opt.desc}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Test order button */}
|
||||||
|
<button
|
||||||
|
onClick={handleTestOrder}
|
||||||
|
disabled={printing || !selectedPrinter}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 200px', textAlign: 'left', padding: '14px 16px',
|
||||||
|
borderRadius: 10, cursor: printing || !selectedPrinter ? 'default' : 'pointer',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
background: printing ? '#f9fafb' : 'white',
|
||||||
|
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: printing ? '#9ca3af' : '#111315', marginBottom: 4 }}>
|
||||||
|
{printing ? 'Εκτύπωση…' : 'Δοκιμαστική Εκτύπωση'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||||||
|
Εκτυπώνει fake παραγγελία με όλους τους τύπους επιλογών για προεπισκόπηση ρυθμίσεων.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{printers.length > 0 && (
|
||||||
|
<div style={{ marginTop: 10 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<select
|
||||||
|
value={selectedPrinter ?? ''}
|
||||||
|
onChange={e => setSelectedPrinter(Number(e.target.value))}
|
||||||
|
disabled={printing}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: 32, borderRadius: 6,
|
||||||
|
border: '1px solid #dfe2e6', background: 'white',
|
||||||
|
padding: '0 8px', fontSize: 12, color: '#374151', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printers.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{printers.length === 0 && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444' }}>
|
||||||
|
Δεν υπάρχουν εκτυπωτές
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Printers section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||||
|
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
|
||||||
|
|
||||||
|
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||||
|
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||||
|
function set(k, v) { setForm(f => ({ ...f, [k]: v })) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 10,
|
||||||
|
padding: '16px 20px', display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 160px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΟΝΟΜΑ</label>
|
||||||
|
<input value={form.name} onChange={e => set('name', e.target.value)}
|
||||||
|
placeholder="π.χ. Κουζίνα" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '2 1 130px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>IP ADDRESS</label>
|
||||||
|
<input value={form.ip_address} onChange={e => set('ip_address', e.target.value)}
|
||||||
|
placeholder="10.98.20.25" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 80px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
|
||||||
|
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
|
||||||
|
type="number" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
|
||||||
|
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
|
||||||
|
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
|
||||||
|
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||||
|
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||||
|
style={btnPrimary}>Αποθήκευση</button>
|
||||||
|
<button onClick={onCancel} style={btnSecondary}>Άκυρο</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
height: 36, borderRadius: 8, border: '1px solid #dfe2e6', background: 'white',
|
||||||
|
padding: '0 10px', fontSize: 13, color: '#111315', fontFamily: 'inherit', width: '100%',
|
||||||
|
}
|
||||||
|
const btnPrimary = {
|
||||||
|
height: 36, padding: '0 16px', borderRadius: 8, background: '#3758c9', color: 'white',
|
||||||
|
border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}
|
||||||
|
const btnSecondary = {
|
||||||
|
height: 36, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6',
|
||||||
|
background: 'white', fontSize: 13, cursor: 'pointer', color: '#374151',
|
||||||
|
}
|
||||||
|
const btnDanger = {
|
||||||
|
height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2',
|
||||||
|
background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626',
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }) {
|
||||||
|
const [reachable, setReachable] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
client.get('/api/system/status').then(r => {
|
||||||
|
if (cancelled) return
|
||||||
|
const match = r.data.printers?.find(p => p.id === printer.id)
|
||||||
|
if (match) setReachable(match.reachable)
|
||||||
|
}).catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [printer.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '12px 20px', borderBottom: '1px solid #f4f4f2',
|
||||||
|
opacity: printer.is_active ? 1 : 0.5,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => onToggle(printer)} title={printer.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}
|
||||||
|
style={{
|
||||||
|
width: 40, height: 22, borderRadius: 999, border: 'none', cursor: 'pointer', flexShrink: 0,
|
||||||
|
background: printer.is_active ? '#16a34a' : '#d1d5db', position: 'relative', transition: 'background 150ms',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 3, left: printer.is_active ? 21 : 3,
|
||||||
|
width: 16, height: 16, borderRadius: '50%', background: 'white',
|
||||||
|
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 120 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{printer.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
|
||||||
|
{printer.ip_address}:{printer.port}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, flexShrink: 0,
|
||||||
|
background: reachable === null ? '#f3f4f6' : reachable ? '#dcfce7' : '#fee2e2',
|
||||||
|
color: reachable === null ? '#9ca3af' : reachable ? '#16a34a' : '#dc2626',
|
||||||
|
}}>
|
||||||
|
{reachable === null ? 'Έλεγχος…' : reachable ? 'Προσβάσιμος' : 'Μη προσβάσιμος'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button onClick={() => onTest(printer.id)} disabled={testPending}
|
||||||
|
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||||
|
Test Print
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onEdit(printer)}
|
||||||
|
style={{ ...btnSecondary, height: 28, padding: '0 10px', fontSize: 12, flexShrink: 0 }}>
|
||||||
|
Επεξεργασία
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(printer.id)} style={{ ...btnDanger, flexShrink: 0 }}>
|
||||||
|
Διαγραφή
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrintersSection() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
|
||||||
|
const { data: printers = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['printers-all'],
|
||||||
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: body => client.post('/api/system/printers', body),
|
||||||
|
onSuccess: () => { toast.success('Εκτυπωτής προστέθηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setShowNew(false) },
|
||||||
|
onError: () => toast.error('Σφάλμα δημιουργίας'),
|
||||||
|
})
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, ...body }) => client.put(`/api/system/printers/${id}`, body),
|
||||||
|
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }); setEditingId(null) },
|
||||||
|
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||||
|
})
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: id => client.delete(`/api/system/printers/${id}`),
|
||||||
|
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['printers-all'] }) },
|
||||||
|
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||||
|
})
|
||||||
|
const testMut = useMutation({
|
||||||
|
mutationFn: id => client.post(`/api/system/printers/test?printer_id=${id}`),
|
||||||
|
onSuccess: res => res.data.success ? toast.success('Test print στάλθηκε!') : toast.error(`Σφάλμα: ${res.data.error}`),
|
||||||
|
onError: () => toast.error('Σφάλμα επικοινωνίας'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleToggle(printer) {
|
||||||
|
updateMut.mutate({ id: printer.id, is_active: !printer.is_active })
|
||||||
|
}
|
||||||
|
|
||||||
|
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); setEditingId(null) }} style={btnSecondary}>
|
||||||
|
+ Νέος εκτυπωτής
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNew && (
|
||||||
|
<div style={{ padding: '12px 20px' }}>
|
||||||
|
<PrinterForm
|
||||||
|
onSave={form => createMut.mutate(form)}
|
||||||
|
onCancel={() => setShowNew(false)}
|
||||||
|
isPending={createMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <p style={{ padding: '16px 20px', color: '#9ca3af', fontSize: 13 }}>Φόρτωση…</p>}
|
||||||
|
{!isLoading && printers.length === 0 && !showNew && (
|
||||||
|
<p style={{ padding: '24px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
|
||||||
|
Δεν υπάρχουν εκτυπωτές ακόμα.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{printers.map(printer => (
|
||||||
|
editingId === printer.id ? (
|
||||||
|
<div key={printer.id} style={{ padding: '12px 20px', borderBottom: '1px solid #f4f4f2' }}>
|
||||||
|
<PrinterForm
|
||||||
|
initial={printer}
|
||||||
|
onSave={form => updateMut.mutate({ id: printer.id, ...form })}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PrinterRow
|
||||||
|
key={printer.id}
|
||||||
|
printer={printer}
|
||||||
|
onEdit={p => { setEditingId(p.id); setShowNew(false) }}
|
||||||
|
onDelete={id => deleteMut.mutate(id)}
|
||||||
|
onTest={id => testMut.mutate(id)}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
testPending={testMut.isPending}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Font groups definition ─────────────────────────────────────────────────
|
||||||
|
const FONT_GROUPS = [
|
||||||
|
{
|
||||||
|
group: 'Αριθμός Παραγγελίας',
|
||||||
|
fields: [
|
||||||
|
{ key: 'print.font_order_number', label: 'Αριθμός Παραγγελίας', sub: '"Παραγγελια #42" — η επικεφαλίδα του ticket' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Επικεφαλίδα Ticket',
|
||||||
|
fields: [
|
||||||
|
{ key: 'print.font_meta', label: 'Τραπέζι · Σερβιτόρος · Ώρα', sub: 'Γραμμές ταυτότητας κάτω από τον αριθμό' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Αντικείμενα',
|
||||||
|
fields: [
|
||||||
|
{ key: 'print.font_item_name', label: 'Όνομα Αντικειμένου', sub: 'Το κυρίως πιάτο/ποτό — γραμμή dot-leader' },
|
||||||
|
{ key: 'print.font_quick', label: '* Quick Options', sub: 'Γρήγορες επιλογές ( * )' },
|
||||||
|
{ key: 'print.font_pref', label: '> Προτιμήσεις', sub: 'Επιλογές preference sets ( > )' },
|
||||||
|
{ key: 'print.font_extra', label: '+ Extras', sub: 'Πρόσθετα / τροποποιητές ( + )' },
|
||||||
|
{ key: 'print.font_ingredient', label: '- Αφαιρέσεις', sub: 'ΧΩΡΙΣ: συστατικά ( - )' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Σημειώσεις',
|
||||||
|
fields: [
|
||||||
|
{ key: 'print.font_item_note', label: '(!) Σημείωση Αντικειμένου', sub: 'Free-text σημείωση ανά πιάτο' },
|
||||||
|
{ key: 'print.font_order_note', label: 'Σημειώσεις Παραγγελίας', sub: 'Η γενική σημείωση της παραγγελίας' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Main tab ───────────────────────────────────────────────────────────────
|
||||||
|
export default function PrintFontsTab() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['pos-settings'],
|
||||||
|
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: printers = [] } = useQuery({
|
||||||
|
queryKey: ['printers-all'],
|
||||||
|
queryFn: () => client.get('/api/system/printers').then(r => r.data),
|
||||||
|
staleTime: 15_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 val(key) { return settings?.[key]?.value ?? FONT_DEFAULTS[key] }
|
||||||
|
function handleChange(key, value) { updateMut.mutate({ key, value }) }
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div style={{ padding: 40, textAlign: 'center', color: '#9ca3af', fontSize: 14 }}>Φόρτωση…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
|
||||||
|
{/* 1. Printers */}
|
||||||
|
<PrintersSection />
|
||||||
|
|
||||||
|
{/* 2. Ticket mode */}
|
||||||
|
<TicketModeSection
|
||||||
|
value={val('print.ticket_mode')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
printers={printers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. Font sizes — grouped */}
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Οι αλλαγές εφαρμόζονται στην επόμενη εκτύπωση.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{FONT_GROUPS.map(group => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<SubgroupHeader label={group.group} />
|
||||||
|
{group.fields.map((field, idx) => (
|
||||||
|
<FontRow
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={val(field.key)}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
nested={group.fields.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Divider style */}
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||||
|
</div>
|
||||||
|
<DividerRow
|
||||||
|
value={val('print.divider_style')}
|
||||||
|
onChange={handleChange}
|
||||||
|
isPending={updateMut.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10,
|
||||||
|
padding: '12px 16px', fontSize: 12, color: '#92400e', lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
<strong>Σημείωση:</strong> Το "Πλατιά" και "Ψηλά και Πλατιά" χωράνε ~24 χαρακτήρες ανά γραμμή αντί για 48.
|
||||||
|
Χρησιμοποιήστε τα μόνο για σύντομα κείμενα (αριθμοί παραγγελίας, επικεφαλίδες).
|
||||||
|
</div>
|
||||||
|
</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() {
|
export default function WaitersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [addModal, setAddModal] = useState(false)
|
const [addModal, setAddModal] = useState(false)
|
||||||
@@ -156,10 +158,14 @@ export default function WaitersPage() {
|
|||||||
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
const [zoneModal, setZoneModal] = useState(null) // waiter object
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
const [confirmDelete, setConfirmDelete] = useState(null) // waiter id
|
||||||
const [newPin, setNewPin] = useState('')
|
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 [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 avatarInputRef = useRef(null)
|
||||||
|
const newAvatarInputRef = useRef(null)
|
||||||
|
|
||||||
const { data: waiters = [], isLoading } = useQuery({
|
const { data: waiters = [], isLoading } = useQuery({
|
||||||
queryKey: ['waiters'],
|
queryKey: ['waiters'],
|
||||||
@@ -174,8 +180,23 @@ export default function WaitersPage() {
|
|||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||||||
|
|
||||||
const createWaiter = useMutation({
|
const createWaiter = useMutation({
|
||||||
mutationFn: (body) => client.post('/api/waiters/', body),
|
mutationFn: async (body) => {
|
||||||
onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() },
|
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 || 'Σφάλμα'),
|
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,8 +252,7 @@ export default function WaitersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-end">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
|
|
||||||
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
|
||||||
</div>
|
</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'}`}>
|
<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 ? 'Ενεργός' : 'Αποκλεισμένος'}
|
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||||||
</span>
|
</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' && (
|
{w.role === 'waiter' && (
|
||||||
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
|
<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 */}
|
{/* Add waiter modal */}
|
||||||
{addModal && (
|
{addModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<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>
|
<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>
|
<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 />
|
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Όνομα χρήστη</label>
|
<label className="label">Παρατσούκλι (nickname) *</label>
|
||||||
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
|
<input className="input" placeholder="π.χ. Γιώργος" value={newForm.nickname} onChange={e => setNewForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Κινητό τηλέφωνο</label>
|
<label className="label">Κινητό τηλέφωνο</label>
|
||||||
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<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 }))}>
|
<select className="input" value={newForm.role} onChange={e => setNewForm(f => ({ ...f, role: e.target.value }))}>
|
||||||
<option value="waiter">Σερβιτόρος</option>
|
<option value="waiter">Σερβιτόρος</option>
|
||||||
<option value="manager">Διαχειριστής</option>
|
<option value="manager">Διαχειριστής</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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 }))} />
|
<PinInput value={newForm.pin} onChange={pin => setNewForm(f => ({ ...f, pin }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<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
|
<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 })}
|
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={!newForm.username.trim() || newForm.pin.length < 4}
|
disabled={createWaiter.isPending || !newForm.username.trim() || !newForm.full_name.trim() || !newForm.nickname.trim() || newForm.pin.length < 4}
|
||||||
className="flex-1 btn btn-primary"
|
className="flex-1 btn btn-primary"
|
||||||
>
|
>
|
||||||
Δημιουργία
|
{createWaiter.isPending ? 'Δημιουργία…' : 'Δημιουργία'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,8 +381,8 @@ export default function WaitersPage() {
|
|||||||
{/* Edit profile modal */}
|
{/* Edit profile modal */}
|
||||||
{editModal && (
|
{editModal && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
<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">Επεξεργασία — {editModal.username}</h2>
|
<h2 className="font-bold text-gray-800">Επεξεργασία — {editModal.full_name || editModal.username}</h2>
|
||||||
|
|
||||||
{/* Avatar section */}
|
{/* Avatar section */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -344,7 +404,7 @@ export default function WaitersPage() {
|
|||||||
disabled={uploadAvatar.isPending}
|
disabled={uploadAvatar.isPending}
|
||||||
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
|
||||||
>
|
>
|
||||||
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
|
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : editModal.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||||||
</button>
|
</button>
|
||||||
{editModal.avatar_url && (
|
{editModal.avatar_url && (
|
||||||
<button
|
<button
|
||||||
@@ -359,29 +419,36 @@ export default function WaitersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Όνομα χρήστη</label>
|
<label className="label">Πλήρες όνομα *</label>
|
||||||
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
|
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Πλήρες όνομα</label>
|
<label className="label">Παρατσούκλι (nickname) *</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>
|
|
||||||
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
|
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Κινητό τηλέφωνο</label>
|
<label className="label">Κινητό τηλέφωνο</label>
|
||||||
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
|
||||||
</div>
|
</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">
|
<div className="flex gap-3 pt-2">
|
||||||
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||||
<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 })}
|
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()}
|
disabled={updateWaiter.isPending || !editForm.username.trim() || !editForm.full_name.trim() || !editForm.nickname.trim()}
|
||||||
className="flex-1 btn btn-primary"
|
className="flex-1 btn btn-primary"
|
||||||
>
|
>
|
||||||
Αποθήκευση
|
{updateWaiter.isPending ? 'Αποθήκευση…' : 'Αποθήκευση'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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) => ({
|
const useAuthStore = create((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: localStorage.getItem('token') || null,
|
token: localStorage.getItem('manager_token') || null,
|
||||||
|
savedUsername: localStorage.getItem('manager_username') || null,
|
||||||
|
locked: false,
|
||||||
|
|
||||||
login(user, token) {
|
login(user, token) {
|
||||||
localStorage.setItem('token', token)
|
localStorage.setItem('manager_token', token)
|
||||||
set({ user, token })
|
localStorage.setItem('manager_username', user.username)
|
||||||
|
set({ user, token, savedUsername: user.username, locked: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('manager_token')
|
||||||
set({ user: null, token: null })
|
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
|
||||||
BIN
simple-pos-system.zip
Normal file
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.jqv9du572qo"
|
||||||
|
}], {});
|
||||||
|
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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>
|
<title>waiter_pwa</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||