feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3
waiter_pwa/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
24
waiter_pwa/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
19
waiter_pwa/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
RUN chmod -R 755 /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
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.hgopbk0r8co"
|
||||
}], {});
|
||||
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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
waiter_pwa/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>waiter_pwa</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
waiter_pwa/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
6759
waiter_pwa/package-lock.json
generated
Normal file
33
waiter_pwa/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "waiter_pwa",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"dexie": "^4.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
1
waiter_pwa/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
waiter_pwa/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
waiter_pwa/public/icons/Icon_Bird_512x512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
waiter_pwa/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
waiter_pwa/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
waiter_pwa/public/icons/icons8-favicon-48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
339
waiter_pwa/src/App.jsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate } from 'react-router-dom'
|
||||
import useAuthStore from './store/authStore'
|
||||
import useShiftStore from './store/shiftStore'
|
||||
import useThemeStore from './store/themeStore'
|
||||
import useTableColourStore from './store/tableColourStore'
|
||||
import useConnectionStore from './store/connectionStore'
|
||||
import client from './api/client'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TableListPage from './pages/TableListPage'
|
||||
import TableDetailPage from './pages/TableDetailPage'
|
||||
import AddItemsPage from './pages/AddItemsPage'
|
||||
import OfflinePage from './pages/OfflinePage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import { NotificationProvider } from './context/NotificationContext'
|
||||
import { SSEProvider } from './context/SSEContext'
|
||||
import ConnectionLostModal from './components/ConnectionLostModal'
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="page page--centered" style={{ gap: 12 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36,
|
||||
border: '3px solid var(--border)',
|
||||
borderTopColor: 'var(--accent)',
|
||||
borderRadius: '50%',
|
||||
animation: 'gate-spin 0.7s linear infinite',
|
||||
}} />
|
||||
<span style={{ color: 'var(--muted)', fontSize: 14 }}>Φόρτωση…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Gate Screens ─────────────────────────────────────────────────────────────
|
||||
|
||||
function GateCard({ emoji, title, subtitle, children }) {
|
||||
return (
|
||||
<div className="page page--centered" style={{ gap: 24, padding: 32 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 52, marginBottom: 12 }}>{emoji}</div>
|
||||
<p style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 6 }}>{title}</p>
|
||||
{subtitle && <p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.5 }}>{subtitle}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GateBtn({ onClick, disabled, variant = 'primary', children }) {
|
||||
const base = {
|
||||
height: 44, padding: '0 24px', borderRadius: 12, border: 'none',
|
||||
fontSize: 15, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1, transition: 'opacity 120ms',
|
||||
}
|
||||
const styles = {
|
||||
primary: { background: 'var(--accent)', color: '#0f172a' },
|
||||
secondary: { background: 'var(--bg3)', color: 'var(--text)' },
|
||||
danger: { background: 'var(--danger)', color: '#fff' },
|
||||
}
|
||||
return <button style={{ ...base, ...styles[variant] }} onClick={onClick} disabled={disabled}>{children}</button>
|
||||
}
|
||||
|
||||
function ClosedScreen({ onRefresh, onLogout }) {
|
||||
return (
|
||||
<GateCard emoji="🔒" title="Εστιατόριο κλειστό"
|
||||
subtitle={'Δεν υπάρχει ενεργή ημέρα λειτουργίας.\nΖητήστε από τον διαχειριστή να ανοίξει την ημέρα.'}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
|
||||
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
|
||||
</div>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingManagerScreen({ onRefresh, onLogout }) {
|
||||
return (
|
||||
<GateCard emoji="⏳" title="Αναμονή για έναρξη βάρδιας"
|
||||
subtitle="Ζητήστε από τον διαχειριστή να ξεκινήσει τη βάρδια σας.">
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<GateBtn variant="secondary" onClick={onRefresh}>Ανανέωση</GateBtn>
|
||||
<GateBtn variant="danger" onClick={onLogout}>Αποσύνδεση</GateBtn>
|
||||
</div>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
function StartShiftScreen({ username, onStart, onLogout }) {
|
||||
const [startingCash, setStartingCash] = useState('')
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleStart() {
|
||||
setStarting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onStart(startingCash ? parseFloat(startingCash) : null)
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.detail || 'Σφάλμα εκκίνησης βάρδιας')
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GateCard emoji="👋" title={`Καλώς ήρθες, ${username}!`}
|
||||
subtitle="Θέλεις να ξεκινήσεις τη βάρδια σου;">
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 320,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 16, padding: 20,
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
}}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--muted)', display: 'block', marginBottom: 6 }}>
|
||||
Αρχικά μετρητά (προαιρετικό)
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 700 }}>€</span>
|
||||
<input
|
||||
type="number" step="0.01" min="0" placeholder="0.00"
|
||||
value={startingCash}
|
||||
onChange={e => setStartingCash(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
||||
style={{
|
||||
flex: 1, background: 'var(--bg3)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, padding: '10px 12px',
|
||||
color: 'var(--text)', fontSize: 16, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{
|
||||
fontSize: 13, color: 'var(--danger)',
|
||||
background: 'var(--danger-dim)', borderRadius: 8, padding: '8px 12px',
|
||||
}}>{error}</p>
|
||||
)}
|
||||
|
||||
<GateBtn onClick={handleStart} disabled={starting}>
|
||||
{starting ? 'Εκκίνηση…' : '▶ Έναρξη Βάρδιας'}
|
||||
</GateBtn>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{ fontSize: 13, color: 'var(--muted)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
onClick={onLogout}
|
||||
>
|
||||
Αποσύνδεση
|
||||
</button>
|
||||
</GateCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Protected Layout with Shift Gate ────────────────────────────────────────
|
||||
|
||||
function AppLayout() {
|
||||
const { token, user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
shift, businessDay,
|
||||
setShift, setBusinessDay,
|
||||
setSelfStartAllowed, setSelfEndAllowed,
|
||||
gateStatus, setGateStatus,
|
||||
} = useShiftStore()
|
||||
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
|
||||
const isManager = user?.role && user.role !== 'waiter'
|
||||
|
||||
async function checkGate() {
|
||||
if (!user) return
|
||||
if (isManager) { setGateStatus('ready'); return }
|
||||
|
||||
setGateStatus('loading')
|
||||
try {
|
||||
const dayRes = await client.get('/api/business-day/current')
|
||||
const day = dayRes.data
|
||||
setBusinessDay(day)
|
||||
if (!day) { setGateStatus('closed'); return }
|
||||
|
||||
const shiftRes = await client.get('/api/shifts/my')
|
||||
if (shiftRes.data) {
|
||||
setShift(shiftRes.data)
|
||||
setGateStatus('ready')
|
||||
return
|
||||
}
|
||||
|
||||
// No active shift — check self-start setting
|
||||
try {
|
||||
const settingsRes = await client.get('/api/settings/')
|
||||
const canStart = settingsRes.data?.['shifts.waiter_self_start']?.value !== 'false'
|
||||
const canEnd = settingsRes.data?.['shifts.waiter_self_end']?.value !== 'false'
|
||||
setSelfStartAllowed(canStart)
|
||||
setSelfEndAllowed(canEnd)
|
||||
setGateStatus(canStart ? 'needs_start' : 'waiting_manager')
|
||||
} catch {
|
||||
setSelfStartAllowed(true)
|
||||
setSelfEndAllowed(true)
|
||||
setGateStatus('needs_start')
|
||||
}
|
||||
} catch {
|
||||
setBusinessDay(null)
|
||||
setGateStatus('closed')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user) checkGate()
|
||||
}, [user?.id])
|
||||
|
||||
// Poll every 15s to detect shift-end or business-day-close triggered by manager
|
||||
useEffect(() => {
|
||||
if (!user || isManager || gateStatus !== 'ready') return
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const dayRes = await client.get('/api/business-day/current')
|
||||
if (!dayRes.data) { setGateStatus('closed'); return }
|
||||
const shiftRes = await client.get('/api/shifts/my')
|
||||
if (!shiftRes.data) {
|
||||
// Shift was ended by manager — rerun full gate check
|
||||
checkGate()
|
||||
}
|
||||
} catch {
|
||||
// network error — ignore, don't lock
|
||||
}
|
||||
}, 15_000)
|
||||
return () => clearInterval(id)
|
||||
}, [user, isManager, gateStatus])
|
||||
|
||||
async function handleStartShift(startingCash) {
|
||||
const res = await client.post('/api/shifts/start', { starting_cash: startingCash })
|
||||
setShift(res.data)
|
||||
setGateStatus('ready')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
if (!user || gateStatus === 'loading') return <Spinner />
|
||||
|
||||
if (gateStatus === 'closed') return <ClosedScreen onRefresh={checkGate} onLogout={handleLogout} />
|
||||
if (gateStatus === 'waiting_manager') return <WaitingManagerScreen onRefresh={checkGate} onLogout={handleLogout} />
|
||||
if (gateStatus === 'needs_start') {
|
||||
return (
|
||||
<StartShiftScreen
|
||||
username={user.username}
|
||||
onStart={handleStartShift}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// ─── Global helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function AuthRehydrator() {
|
||||
const { token, user, login, logout } = useAuthStore()
|
||||
useEffect(() => {
|
||||
if (token && !user) {
|
||||
client.get('/api/auth/me')
|
||||
.then(r => login(r.data, token))
|
||||
.catch(() => logout())
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function OfflineListener() {
|
||||
const navigate = useNavigate()
|
||||
const { token } = useAuthStore()
|
||||
const { status } = useConnectionStore()
|
||||
useEffect(() => {
|
||||
function handler() {
|
||||
// If user is logged in, ConnectionLostModal handles it — don't redirect to /offline
|
||||
if (token && status !== 'online') return
|
||||
// Not logged in and server is down → redirect to offline page
|
||||
if (!token) navigate('/offline')
|
||||
}
|
||||
window.addEventListener('backend-offline', handler)
|
||||
return () => window.removeEventListener('backend-offline', handler)
|
||||
}, [navigate, token, status])
|
||||
return null
|
||||
}
|
||||
|
||||
function ThemeApplier() {
|
||||
const dark = useThemeStore(s => s.dark)
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
return null
|
||||
}
|
||||
|
||||
function ColourLoader() {
|
||||
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
||||
useEffect(() => {
|
||||
client.get('/api/settings/')
|
||||
.then(r => {
|
||||
const raw = r.data?.['ui.table_colours']?.value
|
||||
if (raw) loadFromBackend(raw)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeApplier />
|
||||
<ColourLoader />
|
||||
<AuthRehydrator />
|
||||
<OfflineListener />
|
||||
<SSEProvider>
|
||||
<NotificationProvider>
|
||||
<ConnectionLostModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/offline" element={<OfflinePage />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/tables" element={<TableListPage />} />
|
||||
<Route path="/tables/:tableId" element={<TableDetailPage />} />
|
||||
<Route path="/tables/:tableId/add" element={<AddItemsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/tables" replace />} />
|
||||
</Routes>
|
||||
</NotificationProvider>
|
||||
</SSEProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
26
waiter_pwa/src/api/client.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const client = axios.create({ baseURL: '' })
|
||||
|
||||
client.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (!err.response) {
|
||||
window.dispatchEvent(new Event('backend-offline'))
|
||||
} else if (err.response.status === 401) {
|
||||
// Token expired or user blocked — force logout
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('savedUsername')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default client
|
||||
BIN
waiter_pwa/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
5
waiter_pwa/src/assets/icons/backspace.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="#1C274C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="#1C274C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
7
waiter_pwa/src/assets/icons/categories.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.24 2H5.34C3.15 2 2 3.15 2 5.33V7.23C2 9.41 3.15 10.56 5.33 10.56H7.23C9.41 10.56 10.56 9.41 10.56 7.23V5.33C10.57 3.15 9.42 2 7.24 2Z" fill="currentColor"/>
|
||||
<path opacity="0.4" d="M18.6695 2H16.7695C14.5895 2 13.4395 3.15 13.4395 5.33V7.23C13.4395 9.41 14.5895 10.56 16.7695 10.56H18.6695C20.8495 10.56 21.9995 9.41 21.9995 7.23V5.33C21.9995 3.15 20.8495 2 18.6695 2Z" fill="currentColor"/>
|
||||
<path d="M18.6695 13.4302H16.7695C14.5895 13.4302 13.4395 14.5802 13.4395 16.7602V18.6602C13.4395 20.8402 14.5895 21.9902 16.7695 21.9902H18.6695C20.8495 21.9902 21.9995 20.8402 21.9995 18.6602V16.7602C21.9995 14.5802 20.8495 13.4302 18.6695 13.4302Z" fill="currentColor"/>
|
||||
<path opacity="0.4" d="M7.24 13.4302H5.34C3.15 13.4302 2 14.5802 2 16.7602V18.6602C2 20.8502 3.15 22.0002 5.33 22.0002H7.23C9.41 22.0002 10.56 20.8502 10.56 18.6702V16.7702C10.57 14.5802 9.42 13.4302 7.24 13.4302Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
7
waiter_pwa/src/assets/icons/categories2.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="233px" height="233px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
waiter_pwa/src/assets/icons/flags.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 747 B |
2
waiter_pwa/src/assets/icons/merge.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="currentColor" width="800px" height="800px" viewBox="-4 -2 24 24" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin" class="jam jam-merge-f"><path d='M9.033 5.817v2.028c0 .074-.003.148-.008.221a1 1 0 0 0 .462.637l3.086 1.846a3 3 0 0 1 1.46 2.575v1.059a3.001 3.001 0 1 1-2-.024v-1.035a1 1 0 0 0-.487-.858L8.46 10.42a3 3 0 0 1-.444-.324 3 3 0 0 1-.443.324l-3.086 1.846a1 1 0 0 0-.487.858v1.047a3.001 3.001 0 1 1-2 0v-1.047a3 3 0 0 1 1.46-2.575l3.086-1.846a1 1 0 0 0 .462-.637A3.006 3.006 0 0 1 7 7.845V5.829a3.001 3.001 0 1 1 2.033-.012z' /></svg>
|
||||
|
After Width: | Height: | Size: 689 B |
5
waiter_pwa/src/assets/icons/notifications.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="#292D32"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="#292D32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
waiter_pwa/src/assets/icons/print.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
|
||||
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
|
||||
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
waiter_pwa/src/assets/icons/transfer.svg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
13
waiter_pwa/src/assets/icons/waiter.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 237.888 237.888" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M197.047,59.153C185.153,23.771,153.764,0,118.938,0C82.628,0,50.816,25.12,39.779,62.506
|
||||
c-2.614,8.849-3.94,18.078-3.94,27.434c0,49.588,37.278,89.931,83.1,89.931c45.827,0,83.11-40.343,83.11-89.931
|
||||
C202.049,79.352,200.365,68.991,197.047,59.153z M118.938,159.87c-34.793,0-63.1-31.371-63.1-69.931
|
||||
c0-6.583,0.827-13.078,2.453-19.346h71.861l9.571-20.909l10.073,20.909h29.791c1.626,6.253,2.461,12.736,2.461,19.346
|
||||
C182.049,128.499,153.737,159.87,118.938,159.87z"/>
|
||||
<polygon points="64.61,180.791 64.61,237.888 118.61,221.853 172.61,237.888 172.61,180.791 118.61,196.829 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 939 B |
1
waiter_pwa/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
waiter_pwa/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
14
waiter_pwa/src/components/ConnectionBanner.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ConnectionBanner() {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textAlign: 'center',
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
⚠ Cannot reach the system — check your WiFi
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
waiter_pwa/src/components/ConnectionLostModal.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import client from '../api/client'
|
||||
import { useSSEContext } from '../context/SSEContext'
|
||||
|
||||
const RETRY_INTERVAL = 10_000 // 10s auto-retry while modal is open in Wait mode
|
||||
|
||||
export default function ConnectionLostModal() {
|
||||
const { status, setOnline, enterEmergency } = useConnectionStore()
|
||||
const { reconnect, fullRefresh } = useSSEContext()
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const retryRef = useRef(null)
|
||||
|
||||
const isVisible = status === 'lost'
|
||||
|
||||
async function tryReconnect() {
|
||||
setRetrying(true)
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
// Server is back
|
||||
setOnline()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
} catch {
|
||||
// Still down — stay in modal
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry every 10s while modal is open
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
clearInterval(retryRef.current)
|
||||
return
|
||||
}
|
||||
retryRef.current = setInterval(tryReconnect, RETRY_INTERVAL)
|
||||
return () => clearInterval(retryRef.current)
|
||||
}, [isVisible])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1e293b',
|
||||
border: '2px solid #ef4444',
|
||||
borderRadius: 20,
|
||||
padding: '32px 28px',
|
||||
maxWidth: 400, width: '100%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||
}}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
|
||||
<p style={{
|
||||
fontSize: 20, fontWeight: 700, color: '#f1f5f9',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
Χάθηκε η σύνδεση με τον Manager
|
||||
</p>
|
||||
|
||||
<p style={{
|
||||
fontSize: 14, color: '#94a3b8', lineHeight: 1.6,
|
||||
marginBottom: 28,
|
||||
}}>
|
||||
Δεν μπορώ να φτάσω στον server.{'\n'}
|
||||
Περίμενε ή άνοιξε <strong style={{ color: '#fbbf24' }}>ΕΚΤΑΚΤΗ ΛΕΙΤΟΥΡΓΙΑ</strong>{'\n'}
|
||||
για να συνεχίσεις με τοπικά δεδομένα.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, justifyContent: 'center',
|
||||
}}>
|
||||
<button
|
||||
onClick={enterEmergency}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 48, borderRadius: 12, border: 'none',
|
||||
background: '#dc2626', color: '#fff',
|
||||
fontSize: 15, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
EMERGENCY MODE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: '#475569', marginTop: 16 }}>
|
||||
Αυτόματη επανάληψη κάθε 10 δευτερόλεπτα
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
waiter_pwa/src/components/EmergencyBar.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
|
||||
export default function EmergencyBar() {
|
||||
const { status, lostAt } = useConnectionStore()
|
||||
const [elapsed, setElapsed] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'emergency' || !lostAt) return
|
||||
function tick() {
|
||||
const secs = Math.floor((Date.now() - lostAt.getTime()) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
setElapsed(`${m}:${String(s).padStart(2, '0')}`)
|
||||
}
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [status, lostAt])
|
||||
|
||||
if (status !== 'emergency') return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#dc2626',
|
||||
color: '#fef08a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
fontSize: 13, fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<span>EMERGENCY MODE</span>
|
||||
{elapsed && (
|
||||
<span style={{ opacity: 0.85, fontWeight: 400 }}>({elapsed})</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
waiter_pwa/src/components/Icons.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// Inline SVG icon components — avoids vite-plugin-svgr dependency.
|
||||
// All icons use currentColor so they inherit the surrounding text color.
|
||||
|
||||
export function FlagsIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 1C6.16421 1 6.5 1.33579 6.5 1.75V3.6L8.22067 3.25587C9.8712 2.92576 11.5821 3.08284 13.1449 3.70797L13.3486 3.78943C14.9097 4.41389 16.628 4.53051 18.2592 4.1227C19.0165 3.93339 19.75 4.50613 19.75 5.28669V12.6537C19.75 13.298 19.3115 13.8596 18.6864 14.0159L18.472 14.0695C16.7024 14.5119 14.8385 14.3854 13.1449 13.708C11.5821 13.0828 9.8712 12.9258 8.22067 13.2559L6.5 13.6V21.75C6.5 22.1642 6.16421 22.5 5.75 22.5C5.33579 22.5 5 22.1642 5 21.75V1.75C5 1.33579 5.33579 1 5.75 1Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TransferIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 17h13M4 17l4-4M4 17l4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M20 7H7M20 7l-4-4M20 7l-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MergeIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 6H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h3M16 6h3a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-3M12 12v6M12 12l-3-3M12 12l3-3M9 18h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WaiterIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="7" r="3" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M5 21v-1a7 7 0 0 1 14 0v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintIcon({ width = 24, height = 24 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.75H16C15.8011 16.75 15.6103 16.671 15.4697 16.5303C15.329 16.3897 15.25 16.1989 15.25 16C15.25 15.8011 15.329 15.6103 15.4697 15.4697C15.6103 15.329 15.8011 15.25 16 15.25H18C18.3315 15.25 18.6495 15.1183 18.8839 14.8839C19.1183 14.6495 19.25 14.3315 19.25 14V10C19.25 9.66848 19.1183 9.35054 18.8839 9.11612C18.6495 8.8817 18.3315 8.75 18 8.75H6C5.66848 8.75 5.35054 8.8817 5.11612 9.11612C4.8817 9.35054 4.75 9.66848 4.75 10V14C4.75 14.3315 4.8817 14.6495 5.11612 14.8839C5.35054 15.1183 5.66848 15.25 6 15.25H8C8.19891 15.25 8.38968 15.329 8.53033 15.4697C8.67098 15.6103 8.75 15.8011 8.75 16C8.75 16.1989 8.67098 16.3897 8.53033 16.5303C8.38968 16.671 8.19891 16.75 8 16.75H6C5.27065 16.75 4.57118 16.4603 4.05546 15.9445C3.53973 15.4288 3.25 14.7293 3.25 14V10C3.25 9.27065 3.53973 8.57118 4.05546 8.05546C4.57118 7.53973 5.27065 7.25 6 7.25H18C18.7293 7.25 19.4288 7.53973 19.9445 8.05546C20.4603 8.57118 20.75 9.27065 20.75 10V14C20.75 14.7293 20.4603 15.4288 19.9445 15.9445C19.4288 16.4603 18.7293 16.75 18 16.75Z" fill="currentColor"/>
|
||||
<path d="M16 8.75C15.8019 8.74741 15.6126 8.66756 15.4725 8.52747C15.3324 8.38737 15.2526 8.19811 15.25 8V4.75H8.75V8C8.75 8.19891 8.67098 8.38968 8.53033 8.53033C8.38968 8.67098 8.19891 8.75 8 8.75C7.80109 8.75 7.61032 8.67098 7.46967 8.53033C7.32902 8.38968 7.25 8.19891 7.25 8V4.5C7.25 4.16848 7.3817 3.85054 7.61612 3.61612C7.85054 3.3817 8.16848 3.25 8.5 3.25H15.5C15.8315 3.25 16.1495 3.3817 16.3839 3.61612C16.6183 3.85054 16.75 4.16848 16.75 4.5V8C16.7474 8.19811 16.6676 8.38737 16.5275 8.52747C16.3874 8.66756 16.1981 8.74741 16 8.75Z" fill="currentColor"/>
|
||||
<path d="M15.5 20.75H8.5C8.16848 20.75 7.85054 20.6183 7.61612 20.3839C7.3817 20.1495 7.25 19.8315 7.25 19.5V12.5C7.25 12.1685 7.3817 11.8505 7.61612 11.6161C7.85054 11.3817 8.16848 11.25 8.5 11.25H15.5C15.8315 11.25 16.1495 11.3817 16.3839 11.6161C16.6183 11.8505 16.75 12.1685 16.75 12.5V19.5C16.75 19.8315 16.6183 20.1495 16.3839 20.3839C16.1495 20.6183 15.8315 20.75 15.5 20.75ZM8.75 19.25H15.25V12.75H8.75V19.25Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
352
waiter_pwa/src/components/ItemOptionsModal.jsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ItemOptionsModal({ product, onAdd, onClose }) {
|
||||
const [selectedOptions, setSelectedOptions] = useState([])
|
||||
const [removedIngredients, setRemovedIngredients] = useState([])
|
||||
const [notes, setNotes] = useState('')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const options = product.options || []
|
||||
const ingredients = product.ingredients || []
|
||||
const preferenceSets = product.preference_sets || []
|
||||
|
||||
// selectedPreferences: { [setId]: choice | null }
|
||||
const [selectedPreferences, setSelectedPreferences] = useState(() =>
|
||||
Object.fromEntries(
|
||||
preferenceSets.map(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
return [ps.id, def]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Per-preference-choice inline sub-choices: { [choiceId]: subChoice | null }
|
||||
const [selectedSubChoices, setSelectedSubChoices] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
if (def && def.sub_choices?.length > 0) {
|
||||
const subDef = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
|
||||
init[def.id] = subDef
|
||||
}
|
||||
})
|
||||
return init
|
||||
})
|
||||
|
||||
// Shared-subset selections: { [setId]: subChoice | null }
|
||||
const [selectedSharedSubs, setSelectedSharedSubs] = useState(() => {
|
||||
const init = {}
|
||||
preferenceSets.forEach(ps => {
|
||||
if (ps.shared_subset?.choices?.length > 0) {
|
||||
const selectedChoice = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
if (!selectedChoice || !selectedChoice.disables_subset) {
|
||||
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||
init[ps.id] = subDef
|
||||
}
|
||||
}
|
||||
})
|
||||
return init
|
||||
})
|
||||
|
||||
// Option sub-choices: { [optionId]: subChoice | null }
|
||||
// Initialise with any option that has a default sub-choice pre-selected — but only
|
||||
// if the option itself is checked by default (options are all unchecked initially).
|
||||
const [selectedOptionSubs, setSelectedOptionSubs] = useState({})
|
||||
|
||||
function selectPreference(setId, choice) {
|
||||
setSelectedPreferences(prev => ({ ...prev, [setId]: choice }))
|
||||
if (choice && choice.sub_choices?.length > 0) {
|
||||
const subDef = choice.sub_choices.find(s => s.is_default) ?? choice.sub_choices[0]
|
||||
setSelectedSubChoices(prev => ({ ...prev, [choice.id]: subDef }))
|
||||
}
|
||||
const ps = preferenceSets.find(p => p.id === setId)
|
||||
if (ps?.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
|
||||
setSelectedSharedSubs(prev => {
|
||||
if (prev[setId] != null) return prev
|
||||
const subDef = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||
return { ...prev, [setId]: subDef }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectSubChoice(parentChoiceId, sub) {
|
||||
setSelectedSubChoices(prev => ({ ...prev, [parentChoiceId]: sub }))
|
||||
}
|
||||
|
||||
function selectSharedSub(setId, sub) {
|
||||
setSelectedSharedSubs(prev => ({ ...prev, [setId]: sub }))
|
||||
}
|
||||
|
||||
function toggleOption(opt) {
|
||||
setSelectedOptions(prev => {
|
||||
const exists = prev.find(o => o.id === opt.id)
|
||||
if (exists) {
|
||||
// Deselecting: also clear its sub-choice selection
|
||||
setSelectedOptionSubs(s => { const n = { ...s }; delete n[opt.id]; return n })
|
||||
return prev.filter(o => o.id !== opt.id)
|
||||
}
|
||||
// Selecting: pre-select default sub-choice if any
|
||||
if (opt.sub_choices?.length > 0) {
|
||||
const subDef = opt.sub_choices.find(s => s.is_default) ?? opt.sub_choices[0]
|
||||
setSelectedOptionSubs(s => ({ ...s, [opt.id]: subDef }))
|
||||
}
|
||||
return [...prev, { id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0 }]
|
||||
})
|
||||
}
|
||||
|
||||
function selectOptionSub(optId, sub) {
|
||||
setSelectedOptionSubs(prev => ({ ...prev, [optId]: sub }))
|
||||
}
|
||||
|
||||
function toggleIngredient(ing) {
|
||||
setRemovedIngredients(prev =>
|
||||
prev.includes(ing.name) ? prev.filter(n => n !== ing.name) : [...prev, ing.name]
|
||||
)
|
||||
}
|
||||
|
||||
// Check whether any checked option with sub_choices is missing its sub-choice selection
|
||||
const optionSubsMissing = selectedOptions.some(o => {
|
||||
const full = options.find(opt => opt.id === o.id)
|
||||
return full?.sub_choices?.length > 0 && selectedOptionSubs[o.id] == null
|
||||
})
|
||||
|
||||
function isPrefSetComplete(ps) {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (choice == null) return false
|
||||
if (choice.sub_choices?.length > 0 && selectedSubChoices[choice.id] == null) return false
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && selectedSharedSubs[ps.id] == null) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const allPrefsSelected = preferenceSets.every(isPrefSetComplete)
|
||||
const unselectedPrefs = preferenceSets.filter(ps => !isPrefSetComplete(ps))
|
||||
const canAdd = allPrefsSelected && !optionSubsMissing
|
||||
|
||||
const prefExtra = preferenceSets.reduce((s, ps) => {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return s
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset)
|
||||
? (selectedSharedSubs[ps.id] ?? null) : null
|
||||
return s + (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||
}, 0)
|
||||
const optionExtra = selectedOptions.reduce((s, o) => {
|
||||
const subExtra = selectedOptionSubs[o.id]?.extra_cost ?? 0
|
||||
return s + (o.price_delta ?? 0) + subExtra
|
||||
}, 0)
|
||||
const totalPrice = (product.base_price + optionExtra + prefExtra) * quantity
|
||||
|
||||
function handleAdd() {
|
||||
if (!canAdd) return
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
const choice = selectedPreferences[ps.id]
|
||||
if (!choice) return []
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (selectedSubChoices[choice.id] ?? null) : null
|
||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
||||
const sharedSub = selectedSharedSubs[ps.id] ?? null
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const optionEntries = selectedOptions.flatMap(o => {
|
||||
const entries = [{ id: o.id, name: o.name, price_delta: o.price_delta ?? 0, type: 'extra' }]
|
||||
const sub = selectedOptionSubs[o.id]
|
||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
|
||||
return entries
|
||||
})
|
||||
|
||||
onAdd({
|
||||
product_id: product.id,
|
||||
quantity,
|
||||
selected_options: [...optionEntries, ...prefChoices],
|
||||
removed_ingredients: removedIngredients,
|
||||
notes,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title">{product.name}</h2>
|
||||
<p className="modal-price">{Number(totalPrice).toFixed(2)} €</p>
|
||||
|
||||
{/* ── Checkbox options with optional sub-choices ── */}
|
||||
{options.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Επιλογές</h3>
|
||||
{options.map(opt => {
|
||||
const isChecked = !!selectedOptions.find(o => o.id === opt.id)
|
||||
const hasSubs = opt.sub_choices?.length > 0
|
||||
const subMissing = isChecked && hasSubs && selectedOptionSubs[opt.id] == null
|
||||
return (
|
||||
<div key={opt.id}>
|
||||
<label className="modal-option">
|
||||
<input type="checkbox" checked={isChecked} onChange={() => toggleOption(opt)} />
|
||||
<span>{opt.name}</span>
|
||||
{(opt.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{opt.extra_cost > 0 ? '+' : ''}{Number(opt.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
{isChecked && hasSubs && (
|
||||
<div style={{
|
||||
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||
{opt.sub_choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`optsub-${opt.id}`}
|
||||
checked={selectedOptionSubs[opt.id]?.name === sub.name}
|
||||
onChange={() => selectOptionSub(opt.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Preference sets ── */}
|
||||
{preferenceSets.map(ps => {
|
||||
const missing = !isPrefSetComplete(ps)
|
||||
const selectedChoice = selectedPreferences[ps.id] ?? null
|
||||
const showSharedSubset = ps.shared_subset?.choices?.length > 0
|
||||
&& selectedChoice != null
|
||||
&& !selectedChoice.disables_subset
|
||||
const sharedMissing = showSharedSubset && selectedSharedSubs[ps.id] == null
|
||||
|
||||
return (
|
||||
<section key={ps.id} className="modal-section"
|
||||
style={missing ? { border: '1.5px solid #ef4444', borderRadius: 10, padding: '10px 12px' } : {}}>
|
||||
<h3 style={{ color: missing ? '#ef4444' : undefined }}>
|
||||
{ps.name}
|
||||
{missing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||
</h3>
|
||||
|
||||
{ps.choices.map(ch => {
|
||||
const isSelected = selectedPreferences[ps.id]?.id === ch.id
|
||||
const hasSubs = ch.sub_choices?.length > 0
|
||||
const subMissing = isSelected && hasSubs && selectedSubChoices[ch.id] == null
|
||||
return (
|
||||
<div key={ch.id}>
|
||||
<label className="modal-option">
|
||||
<input type="radio" name={`pref-${ps.id}`} checked={isSelected}
|
||||
onChange={() => selectPreference(ps.id, ch)} />
|
||||
<span>{ch.name}</span>
|
||||
{(ch.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{ch.extra_cost > 0 ? '+' : ''}{Number(ch.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
{isSelected && hasSubs && (
|
||||
<div style={{
|
||||
marginLeft: 24, marginTop: 4, marginBottom: 6,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${subMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '0 0 4px' }}>— απαιτείται επιλογή</p>}
|
||||
{ch.sub_choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`sub-${ch.id}`}
|
||||
checked={selectedSubChoices[ch.id]?.name === sub.name}
|
||||
onChange={() => selectSubChoice(ch.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showSharedSubset && (
|
||||
<div style={{
|
||||
marginTop: 8, marginLeft: 8, padding: '8px 12px', borderRadius: 8,
|
||||
borderLeft: `3px solid ${sharedMissing ? '#ef4444' : '#6366f1'}`,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: sharedMissing ? '#ef4444' : '#4338ca' }}>
|
||||
{ps.shared_subset.name}
|
||||
{sharedMissing && <span style={{ fontSize: 12, marginLeft: 6, fontWeight: 400 }}>— απαιτείται επιλογή</span>}
|
||||
</p>
|
||||
{ps.shared_subset.choices.map((sub, si) => (
|
||||
<label key={si} className="modal-option" style={{ fontSize: 14 }}>
|
||||
<input type="radio" name={`shared-${ps.id}`}
|
||||
checked={selectedSharedSubs[ps.id]?.name === sub.name}
|
||||
onChange={() => selectSharedSub(ps.id, sub)} />
|
||||
<span>{sub.name}</span>
|
||||
{(sub.extra_cost ?? 0) !== 0 && (
|
||||
<span className="option-price">{sub.extra_cost > 0 ? '+' : ''}{Number(sub.extra_cost).toFixed(2)} €</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Remove ingredients ── */}
|
||||
{ingredients.length > 0 && (
|
||||
<section className="modal-section">
|
||||
<h3>Αφαίρεση υλικών</h3>
|
||||
{ingredients.map(ing => (
|
||||
<label key={ing.id} className="modal-option modal-option--remove">
|
||||
<input type="checkbox" checked={removedIngredients.includes(ing.name)}
|
||||
onChange={() => toggleIngredient(ing)} />
|
||||
<span>χωρίς {ing.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="modal-section">
|
||||
<h3>Σημείωση</h3>
|
||||
<textarea className="modal-notes" placeholder="π.χ. χωρίς αλάτι..."
|
||||
value={notes} onChange={e => setNotes(e.target.value)} rows={2} />
|
||||
</section>
|
||||
|
||||
<div className="modal-qty">
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => Math.max(1, q - 1))}>−</button>
|
||||
<span className="qty-value">{quantity}</span>
|
||||
<button className="qty-btn" onClick={() => setQuantity(q => q + 1)}>+</button>
|
||||
</div>
|
||||
|
||||
{!allPrefsSelected && (
|
||||
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 8 }}>
|
||||
Επιλέξτε: {unselectedPrefs.map(ps => ps.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{optionSubsMissing && (
|
||||
<p style={{ color: '#ef4444', fontSize: 13, textAlign: 'center', marginTop: 4 }}>
|
||||
Επιλέξτε υπο-επιλογή για τις επιλεγμένες επιλογές
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="btn btn--primary btn--lg" onClick={handleAdd} disabled={!canAdd}
|
||||
style={{ width: '100%', marginTop: 16, opacity: canAdd ? 1 : 0.45 }}>
|
||||
Προσθήκη στην παραγγελία
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
916
waiter_pwa/src/components/OrderDrawer.jsx
Normal file
@@ -0,0 +1,916 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n) {
|
||||
if (n === 0) return ''
|
||||
const s = n > 0 ? `+${n.toFixed(2)} €` : `${n.toFixed(2)} €`
|
||||
return s
|
||||
}
|
||||
|
||||
function buildInitialState(product) {
|
||||
const preferenceSets = product.preference_sets || []
|
||||
|
||||
const prefs = {}
|
||||
const subChoices = {}
|
||||
const sharedSubs = {}
|
||||
|
||||
preferenceSets.forEach(ps => {
|
||||
const def = ps.default_choice_id != null
|
||||
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
|
||||
: null
|
||||
prefs[ps.id] = def
|
||||
if (def) {
|
||||
if (def.sub_choices?.length > 0) {
|
||||
subChoices[def.id] = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
|
||||
}
|
||||
if (ps.shared_subset?.choices?.length > 0 && !def.disables_subset) {
|
||||
sharedSubs[ps.id] = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { prefs, subChoices, sharedSubs }
|
||||
}
|
||||
|
||||
// Build sorted favorites list across all item types
|
||||
function buildFavorites(product) {
|
||||
const items = []
|
||||
;(product.quick_options || []).forEach(q => {
|
||||
if (q.is_favorite) items.push({ type: 'quick', item: q, sortOrder: q.favorite_sort_order ?? 0 })
|
||||
})
|
||||
;(product.ingredients || []).forEach(ing => {
|
||||
if (ing.is_favorite) items.push({ type: 'ingredient', item: ing, sortOrder: ing.favorite_sort_order ?? 0 })
|
||||
})
|
||||
;(product.options || []).forEach(opt => {
|
||||
if (opt.is_favorite) items.push({ type: 'option', item: opt, sortOrder: opt.favorite_sort_order ?? 0 })
|
||||
})
|
||||
;(product.preference_sets || []).forEach(ps => {
|
||||
if (ps.is_favorite) items.push({ type: 'pref', item: ps, sortOrder: ps.favorite_sort_order ?? 0 })
|
||||
})
|
||||
return items.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
|
||||
const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
|
||||
|
||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Stepper({ value, onChange, min = 0, max = 99 }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: 40, borderRadius: 20,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
|
||||
style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? 'var(--muted)' : 'var(--text)' }}>−</button>
|
||||
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 15, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
|
||||
<button onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max}
|
||||
style={{ width: 40, height: 40, border: 'none', background: 'transparent', fontSize: 18, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? 'var(--muted)' : 'var(--text)' }}>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckCircle({ selected }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
|
||||
background: selected ? '#f59e0b' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioDot({ selected }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : 'var(--border)'}`,
|
||||
background: 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && <div style={{ width: 10, height: 10, borderRadius: '50%', background: '#f59e0b' }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ selected, onClick, children, right, left, style = {} }) {
|
||||
return (
|
||||
<div onClick={onClick} style={{
|
||||
padding: '12px 14px',
|
||||
background: selected ? 'rgba(245,158,11,0.12)' : 'var(--bg2)',
|
||||
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : 'var(--border)'}`,
|
||||
borderRadius: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
minHeight: 56,
|
||||
...style,
|
||||
}}>
|
||||
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared: single quick option row ──────────────────────────────────────────
|
||||
|
||||
function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
|
||||
const qty = quickState[opt.id] || 0
|
||||
const selected = qty > 0
|
||||
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
|
||||
return (
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggleSingle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={opt.allow_multiple ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||
{selected
|
||||
? <Stepper value={qty} onChange={v => setQuickState(s => ({ ...s, [opt.id]: v }))} />
|
||||
: <button
|
||||
onClick={e => { e.stopPropagation(); setQuickState(s => ({ ...s, [opt.id]: 1 })) }}
|
||||
style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div style={{ fontSize: compact ? 13 : 15, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.name}</div>
|
||||
{opt.price > 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared: single extra/option row ──────────────────────────────────────────
|
||||
|
||||
function ExtraOptionRow({ opt, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
|
||||
const sel = extrasState[opt.id]
|
||||
const selected = !!sel
|
||||
const open = expandedExtra === opt.id
|
||||
const hasSubs = opt.sub_choices?.length > 0
|
||||
const subLabel = sel ? opt.sub_choices?.find(s => s.name === sel.subName)?.name : null
|
||||
|
||||
const toggle = () => {
|
||||
if (selected) {
|
||||
setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n })
|
||||
if (open) setExpandedExtra(null)
|
||||
} else {
|
||||
const firstSub = hasSubs ? opt.sub_choices[0] : null
|
||||
setExtrasState(s => ({ ...s, [opt.id]: { qty: 1, subName: firstSub?.name ?? null } }))
|
||||
if (hasSubs) setExpandedExtra(opt.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row
|
||||
selected={selected}
|
||||
onClick={toggle}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||
{opt.allow_multiple && !selected && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggle() }}
|
||||
style={{ width: 34, height: 34, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{selected && opt.allow_multiple && (
|
||||
<Stepper value={sel.qty} onChange={v => {
|
||||
if (v === 0) { setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n }); return }
|
||||
setExtrasState(s => ({ ...s, [opt.id]: { ...sel, qty: v } }))
|
||||
}} />
|
||||
)}
|
||||
{selected && hasSubs && (
|
||||
<button onClick={e => { e.stopPropagation(); setExpandedExtra(open ? null : opt.id) }}
|
||||
style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: '1px solid var(--border)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)} €` : 'Included'}
|
||||
{subLabel && <span style={{ color: '#f59e0b', fontWeight: 600 }}> · {subLabel}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{selected && open && hasSubs && (
|
||||
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '6px 2px 2px' }}>Επιλογή</div>
|
||||
{opt.sub_choices.map((sub, si) => {
|
||||
const isSel = sel.subName === sub.name
|
||||
return (
|
||||
<Row key={si} selected={isSel}
|
||||
onClick={() => setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))}
|
||||
left={<RadioDot selected={isSel} />}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div>}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared: single ingredient row ─────────────────────────────────────────────
|
||||
|
||||
function IngredientRow({ ing, removedState, setRemovedState }) {
|
||||
const removed = !!removedState[ing.id]
|
||||
return (
|
||||
<Row selected={false}
|
||||
onClick={() => setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
|
||||
right={
|
||||
<div style={{
|
||||
height: 34, padding: '0 14px', borderRadius: 17,
|
||||
background: removed ? 'var(--danger)' : 'var(--bg3)',
|
||||
border: `1px solid ${removed ? 'var(--danger)' : 'var(--border)'}`,
|
||||
color: removed ? '#fff' : 'var(--text)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
|
||||
</div>
|
||||
}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: removed ? 'var(--muted)' : 'var(--text)', textDecoration: removed ? 'line-through' : 'none', transition: 'all 120ms' }}>{ing.name}</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared: single preference set ─────────────────────────────────────────────
|
||||
|
||||
function PrefSetBlock({ ps, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
|
||||
const selChoice = prefs[ps.id] ?? null
|
||||
const complete = selChoice != null
|
||||
&& !(selChoice.sub_choices?.length > 0 && subChoices[selChoice.id] == null)
|
||||
&& !(ps.shared_subset?.choices?.length > 0 && !selChoice.disables_subset && sharedSubs[ps.id] == null)
|
||||
const showShared = ps.shared_subset?.choices?.length > 0 && selChoice != null && !selChoice.disables_subset
|
||||
|
||||
function selectPref(choice) {
|
||||
setPrefs(p => ({ ...p, [ps.id]: choice }))
|
||||
if (choice?.sub_choices?.length > 0) {
|
||||
setSubChoices(s => ({ ...s, [choice.id]: s[choice.id] ?? (choice.sub_choices.find(x => x.is_default) ?? choice.sub_choices[0]) }))
|
||||
}
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
|
||||
setSharedSubs(s => s[ps.id] != null ? s : { ...s, [ps.id]: ps.shared_subset.choices.find(x => x.is_default) ?? ps.shared_subset.choices[0] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, padding: '0 2px 10px' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: complete ? 'var(--text)' : '#ef4444' }}>{ps.name}</div>
|
||||
{!complete && <div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: 0.6 }}>Απαιτείται</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ps.choices.map(ch => {
|
||||
const isSel = selChoice?.id === ch.id
|
||||
const hasSubs = ch.sub_choices?.length > 0
|
||||
const subMissing = isSel && hasSubs && subChoices[ch.id] == null
|
||||
|
||||
return (
|
||||
<div key={ch.id}>
|
||||
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
|
||||
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--muted)' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{ch.name}</div>
|
||||
</Row>
|
||||
|
||||
{isSel && hasSubs && (
|
||||
<div style={{ margin: '6px 0 2px 16px', paddingLeft: 14, borderLeft: `2px solid ${subMissing ? '#ef4444' : 'rgba(245,158,11,0.3)'}`, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{subMissing && <p style={{ fontSize: 12, color: '#ef4444', margin: '4px 2px 2px' }}>— απαιτείται επιλογή</p>}
|
||||
{ch.sub_choices.map((sub, si) => {
|
||||
const subSel = subChoices[ch.id]?.name === sub.name
|
||||
return (
|
||||
<Row key={si} selected={subSel} onClick={() => setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={<RadioDot selected={subSel} />}
|
||||
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{showShared && (
|
||||
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 14, borderLeft: '2px solid rgba(245,158,11,0.3)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>{ps.shared_subset.name}</div>
|
||||
{ps.shared_subset.choices.map((sub, si) => {
|
||||
const subSel = sharedSubs[ps.id]?.name === sub.name
|
||||
return (
|
||||
<Row key={si} selected={subSel} onClick={() => setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={<RadioDot selected={subSel} />}
|
||||
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: 'var(--muted)' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text)' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Favorites ────────────────────────────────────────────────────────────
|
||||
|
||||
function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtrasState, expandedExtra, setExpandedExtra, removedState, setRemovedState, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
|
||||
const favorites = buildFavorites(product)
|
||||
|
||||
if (favorites.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν αγαπημένα για αυτό το προϊόν.</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{favorites.map((fav, fi) => {
|
||||
if (fav.type === 'quick') {
|
||||
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} compact={false} />
|
||||
}
|
||||
if (fav.type === 'ingredient') {
|
||||
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
}
|
||||
if (fav.type === 'option') {
|
||||
return <ExtraOptionRow key={`opt-${fav.item.id}`} opt={fav.item} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
|
||||
}
|
||||
if (fav.type === 'pref') {
|
||||
return (
|
||||
<PrefSetBlock key={`pref-${fav.item.id}`} ps={fav.item}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Quick Options ────────────────────────────────────────────────────────
|
||||
|
||||
function QuickTab({ product, quickState, setQuickState }) {
|
||||
const quickOptions = product.quick_options || []
|
||||
if (quickOptions.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{quickOptions.map(opt => (
|
||||
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
|
||||
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} compact={opt.is_compact} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Extras ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
|
||||
const options = product.options || []
|
||||
if (options.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{options.map(opt => (
|
||||
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Υλικά (Ingredients) ─────────────────────────────────────────────────
|
||||
|
||||
function IngredientsTab({ product, removedState, setRemovedState }) {
|
||||
const ingredients = product.ingredients || []
|
||||
if (ingredients.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Πατήστε για να αφαιρέσετε υλικό από το πιάτο.
|
||||
</div>
|
||||
{ingredients.map(ing => (
|
||||
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Προτιμήσεις ─────────────────────────────────────────────────────────
|
||||
|
||||
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
|
||||
const preferenceSets = product.preference_sets || []
|
||||
if (preferenceSets.length === 0) return (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{preferenceSets.map(ps => (
|
||||
<PrefSetBlock key={ps.id} ps={ps}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Notes ────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesTab({ note, setNote }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg3)', borderRadius: 10, fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
Οτιδήποτε ειδικό για την κουζίνα.
|
||||
</div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: 14, fontSize: 15, fontFamily: 'inherit', color: 'var(--text)', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 20, marginBottom: 8 }}>Γρήγορες σημειώσεις</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{QUICK_NOTES.map(q => (
|
||||
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
|
||||
style={{ height: 36, padding: '0 14px', borderRadius: 18, background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text)', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
|
||||
+ {q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab: Summary ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryTab({ product, summaryLines, note, onJumpTab }) {
|
||||
const isEmpty = summaryLines.length === 0 && !note
|
||||
const byGroup = { quick: [], extras: [], removed: [], prefs: [] }
|
||||
summaryLines.forEach(l => byGroup[l.group]?.push(l))
|
||||
|
||||
const Section = ({ title, tab, lines }) => lines.length === 0 ? null : (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>{title}</div>
|
||||
<button onClick={() => onJumpTab(tab)} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{l.group === 'prefs' ? (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</div>
|
||||
</>
|
||||
) : l.group === 'removed' ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Χωρίς {l.label}
|
||||
</div>
|
||||
) : l.group === 'extras' ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.label}
|
||||
{l.subName && <span> · {l.subName}</span>}
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginLeft: 6, fontVariantNumeric: 'tabular-nums' }}>×{l.qty}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isEmpty ? (
|
||||
<div style={{ padding: '40px 16px', textAlign: 'center', color: 'var(--muted)', fontSize: 14 }}>
|
||||
Δεν έχει γίνει καμία προσαρμογή. Χρησιμοποιήστε τις καρτέλες για να διαμορφώσετε το προϊόν.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Section title="Προτιμήσεις" tab="prefs" lines={byGroup.prefs} />
|
||||
<Section title="Γρήγορες Επιλογές" tab="quick" lines={byGroup.quick} />
|
||||
<Section title="Extras" tab="extras" lines={byGroup.extras} />
|
||||
<Section title="Αφαιρέθηκαν" tab="ingredients" lines={byGroup.removed} />
|
||||
{note && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 2px 8px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Σημείωση</div>
|
||||
<button onClick={() => onJumpTab('notes')} style={{ background: 'none', border: 'none', fontSize: 12, fontWeight: 700, color: '#f59e0b', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: 0.6 }}>Αλλαγή</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 14px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 10, fontSize: 14, color: 'var(--text)', lineHeight: 1.4, whiteSpace: 'pre-wrap' }}>{note}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main OrderDrawer ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialState }) {
|
||||
const preferenceSets = product?.preference_sets || []
|
||||
const quickOptions = product?.quick_options || []
|
||||
const options = product?.options || []
|
||||
const ingredients = product?.ingredients || []
|
||||
const favorites = product ? buildFavorites(product) : []
|
||||
|
||||
const hasTabs = {
|
||||
favorites: favorites.length > 0,
|
||||
quick: quickOptions.length > 0,
|
||||
extras: options.length > 0,
|
||||
ingredients: ingredients.length > 0,
|
||||
prefs: preferenceSets.length > 0,
|
||||
}
|
||||
|
||||
const firstTab = hasTabs.favorites ? 'favorites'
|
||||
: hasTabs.quick ? 'quick'
|
||||
: hasTabs.extras ? 'extras'
|
||||
: hasTabs.ingredients ? 'ingredients'
|
||||
: hasTabs.prefs ? 'prefs'
|
||||
: 'notes'
|
||||
|
||||
const [activeTab, setActiveTab] = useState(firstTab)
|
||||
const [qty, setQty] = useState(1)
|
||||
const [quickState, setQuickState] = useState({})
|
||||
const [extrasState, setExtrasState] = useState({})
|
||||
const [expandedExtra, setExpandedExtra] = useState(null)
|
||||
const [removedState, setRemovedState] = useState({})
|
||||
const [prefs, setPrefs] = useState({})
|
||||
const [subChoices, setSubChoices] = useState({})
|
||||
const [sharedSubs, setSharedSubs] = useState({})
|
||||
const [note, setNote] = useState('')
|
||||
const [addAttempted, setAddAttempted] = useState(false)
|
||||
|
||||
// Reset/init when drawer opens or product changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !product) return
|
||||
const base = buildInitialState(product)
|
||||
if (initialState) {
|
||||
setQty(initialState.qty ?? 1)
|
||||
setQuickState(initialState.quickState ?? {})
|
||||
setExtrasState(initialState.extrasState ?? {})
|
||||
setRemovedState(initialState.removedState ?? {})
|
||||
setPrefs(initialState.prefs ?? base.prefs)
|
||||
setSubChoices(initialState.subChoices ?? base.subChoices)
|
||||
setSharedSubs(initialState.sharedSubs ?? base.sharedSubs)
|
||||
setNote(initialState.note ?? '')
|
||||
} else {
|
||||
setQty(1)
|
||||
setQuickState({})
|
||||
setExtrasState({})
|
||||
setRemovedState({})
|
||||
setPrefs(base.prefs)
|
||||
setSubChoices(base.subChoices)
|
||||
setSharedSubs(base.sharedSubs)
|
||||
setNote('')
|
||||
}
|
||||
setExpandedExtra(null)
|
||||
setAddAttempted(false)
|
||||
setActiveTab(initialState?.activeTab ?? firstTab)
|
||||
}, [isOpen, product?.id])
|
||||
|
||||
// Derived: summary lines + price
|
||||
const { summaryLines, totalPrice } = (() => {
|
||||
if (!product) return { summaryLines: [], totalPrice: 0 }
|
||||
let price = product.base_price
|
||||
const lines = []
|
||||
|
||||
preferenceSets.forEach(ps => {
|
||||
const choice = prefs[ps.id]
|
||||
if (!choice) return
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
||||
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
|
||||
const delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||
|
||||
// Skip if this is entirely the default selection
|
||||
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
|
||||
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
|
||||
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
|
||||
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
|
||||
: null
|
||||
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
|
||||
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
|
||||
: null
|
||||
const isFullyDefault = isDefaultChoice
|
||||
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
|
||||
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
|
||||
if (isFullyDefault) { price += delta; return }
|
||||
|
||||
const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
|
||||
lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null })
|
||||
price += delta
|
||||
})
|
||||
|
||||
quickOptions.forEach(opt => {
|
||||
const q = quickState[opt.id] || 0
|
||||
if (q === 0) return
|
||||
const linePrice = opt.price * q
|
||||
lines.push({ group: 'quick', label: opt.name, qty: q, price: linePrice, detail: null })
|
||||
price += linePrice
|
||||
})
|
||||
|
||||
options.forEach(opt => {
|
||||
const sel = extrasState[opt.id]
|
||||
if (!sel) return
|
||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
|
||||
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null })
|
||||
price += linePrice
|
||||
})
|
||||
|
||||
ingredients.forEach(ing => {
|
||||
if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null })
|
||||
})
|
||||
|
||||
return { summaryLines: lines, totalPrice: price * qty }
|
||||
})()
|
||||
|
||||
// Validation
|
||||
function isPrefComplete(ps) {
|
||||
const choice = prefs[ps.id]
|
||||
if (!choice) return false
|
||||
if (choice.sub_choices?.length > 0 && subChoices[choice.id] == null) return false
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && sharedSubs[ps.id] == null) return false
|
||||
return true
|
||||
}
|
||||
const allPrefsOk = preferenceSets.every(isPrefComplete)
|
||||
const extrasSubsMissing = options.some(opt => {
|
||||
const sel = extrasState[opt.id]
|
||||
return sel && opt.sub_choices?.length > 0 && sel.subName == null
|
||||
})
|
||||
const canAdd = allPrefsOk && !extrasSubsMissing
|
||||
|
||||
const prefsHasMandatory = hasTabs.prefs && preferenceSets.some(ps => ps.default_choice_id == null)
|
||||
const prefsTabAlert = hasTabs.prefs && !allPrefsOk && (addAttempted || prefsHasMandatory)
|
||||
|
||||
// Also alert the favorites tab if it contains an incomplete pref
|
||||
const favHasIncompletePref = hasTabs.favorites && !allPrefsOk && favorites.some(f => f.type === 'pref' && !isPrefComplete(f.item))
|
||||
const favTabAlert = favHasIncompletePref && (addAttempted || prefsHasMandatory)
|
||||
|
||||
function handleAdd() {
|
||||
if (!canAdd) {
|
||||
setAddAttempted(true)
|
||||
if (!allPrefsOk) {
|
||||
// Jump to favorites if the incomplete pref is there, else prefs tab
|
||||
if (favHasIncompletePref) setActiveTab('favorites')
|
||||
else if (hasTabs.prefs) setActiveTab('prefs')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
const choice = prefs[ps.id]
|
||||
if (!choice) return []
|
||||
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
|
||||
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
|
||||
|
||||
// Don't emit entries that are entirely at their defaults — nothing changed
|
||||
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
|
||||
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
|
||||
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
|
||||
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0])
|
||||
: null
|
||||
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
|
||||
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0])
|
||||
: null
|
||||
const isFullyDefault = isDefaultChoice
|
||||
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
|
||||
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
|
||||
if (isFullyDefault) return []
|
||||
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
|
||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
|
||||
return entries
|
||||
})
|
||||
|
||||
const optionEntries = options.flatMap(opt => {
|
||||
const sel = extrasState[opt.id]
|
||||
if (!sel) return []
|
||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||
const entries = []
|
||||
for (let i = 0; i < sel.qty; i++) {
|
||||
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
|
||||
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const quickEntries = quickOptions.flatMap(opt => {
|
||||
const q = quickState[opt.id] || 0
|
||||
if (q === 0) return []
|
||||
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
|
||||
})
|
||||
|
||||
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
|
||||
|
||||
onAdd({
|
||||
product_id: product.id,
|
||||
quantity: qty,
|
||||
selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
|
||||
removed_ingredients: removedNames,
|
||||
notes: note,
|
||||
_drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note },
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Tabs definition
|
||||
const tabs = [
|
||||
hasTabs.favorites && { id: 'favorites', label: '♥ Αγαπ.' },
|
||||
hasTabs.quick && { id: 'quick', label: 'Quick' },
|
||||
hasTabs.extras && { id: 'extras', label: 'Extras' },
|
||||
hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
|
||||
hasTabs.prefs && { id: 'prefs', label: 'Προτιμ.' },
|
||||
{ id: 'notes', label: 'Note' },
|
||||
{ id: 'summary', label: 'Summary' },
|
||||
].filter(Boolean)
|
||||
|
||||
const badgeFor = id => {
|
||||
if (id === 'favorites') {
|
||||
// count favorited items that have been interacted with
|
||||
const favQuick = favorites.filter(f => f.type === 'quick' && (quickState[f.item.id] || 0) > 0).length
|
||||
const favIng = favorites.filter(f => f.type === 'ingredient' && removedState[f.item.id]).length
|
||||
const favExt = favorites.filter(f => f.type === 'option' && extrasState[f.item.id]).length
|
||||
const favPref = favorites.filter(f => f.type === 'pref' && isPrefComplete(f.item)).length
|
||||
return favQuick + favIng + favExt + favPref
|
||||
}
|
||||
if (id === 'quick') return Object.values(quickState).filter(v => v > 0).length
|
||||
if (id === 'extras') return Object.values(extrasState).filter(Boolean).length
|
||||
if (id === 'ingredients') return Object.values(removedState).filter(Boolean).length
|
||||
if (id === 'prefs') return preferenceSets.filter(isPrefComplete).length
|
||||
if (id === 'notes') return note ? 1 : 0
|
||||
if (id === 'summary') return summaryLines.length + (note ? 1 : 0)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!product) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 260ms ease',
|
||||
zIndex: 40,
|
||||
}} />
|
||||
|
||||
{/* Sheet */}
|
||||
<div style={{
|
||||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||
height: '92svh',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 320ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 41,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 -8px 40px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
{/* Grab handle */}
|
||||
<div style={{ padding: '10px 0 4px', display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 40, height: 4, borderRadius: 2, background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '4px 16px 0', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{product.image_url && (
|
||||
<img src={product.image_url} alt=""
|
||||
style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'cover', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', lineHeight: 1.2 }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>{product.base_price.toFixed(2)} €</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 36, height: 36, borderRadius: '50%', background: 'var(--bg3)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="var(--text)" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs bar */}
|
||||
<div style={{ marginTop: 12, borderBottom: '1px solid var(--border)', overflowX: 'auto', scrollbarWidth: 'none', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', padding: '0 12px', gap: 2, minWidth: 'max-content' }}>
|
||||
{tabs.map(t => {
|
||||
const active = activeTab === t.id
|
||||
const badge = badgeFor(t.id)
|
||||
const isAlert = (t.id === 'prefs' && prefsTabAlert) || (t.id === 'favorites' && favTabAlert)
|
||||
const tabColor = isAlert ? '#f59e0b' : active ? '#f59e0b' : 'var(--muted)'
|
||||
return (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
|
||||
padding: '12px 6px',
|
||||
background: 'none', border: 'none',
|
||||
borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
|
||||
color: tabColor,
|
||||
fontSize: 14, fontWeight: (active || isAlert) ? 700 : 500,
|
||||
fontFamily: 'inherit', cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
whiteSpace: 'nowrap', marginRight: 8,
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
animation: isAlert ? 'tab-pulse 0.9s ease-in-out 3' : 'none',
|
||||
}}>
|
||||
{t.label}
|
||||
{badge > 0 && !isAlert && (
|
||||
<span style={{
|
||||
minWidth: 18, height: 18, padding: '0 5px',
|
||||
borderRadius: 9,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 16px 20px', background: 'var(--bg)', WebkitOverflowScrolling: 'touch' }}>
|
||||
{activeTab === 'favorites' && (
|
||||
<FavoritesTab
|
||||
product={product}
|
||||
quickState={quickState} setQuickState={setQuickState}
|
||||
extrasState={extrasState} setExtrasState={setExtrasState}
|
||||
expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra}
|
||||
removedState={removedState} setRemovedState={setRemovedState}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'quick' && <QuickTab product={product} quickState={quickState} setQuickState={setQuickState} />}
|
||||
{activeTab === 'extras' && <ExtrasTab product={product} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />}
|
||||
{activeTab === 'ingredients' && <IngredientsTab product={product} removedState={removedState} setRemovedState={setRemovedState} />}
|
||||
{activeTab === 'prefs' && <PrefsTab product={product} prefs={prefs} setPrefs={setPrefs} subChoices={subChoices} setSubChoices={setSubChoices} sharedSubs={sharedSubs} setSharedSubs={setSharedSubs} />}
|
||||
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
|
||||
{activeTab === 'summary' && <SummaryTab product={product} summaryLines={summaryLines} note={note} onJumpTab={setActiveTab} />}
|
||||
</div>
|
||||
|
||||
{/* Footer: qty stepper + ΠΡΟΣΘΗΚΗ */}
|
||||
<div style={{ padding: '12px 16px 20px', background: 'var(--bg2)', borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, boxShadow: '0 -4px 16px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', height: 52, borderRadius: 26, background: 'var(--bg3)', border: '1px solid var(--border)', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<button onClick={() => setQty(q => Math.max(1, q - 1))} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? 'var(--muted)' : 'var(--text)' }}>−</button>
|
||||
<div style={{ minWidth: 32, textAlign: 'center', fontSize: 17, fontWeight: 700, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{qty}</div>
|
||||
<button onClick={() => setQty(q => q + 1)} style={{ width: 52, height: 52, border: 'none', background: 'transparent', fontSize: 22, fontWeight: 500, cursor: 'pointer', color: 'var(--text)' }}>+</button>
|
||||
</div>
|
||||
<button onClick={handleAdd} disabled={!canAdd} style={{
|
||||
flex: 1, height: 52, borderRadius: 26,
|
||||
background: canAdd ? 'var(--accent)' : 'var(--bg3)',
|
||||
border: 'none', color: canAdd ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
fontSize: 16, fontWeight: 700, fontFamily: 'inherit',
|
||||
cursor: canAdd ? 'pointer' : 'not-allowed',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 20px',
|
||||
transition: 'background 150ms ease',
|
||||
}}>
|
||||
<span>ΠΡΟΣΘΗΚΗ</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{totalPrice.toFixed(2)} €</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
288
waiter_pwa/src/components/OrderSummary.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Number(v).toFixed(2) + ' €'
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionIcon({ type }) {
|
||||
const icons = {
|
||||
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
|
||||
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
|
||||
}
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||||
}
|
||||
|
||||
// ── Parse selected_options into grouped sections (same logic as cart) ────────
|
||||
|
||||
function buildSections(item) {
|
||||
const sections = []
|
||||
const opts = (() => {
|
||||
try { return item.selected_options ? JSON.parse(item.selected_options) : [] } catch { return [] }
|
||||
})()
|
||||
const removed = (() => {
|
||||
try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] }
|
||||
})()
|
||||
|
||||
// We don't have product metadata here, so we classify by heuristics:
|
||||
// - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them
|
||||
// - id == null → sub-choice (follows its parent)
|
||||
// Strategy: walk through opts in order, attaching sub-choices to their parent,
|
||||
// then classify parent items: items with a real id that appear multiple times → extra (stacked),
|
||||
// but without product metadata we can't fully distinguish prefs from extras.
|
||||
// We use a simple rule: if an option with id appears only once in the stream → treat as pref
|
||||
// (since extras can be added multiple times). This matches how handleAdd() emits them.
|
||||
|
||||
const prefGroups = [] // { setName: null (unknown), values: [...] }
|
||||
const extraGroups = [] // { id, name, subName, qty }
|
||||
const quickLines = [] // { name, _qty }
|
||||
|
||||
// Count how many times each id appears (extras can be stacked → appear multiple times)
|
||||
const idCount = {}
|
||||
opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 })
|
||||
|
||||
// Single pass: consume each item and its optional following sub (id=null)
|
||||
const consumedAsSubAtIndex = new Set()
|
||||
let i = 0
|
||||
while (i < opts.length) {
|
||||
const o = opts[i]
|
||||
if (consumedAsSubAtIndex.has(i)) { i++; continue }
|
||||
|
||||
if (o.id == null) {
|
||||
// Standalone id=null → quick option
|
||||
const existing = quickLines.find(x => x.name === o.name)
|
||||
if (existing) existing._qty = (existing._qty || 1) + 1
|
||||
else quickLines.push({ name: o.name, _qty: 1 })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// id != null — look ahead for immediate sub
|
||||
let subName = null
|
||||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||
subName = opts[i + 1].name
|
||||
consumedAsSubAtIndex.add(i + 1)
|
||||
}
|
||||
|
||||
if (idCount[o.id] > 1) {
|
||||
// Extra — appears multiple times in the list
|
||||
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||||
if (existing) existing.qty++
|
||||
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||||
} else {
|
||||
// Single occurrence → preference choice
|
||||
const value = subName ? `${o.name} · ${subName}` : o.name
|
||||
prefGroups.push({ setName: null, values: [value] })
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||
if (quickLines.length > 0) sections.push({ type: 'quick', lines: quickLines })
|
||||
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||
if (removed.length > 0) sections.push({ type: 'removed', lines: removed.map(n => ({ name: n })) })
|
||||
if (item.notes) sections.push({ type: 'note', lines: [{ name: item.notes }] })
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// ── ItemRow ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
|
||||
const isPaid = item.status === 'paid'
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
|
||||
const sections = buildSections(item)
|
||||
const hasDetails = sections.length > 0
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Long-press detection
|
||||
const pressTimer = useRef(null)
|
||||
const didLongPress = useRef(false)
|
||||
const touchStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
function handleTouchStart(e) {
|
||||
if (!selectable || isPaid || isCancelled || !onLongPress) return
|
||||
didLongPress.current = false
|
||||
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
pressTimer.current = setTimeout(() => {
|
||||
didLongPress.current = true
|
||||
onLongPress(item)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handleTouchMove(e) {
|
||||
const dx = Math.abs(e.touches[0].clientX - touchStartPos.current.x)
|
||||
const dy = Math.abs(e.touches[0].clientY - touchStartPos.current.y)
|
||||
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
|
||||
}
|
||||
|
||||
function handleTouchEnd() { clearTimeout(pressTimer.current) }
|
||||
|
||||
function handleBodyClick() {
|
||||
if (didLongPress.current) { didLongPress.current = false; return }
|
||||
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{/* Main row — click to select */}
|
||||
<div
|
||||
onClick={handleBodyClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px',
|
||||
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
{selectable && !isPaid && !isCancelled && (
|
||||
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Name + badges */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||
{!isPaid && !isCancelled && !item.printed && (
|
||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qty + price */}
|
||||
<span className="order-item__qty">×{item.quantity}</span>
|
||||
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
||||
|
||||
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
|
||||
{hasDetails && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
|
||||
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && hasDetails && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '5px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="prefs" />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||
{line.setName && (
|
||||
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="quick" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="extras" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line.subName && <span> · {line.subName}</span>}
|
||||
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="removed" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="note" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem }) {
|
||||
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
|
||||
const total = activeItems
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const paidTotal = activeItems
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
|
||||
return (
|
||||
<div className="order-summary">
|
||||
{activeItems.length === 0 && <p style={{ color: '#64748b', textAlign: 'center' }}>Δεν υπάρχουν αντικείμενα</p>}
|
||||
{activeItems.map((item, idx) => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectable={selectable}
|
||||
selected={selectedIds.includes(item.id)}
|
||||
onToggle={onToggle}
|
||||
onLongPress={onLongPressItem}
|
||||
isLast={idx === activeItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div className="order-summary__total">
|
||||
<span>Σύνολο</span>
|
||||
<span>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#64748b' }}>
|
||||
<span>Πληρωμένο</span>
|
||||
<span style={{ color: '#22c55e' }}>{fmtPrice(paidTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
{paidTotal > 0 && paidTotal < total && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<span>Εκκρεμεί</span>
|
||||
<span style={{ color: '#f59e0b', fontWeight: 700 }}>{fmtPrice(total - paidTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
waiter_pwa/src/components/PinPad.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function PinPad({ onSubmit, loading }) {
|
||||
const [pin, setPin] = useState('')
|
||||
|
||||
function press(digit) {
|
||||
if (pin.length >= 4) return
|
||||
const next = pin + digit
|
||||
setPin(next)
|
||||
if (next.length === 4 && !loading) onSubmit(next)
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
setPin(p => p.slice(0, -1))
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (pin.length > 0 && !loading) onSubmit(pin)
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: 4 }, (_, i) => (
|
||||
<span key={i} style={{ fontSize: 20, color: i < pin.length ? '#f59e0b' : '#334155' }}>●</span>
|
||||
))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>{dots}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
|
||||
{[1,2,3,4,5,6,7,8,9].map(d => (
|
||||
<button key={d} onClick={() => press(String(d))} className="pin-btn">{d}</button>
|
||||
))}
|
||||
<button onClick={backspace} className="pin-btn pin-btn--secondary">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0303 8.96967C10.7374 8.67678 10.2625 8.67678 9.96965 8.96967C9.67676 9.26256 9.67676 9.73744 9.96965 10.0303L11.9393 12L9.96967 13.9697C9.67678 14.2626 9.67678 14.7374 9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L13 13.0607L14.9696 15.0303C15.2625 15.3232 15.7374 15.3232 16.0303 15.0303C16.3232 14.7374 16.3232 14.2625 16.0303 13.9697L14.0606 12L16.0303 10.0304C16.3232 9.73746 16.3232 9.26258 16.0303 8.96969C15.7374 8.6768 15.2625 8.6768 14.9696 8.96969L13 10.9394L11.0303 8.96967Z" fill="currentColor"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M21.3191 4.63407C20.5538 3.88938 19.5855 3.55963 18.3866 3.40278C17.2186 3.24997 15.7251 3.24999 13.8342 3.25H11.1058C10.0228 3.24999 9.15832 3.24999 8.45039 3.31591C7.71946 3.38398 7.09979 3.52598 6.51512 3.84132C5.92948 4.15718 5.47496 4.59515 5.02578 5.16537C4.59197 5.7161 4.13289 6.43088 3.55968 7.32338L2.83702 8.44855C2.35887 9.19299 1.96846 9.80083 1.7023 10.3305C1.42424 10.8839 1.25 11.411 1.25 12C1.25 12.589 1.42424 13.1161 1.7023 13.6695C1.96845 14.1992 2.35886 14.807 2.83699 15.5514L3.55969 16.6766C4.1329 17.5691 4.59197 18.2839 5.02578 18.8346C5.47496 19.4048 5.92948 19.8428 6.51512 20.1587C7.09979 20.474 7.71947 20.616 8.45039 20.6841C9.15831 20.75 10.0228 20.75 11.1058 20.75H13.8341C15.725 20.75 17.2186 20.75 18.3866 20.5972C19.5855 20.4404 20.5538 20.1106 21.3191 19.3659C22.0872 18.6185 22.4299 17.6679 22.5924 16.4917C22.75 15.3511 22.75 13.8943 22.75 12.0577V11.9422C22.75 10.1056 22.75 8.64883 22.5924 7.50827C22.4299 6.33205 22.0872 5.38153 21.3191 4.63407ZM13.779 4.75C15.7373 4.75 17.1327 4.75151 18.192 4.89011C19.2319 5.02615 19.8343 5.2822 20.273 5.70908C20.7088 6.13319 20.9681 6.71126 21.1066 7.71356C21.2483 8.73957 21.25 10.0926 21.25 12C21.25 13.9074 21.2483 15.2604 21.1066 16.2864C20.9681 17.2887 20.7088 17.8668 20.273 18.2909C19.8343 18.7178 19.2319 18.9738 18.192 19.1099C17.1327 19.2485 15.7373 19.25 13.779 19.25H11.142C10.0146 19.25 9.21982 19.2493 8.58947 19.1906C7.97424 19.1333 7.5722 19.0246 7.22717 18.8385C6.88311 18.6529 6.57764 18.3806 6.20411 17.9064C5.82029 17.4192 5.39961 16.7657 4.80167 15.8347L4.12086 14.7747C3.61571 13.9882 3.26903 13.4466 3.04261 12.996C2.82407 12.5611 2.75 12.2714 2.75 12C2.75 11.7286 2.82407 11.4389 3.04261 11.004C3.26903 10.5534 3.61571 10.0118 4.12086 9.22531L4.80167 8.16532C5.39961 7.23433 5.82029 6.58082 6.20411 6.09357C6.57764 5.61938 6.88311 5.34711 7.22717 5.16154C7.5722 4.97545 7.97424 4.86674 8.58947 4.80945C9.21982 4.75075 10.0146 4.75 11.142 4.75L13.779 4.75Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => press('0')} className="pin-btn">0</button>
|
||||
<button onClick={submit} className="pin-btn pin-btn--confirm" disabled={loading || pin.length === 0}>
|
||||
{loading ? '…' : '✓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
waiter_pwa/src/components/ProductPicker.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import OrderDrawer from './OrderDrawer'
|
||||
|
||||
function CategoriesIcon({ width = 20, height = 20 }) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
if (!hex) return null
|
||||
const h = hex.replace('#', '')
|
||||
const r = parseInt(h.substring(0, 2), 16)
|
||||
const g = parseInt(h.substring(2, 4), 16)
|
||||
const b = parseInt(h.substring(4, 6), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
|
||||
function ProductGrid({ products, onOpen }) {
|
||||
if (products.length === 0) return null
|
||||
return (
|
||||
<div className="product-grid">
|
||||
{products.map(product => {
|
||||
const initials = product.name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
return (
|
||||
<button key={product.id} className="product-btn" onClick={() => onOpen(product)}>
|
||||
<div className="product-btn__thumb">
|
||||
<div className="product-btn__thumb-inner">
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" className="product-btn__img" />
|
||||
: <span className="product-btn__initials">{initials}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-btn__info">
|
||||
<span className="product-btn__name">{product.name}</span>
|
||||
<span className="product-btn__price">{Number(product.base_price).toFixed(2)} €</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Builds the ordered list of sections for a top-level category:
|
||||
// interleaves direct products (as a "General" section) and sub-categories
|
||||
// according to general_sort_order and each sub's sort_order.
|
||||
function buildSections(parent, subcategories, directProducts) {
|
||||
const sections = []
|
||||
|
||||
if (directProducts.length > 0) {
|
||||
sections.push({ _isGeneral: true, sort_order: parent.general_sort_order, products: directProducts })
|
||||
}
|
||||
|
||||
for (const sub of subcategories) {
|
||||
sections.push({ ...sub, _isGeneral: false, sort_order: sub.sort_order })
|
||||
}
|
||||
|
||||
return sections.sort((a, b) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
export default function ProductPicker({ categories, products, onAdd, viewAllOpen, setViewAllOpen }) {
|
||||
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
|
||||
const initialCatId = topLevel[0]?.id ?? null
|
||||
const [activeCat, setActiveCat] = useState(initialCatId)
|
||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||
// Track which sub-category sections are expanded (by sub-cat id or '__general__')
|
||||
const [expandedSubs, setExpandedSubs] = useState(() => {
|
||||
if (!initialCatId) return {}
|
||||
const subs = categories.filter(c => c.parent_id === initialCatId)
|
||||
const state = {}
|
||||
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
|
||||
return state
|
||||
})
|
||||
|
||||
const activeParent = categories.find(c => c.id === activeCat)
|
||||
const subcategories = activeParent
|
||||
? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
|
||||
: []
|
||||
const hasSubcats = subcategories.length > 0
|
||||
|
||||
// Products directly on this top-level category (no sub-cat)
|
||||
const directProducts = products.filter(p => p.category_id === activeCat)
|
||||
// Products for the flat view (no sub-cats)
|
||||
const flatProducts = products.filter(p => p.category_id === activeCat)
|
||||
|
||||
// Build sections for accordion view
|
||||
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
|
||||
|
||||
function buildDefaultExpanded(catId) {
|
||||
const subs = categories.filter(c => c.parent_id === catId)
|
||||
const state = {}
|
||||
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
|
||||
return state
|
||||
}
|
||||
|
||||
function selectCategory(id) {
|
||||
setActiveCat(id)
|
||||
setViewAllOpen(false)
|
||||
setExpandedSubs(buildDefaultExpanded(id))
|
||||
}
|
||||
|
||||
function toggleSub(key) {
|
||||
setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
function openDrawer(product) { setDrawerProduct(product) }
|
||||
function closeDrawer() { setDrawerProduct(null) }
|
||||
|
||||
return (
|
||||
<div className="product-picker">
|
||||
<div className="category-tabs">
|
||||
<div className="category-tabs__scroll-wrap">
|
||||
<div className="category-tabs__scroll">
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color
|
||||
? isActive ? cat.color : hexToRgba(cat.color, 0.35)
|
||||
: isActive ? 'var(--accent)' : 'var(--bg3)'
|
||||
const color = cat.color
|
||||
? isActive ? '#fff' : 'rgba(255,255,255,0.65)'
|
||||
: isActive ? 'var(--accent-fg)' : 'var(--muted)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="cat-tab"
|
||||
style={{ background: bg, color, border: isActive && cat.color ? `2px solid ${cat.color}` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product area — flat grid or accordion depending on sub-cats */}
|
||||
<div className="product-area">
|
||||
{!hasSubcats ? (
|
||||
// No sub-categories: original flat grid
|
||||
<>
|
||||
<ProductGrid products={flatProducts} onOpen={openDrawer} />
|
||||
{flatProducts.length === 0 && (
|
||||
<p style={{ color: '#64748b', textAlign: 'center', padding: 32 }}>
|
||||
Δεν υπάρχουν προϊόντα
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Has sub-categories: accordion view
|
||||
<div className="subcat-accordion">
|
||||
{sections.map(section => {
|
||||
const key = section._isGeneral ? '__general__' : String(section.id)
|
||||
const isOpen = !!expandedSubs[key]
|
||||
const sectionProducts = section._isGeneral
|
||||
? section.products
|
||||
: products.filter(p => p.category_id === section.id)
|
||||
if (sectionProducts.length === 0) return null
|
||||
|
||||
// General products appear flat — no collapsible header
|
||||
if (section._isGeneral) {
|
||||
return (
|
||||
<div key={key} className="subcat-general">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const accentColor = section.color ?? activeParent?.color ?? null
|
||||
|
||||
return (
|
||||
<div key={key} className="subcat-section">
|
||||
<button
|
||||
className={`subcat-header ${isOpen ? 'subcat-header--open' : ''}`}
|
||||
onClick={() => toggleSub(key)}
|
||||
>
|
||||
{accentColor && <span className="subcat-header__pill" style={{ background: accentColor }} />}
|
||||
<span className="subcat-header__name">{section.name}</span>
|
||||
<span className="subcat-header__count">{sectionProducts.length}</span>
|
||||
<svg
|
||||
className="subcat-header__chevron"
|
||||
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="subcat-body">
|
||||
<ProductGrid products={sectionProducts} onOpen={openDrawer} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View All modal — top-level categories only */}
|
||||
{viewAllOpen && (
|
||||
<div className="modal-overlay" onClick={() => setViewAllOpen(false)}>
|
||||
<div
|
||||
className="cat-all-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="cat-all-modal__header">
|
||||
<span className="cat-all-modal__title">Κατηγορίες</span>
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="cat-all-grid">
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color || 'var(--bg3)'
|
||||
const overlay = isActive ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.35)'
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`cat-all-tile ${isActive ? 'cat-all-tile--active' : ''}`}
|
||||
style={{ background: bg, boxShadow: isActive ? `0 0 0 3px #fff` : undefined }}
|
||||
onClick={() => selectCategory(cat.id)}
|
||||
>
|
||||
<span className="cat-all-tile__overlay" style={{ background: overlay }} />
|
||||
<span className="cat-all-tile__name">{cat.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OrderDrawer
|
||||
product={drawerProduct}
|
||||
isOpen={!!drawerProduct}
|
||||
onClose={closeDrawer}
|
||||
onAdd={item => { onAdd(item); closeDrawer() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
677
waiter_pwa/src/components/TableCard.jsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import useThemeStore from '../store/themeStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
|
||||
|
||||
const STATUS_LABELS = {
|
||||
free: 'ΕΛΕΥΘΕΡΟ',
|
||||
open: 'ΑΝΟΙΧΤΟ',
|
||||
mine: 'ΔΙΚΟ ΜΟΥ',
|
||||
paid: 'ΠΛΗΡΩΜΕΝΟ',
|
||||
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 8
|
||||
const HOLD_MS = 480
|
||||
|
||||
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const AVATAR_PALETTE = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a']
|
||||
|
||||
function avatarColor(name = '') {
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
|
||||
}
|
||||
|
||||
function WaiterAvatar({ waiter, size = 22, ring }) {
|
||||
const displayName = waiter.nickname || waiter.full_name || waiter.username || '?'
|
||||
const initials = displayName.trim().split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
|
||||
const ringStyle = ring ? { boxShadow: `0 0 0 2px ${ring}` } : {}
|
||||
|
||||
if (waiter.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={waiter.avatar_url}
|
||||
alt={displayName}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
objectFit: 'cover', flexShrink: 0,
|
||||
...ringStyle,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: avatarColor(displayName),
|
||||
color: 'white', fontSize: size * 0.4, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
...ringStyle,
|
||||
}}>{initials}</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renders [icon] Name, [icon] Name inline. Falls back to icons + "X Waiters" if they don't fit
|
||||
// (we approximate "don't fit" as > 2 waiters for the compact footer height).
|
||||
function WaiterRow({ waiters, size = 22, cfg }) {
|
||||
if (!waiters?.length) return null
|
||||
const textColor = cfg.nameText
|
||||
|
||||
// ≤ 2 waiters: show icon + name pairs
|
||||
if (waiters.length <= 2) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'nowrap', overflow: 'hidden', minWidth: 0 }}>
|
||||
{waiters.map((w, i) => {
|
||||
const name = w.nickname || w.full_name || w.username || '?'
|
||||
return (
|
||||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0, overflow: 'hidden' }}>
|
||||
{i > 0 && <span style={{ color: textColor, opacity: 0.3, fontSize: 14, flexShrink: 0 }}>·</span>}
|
||||
<WaiterAvatar waiter={w} size={size} />
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.85,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// > 2 waiters: icons only + "X Waiters" label
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{waiters.slice(0, 3).map((w, i) => (
|
||||
<div key={w.id} style={{ marginLeft: i === 0 ? 0 : -(size * 0.28) }}>
|
||||
<WaiterAvatar waiter={w} size={size} ring={cfg.cardBg} />
|
||||
</div>
|
||||
))}
|
||||
{waiters.length > 3 && (
|
||||
<div style={{
|
||||
marginLeft: -(size * 0.28), height: size, padding: '0 6px',
|
||||
borderRadius: size, background: `${cfg.nameText}20`,
|
||||
color: cfg.nameText, fontSize: 10, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>+{waiters.length - 3}</div>
|
||||
)}
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: textColor, opacity: 0.7, marginLeft: 4 }}>
|
||||
{waiters.length} σερβιτόροι
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status pill ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusPill({ label, badgeBg, badgeText, small }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
height: small ? 18 : 20,
|
||||
padding: small ? '0 6px' : '0 8px',
|
||||
borderRadius: 4,
|
||||
background: badgeBg,
|
||||
color: badgeText,
|
||||
fontSize: small ? 9 : 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 0.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{label}</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Flag dot ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function FlagDot({ flag, size = 22 }) {
|
||||
const textColor = flag.text_color || '#ffffff'
|
||||
return (
|
||||
<div
|
||||
title={flag.name}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: flag.color || '#6295F3',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.55,
|
||||
flexShrink: 0,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{flag.emoji || '🏷️'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Flag overflow row: show up to maxShow dots, then +N bubble ───────────────
|
||||
|
||||
function FlagDots({ flags, size, maxShow }) {
|
||||
if (!flags.length) return null
|
||||
const visible = flags.slice(0, maxShow)
|
||||
const overflow = flags.length - maxShow
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
|
||||
{visible.map(f => <FlagDot key={f.id} flag={f} size={size} />)}
|
||||
{overflow > 0 && (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.18)',
|
||||
color: '#fff', fontSize: size * 0.44, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>+{overflow}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Flag chip (icon + label) ─────────────────────────────────────────────────
|
||||
|
||||
function FlagChip({ flag }) {
|
||||
const textColor = flag.text_color || '#ffffff'
|
||||
return (
|
||||
<div
|
||||
title={flag.name}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
height: 26, padding: '0 9px',
|
||||
borderRadius: 13,
|
||||
background: flag.color || '#6295F3',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, lineHeight: 1 }}>{flag.emoji || '🏷️'}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: textColor, whiteSpace: 'nowrap' }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Amount display ───────────────────────────────────────────────────────────
|
||||
|
||||
function Amount({ value, size = 22, color }) {
|
||||
const s = Number(value || 0).toFixed(2)
|
||||
const [whole, cents] = s.split('.')
|
||||
const isNum = typeof size === 'number'
|
||||
const centsSize = isNum ? size * 0.56 : `calc(${size} * 0.56)`
|
||||
return (
|
||||
<div style={{ lineHeight: 1, color: color || 'inherit' }}>
|
||||
<span style={{ fontSize: size, fontWeight: 800, letterSpacing: -0.5 }}>{whole}</span>
|
||||
<span style={{ fontSize: centsSize, fontWeight: 800, opacity: 0.8 }}>.{cents}€</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card variants ────────────────────────────────────────────────────────────
|
||||
|
||||
// 1x1 — square-ish, 4 per row. Badges top (up to 2 + +N), name center, status bottom.
|
||||
function Card1x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', aspectRatio: '1 / 1.05',
|
||||
background: cfg.cardBg, borderRadius: 14,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
padding: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}>
|
||||
{/* top strip: badges up to 2, then +N */}
|
||||
<div style={{ height: '20%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<FlagDots flags={flags} size={16} maxShow={2} />
|
||||
</div>
|
||||
|
||||
{/* center: name */}
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 800, fontSize: 'clamp(18px, 5vw, 26px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
|
||||
{/* bottom strip: status */}
|
||||
<div style={{ height: '20%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
<span style={{
|
||||
fontSize: 7, fontWeight: 800, letterSpacing: 0.3,
|
||||
color: cfg.badgeText, textTransform: 'uppercase',
|
||||
background: cfg.badgeBg, borderRadius: 3,
|
||||
padding: '1px 4px', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{STATUS_LABELS[statusKey]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2x1 — half width, compact horizontal. Name left, status + badges (up to 3 + +N) right.
|
||||
function Card2x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 64,
|
||||
background: cfg.cardBg, borderRadius: 14,
|
||||
padding: '10px 12px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 10, overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}>
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(18px, 4.5vw, 24px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-end', justifyContent: 'center', gap: 4,
|
||||
}}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
{flags.length > 0 && (
|
||||
<FlagDots flags={flags} size={18} maxShow={3} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2x2 — current-style square. Name top-left, status (slightly smaller) below, amount bottom-left, flags right.
|
||||
function Card2x2({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', minHeight: 116,
|
||||
background: cfg.cardBg, borderRadius: 16,
|
||||
padding: '12px 12px 12px',
|
||||
display: 'flex', gap: 8, overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
}}>
|
||||
{/* left column */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{
|
||||
fontSize: 'clamp(22px, 5.5vw, 36px)', fontWeight: 800,
|
||||
lineHeight: 1.05, color: cfg.nameText, letterSpacing: -0.5,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</span>
|
||||
<div style={{ marginTop: 5 }}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8, minHeight: 28 }}>
|
||||
{showAmount && <Amount value={total} size={'clamp(22px, 5.5vw, 36px)'} color={cfg.nameText} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* right column: flags — show 2, then +N */}
|
||||
{flags.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column-reverse',
|
||||
gap: 4, alignItems: 'flex-end', justifyContent: 'flex-start',
|
||||
}}>
|
||||
<FlagDots flags={flags} size={26} maxShow={2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4x1 — full width horizontal. Name + amount left-center, badges (up to 3 + +N) + status right.
|
||||
function Card4x1({ table, order, flags, waiterObjects, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 68,
|
||||
background: cfg.cardBg, borderRadius: 14,
|
||||
padding: '12px 14px',
|
||||
display: 'flex', alignItems: 'center', gap: 14, overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}>
|
||||
{/* name */}
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(20px, 4.5vw, 28px)',
|
||||
letterSpacing: -0.5, color: cfg.nameText, lineHeight: 1, flexShrink: 0,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
|
||||
{/* separator dot */}
|
||||
<span style={{ color: cfg.nameText, opacity: 0.3, fontSize: 20, lineHeight: 1, flexShrink: 0 }}>·</span>
|
||||
|
||||
{/* amount */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{showAmount && <Amount value={total} size={'clamp(20px, 4.5vw, 28px)'} color={cfg.nameText} />}
|
||||
</div>
|
||||
|
||||
{/* flags up to 3 + +N */}
|
||||
{flags.length > 0 && (
|
||||
<FlagDots flags={flags} size={24} maxShow={3} />
|
||||
)}
|
||||
|
||||
{/* status */}
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4x2 — full width, tall. One main row: name+zone left, status center, amount+flags right. Flag chips below. Waiter footer.
|
||||
function Card4x2({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const total = order?.items?.filter(i => i.status === 'active').reduce((s, i) => s + i.unit_price * i.quantity, 0) ?? 0
|
||||
const showAmount = !isFree
|
||||
const showWaiters = !isFree && waiterObjects.length > 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
background: cfg.cardBg, borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* main body */}
|
||||
<div style={{ padding: '14px 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* top row: name LEFT | status CENTER | amount RIGHT — all top-aligned */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
{/* left: name + zone */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(30px, 7vw, 44px)',
|
||||
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
{groupName && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||||
color: cfg.nameText, opacity: 0.6,
|
||||
textTransform: 'uppercase', marginTop: 3,
|
||||
}}>
|
||||
{groupName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* center: status pill — top-aligned via paddingTop to optically align with name cap */}
|
||||
<div style={{ paddingTop: 4, flexShrink: 0 }}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} />
|
||||
</div>
|
||||
|
||||
{/* right: amount — top-aligned */}
|
||||
{showAmount && (
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Amount value={total} size={'clamp(30px, 7vw, 44px)'} color={cfg.nameText} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* flag chips row — right-aligned */}
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', flexWrap: 'wrap', gap: 6 }}>
|
||||
{flags.slice(0, 4).map(f => <FlagChip key={f.id} flag={f} />)}
|
||||
{flags.length > 4 && (
|
||||
<div style={{
|
||||
height: 26, padding: '0 9px', borderRadius: 13,
|
||||
background: 'rgba(0,0,0,0.18)', color: '#fff',
|
||||
fontSize: 11, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>+{flags.length - 4}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer: waiters */}
|
||||
<div style={{
|
||||
borderTop: `1px solid ${cfg.nameText}22`,
|
||||
padding: '10px 14px', minHeight: 40,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
{showWaiters
|
||||
? <WaiterRow waiters={waiterObjects} size={24} cfg={cfg} />
|
||||
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4x3 — full width, two-column detail card. Left: name/zone/status/amount. Right: order items list. Footer: waiters.
|
||||
function Card4x3({ table, order, flags, waiterObjects, groupName, cfg, statusKey }) {
|
||||
const isFree = !order
|
||||
const activeItems = order?.items?.filter(i => i.status === 'active') ?? []
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const showWaiters = !isFree && waiterObjects.length > 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
background: cfg.cardBg, borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.12)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', padding: '14px 14px 10px', gap: 14, minWidth: 0, overflow: 'hidden' }}>
|
||||
{/* left column: name, zone, amount, status, flags */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 100, flexShrink: 0, justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontWeight: 800, fontSize: 'clamp(28px, 6vw, 40px)',
|
||||
letterSpacing: -1.5, lineHeight: 1, color: cfg.nameText,
|
||||
}}>
|
||||
{table.label || `T${table.number}`}
|
||||
</div>
|
||||
{groupName && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: 0.8,
|
||||
color: cfg.nameText, opacity: 0.6,
|
||||
textTransform: 'uppercase', marginTop: 3,
|
||||
}}>
|
||||
{groupName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{!isFree && <Amount value={total} size={'clamp(22px, 5vw, 32px)'} color={cfg.nameText} />}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusPill label={STATUS_LABELS[statusKey]} badgeBg={cfg.badgeBg} badgeText={cfg.badgeText} small />
|
||||
</div>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<FlagDots flags={flags} size={22} maxShow={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* divider */}
|
||||
<div style={{ width: 1, background: `${cfg.nameText}20`, alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* right column: order items */}
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
{isFree ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Ελεύθερο</span>
|
||||
</div>
|
||||
) : activeItems.length === 0 ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.35 }}>Κανένα είδος</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
||||
{activeItems.slice(0, 7).map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', alignItems: 'baseline', gap: 5, overflow: 'hidden', minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, color: cfg.nameText,
|
||||
background: `${cfg.nameText}18`, borderRadius: 3,
|
||||
padding: '1px 5px', flexShrink: 0,
|
||||
}}>{item.quantity}×</span>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 500, color: cfg.nameText,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
|
||||
}}>{item.product?.name || `#${item.product_id}`}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: cfg.nameText, opacity: 0.7, flexShrink: 0 }}>
|
||||
{(item.unit_price * item.quantity).toFixed(2)}€
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{activeItems.length > 7 && (
|
||||
<div style={{ fontSize: 11, color: cfg.nameText, opacity: 0.5, marginTop: 2 }}>
|
||||
+{activeItems.length - 7} ακόμα…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer: waiters */}
|
||||
<div style={{
|
||||
borderTop: `1px solid ${cfg.nameText}22`,
|
||||
padding: '10px 14px', minHeight: 38,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
{showWaiters
|
||||
? <WaiterRow waiters={waiterObjects} size={22} cfg={cfg} />
|
||||
: <span style={{ fontSize: 12, color: cfg.nameText, opacity: 0.45 }}>—</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TableCard({
|
||||
table,
|
||||
order,
|
||||
isMine,
|
||||
flags = [],
|
||||
groupName = '',
|
||||
waiterObjects = [],
|
||||
density = '2x2',
|
||||
onClick,
|
||||
onLongPress,
|
||||
}) {
|
||||
const holdTimer = useRef(null)
|
||||
const startPos = useRef({ x: 0, y: 0 })
|
||||
const didFire = useRef(false)
|
||||
const [showTip, setShowTip] = useState(false)
|
||||
|
||||
const dark = useThemeStore(s => s.dark)
|
||||
const colours = useTableColourStore(s => s.colours)
|
||||
|
||||
let statusKey = 'free'
|
||||
if (order?.status === 'paid') statusKey = 'paid'
|
||||
else if (order?.status === 'partially_paid') statusKey = 'partially_paid'
|
||||
else if (order && isMine) statusKey = 'mine'
|
||||
else if (order) statusKey = 'open'
|
||||
|
||||
const mode = dark ? 'dark' : 'light'
|
||||
const cfg = colours[mode][statusKey]
|
||||
|
||||
function cancel() {
|
||||
clearTimeout(holdTimer.current)
|
||||
holdTimer.current = null
|
||||
}
|
||||
|
||||
function onTouchStart(e) {
|
||||
const t = e.touches[0]
|
||||
startPos.current = { x: t.clientX, y: t.clientY }
|
||||
didFire.current = false
|
||||
holdTimer.current = setTimeout(() => {
|
||||
didFire.current = true
|
||||
if (onLongPress) onLongPress()
|
||||
else setShowTip(true)
|
||||
}, HOLD_MS)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!holdTimer.current) return
|
||||
const t = e.touches[0]
|
||||
const dx = Math.abs(t.clientX - startPos.current.x)
|
||||
const dy = Math.abs(t.clientY - startPos.current.y)
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||||
}
|
||||
|
||||
function onTouchEnd() { cancel(); setShowTip(false) }
|
||||
|
||||
function onMouseDown(e) {
|
||||
startPos.current = { x: e.clientX, y: e.clientY }
|
||||
didFire.current = false
|
||||
holdTimer.current = setTimeout(() => {
|
||||
didFire.current = true
|
||||
if (onLongPress) onLongPress()
|
||||
else setShowTip(true)
|
||||
}, HOLD_MS)
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!holdTimer.current) return
|
||||
const dx = Math.abs(e.clientX - startPos.current.x)
|
||||
const dy = Math.abs(e.clientY - startPos.current.y)
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) cancel()
|
||||
}
|
||||
function onMouseUp() { cancel(); setShowTip(false) }
|
||||
function onMouseLeave() { cancel(); setShowTip(false) }
|
||||
|
||||
function handleClick(e) {
|
||||
if (didFire.current) { e.preventDefault(); return }
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
const cardProps = { table, order, flags, waiterObjects, groupName, cfg, statusKey }
|
||||
|
||||
const CardComponent = {
|
||||
'1x1': Card1x1,
|
||||
'2x1': Card2x1,
|
||||
'2x2': Card2x2,
|
||||
'4x1': Card4x1,
|
||||
'4x2': Card4x2,
|
||||
'4x3': Card4x3,
|
||||
}[density] || Card2x2
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
|
||||
<button
|
||||
style={{ display: 'block', width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
|
||||
onClick={handleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<CardComponent {...cardProps} />
|
||||
</button>
|
||||
|
||||
{showTip && flags.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 'calc(100% + 8px)', right: 0,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, padding: '8px 12px', zIndex: 50,
|
||||
boxShadow: '0 4px 16px var(--shadow)',
|
||||
minWidth: 160, pointerEvents: 'none',
|
||||
}}>
|
||||
{flags.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 15 }}>{f.emoji || '🏷️'}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text)' }}>{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
waiter_pwa/src/components/UserMenu.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useShiftStore from '../store/shiftStore'
|
||||
import useThemeStore from '../store/themeStore'
|
||||
import client from '../api/client'
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDuration(iso) {
|
||||
if (!iso) return ''
|
||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||
if (mins < 60) return `${mins}λ`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||||
}
|
||||
|
||||
export default function UserMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { dark, toggle } = useThemeStore()
|
||||
const {
|
||||
shift, selfEndAllowed,
|
||||
setShift, clearShift,
|
||||
} = useShiftStore()
|
||||
|
||||
useEffect(() => {
|
||||
function onClick(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => document.removeEventListener('mousedown', onClick)
|
||||
}, [])
|
||||
|
||||
function handleLogout() {
|
||||
setOpen(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const activeBreak = shift?.breaks?.find(b => !b.ended_at)
|
||||
const isWaiter = user?.role === 'waiter'
|
||||
|
||||
async function handleEndShift() {
|
||||
if (!window.confirm('Να τελειώσει η βάρδια σου;')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await client.post('/api/shifts/end', {})
|
||||
clearShift()
|
||||
setOpen(false)
|
||||
} catch {
|
||||
// ignore — gate will re-check
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBreak() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (activeBreak) {
|
||||
await client.post(`/api/shifts/${shift.id}/break/end`)
|
||||
} else {
|
||||
await client.post(`/api/shifts/${shift.id}/break/start`)
|
||||
}
|
||||
const res = await client.get('/api/shifts/my')
|
||||
setShift(res.data)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
title="Μενού χρήστη"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 10px' }}
|
||||
>
|
||||
{/* Break indicator dot */}
|
||||
{activeBreak && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: 'var(--accent)', flexShrink: 0,
|
||||
animation: 'tab-pulse 1.5s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{user?.username}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-menu-dropdown">
|
||||
{/* ── Shift info (waiters only) ─────────────────────── */}
|
||||
{isWaiter && shift && (
|
||||
<>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg3)',
|
||||
borderRadius: 10,
|
||||
margin: '4px 8px 2px',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 4 }}>
|
||||
Βάρδια ενεργή
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text)', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>Από {formatTime(shift.started_at)}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{formatDuration(shift.started_at)}</span>
|
||||
</div>
|
||||
{shift.starting_cash != null && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Αρχικά: €{shift.starting_cash.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
{activeBreak && (
|
||||
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 4, fontWeight: 600 }}>
|
||||
☕ Σε διάλειμμα από {formatTime(activeBreak.started_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Break button */}
|
||||
<button
|
||||
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
|
||||
onClick={handleBreak}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="user-menu-item__icon">{activeBreak ? '▶' : '☕'}</span>
|
||||
<span>{activeBreak ? 'Τέλος Διαλείμματος' : 'Διάλειμμα'}</span>
|
||||
</button>
|
||||
|
||||
{/* End shift button */}
|
||||
{selfEndAllowed ? (
|
||||
<button
|
||||
className={`user-menu-item ${busy ? 'user-menu-item--disabled' : ''}`}
|
||||
onClick={handleEndShift}
|
||||
disabled={busy}
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<span className="user-menu-item__icon">⏹</span>
|
||||
<span>Τέλος Βάρδιας</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ padding: '8px 16px', fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
Ζητήστε από τον διαχειριστή να κλείσει τη βάρδια
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Theme toggle ──────────────────────────────────── */}
|
||||
<button className="user-menu-item" onClick={() => { toggle(); setOpen(false) }}>
|
||||
<span className="user-menu-item__icon">{dark ? '☀️' : '🌙'}</span>
|
||||
<span>{dark ? 'Φωτεινό θέμα' : 'Σκοτεινό θέμα'}</span>
|
||||
</button>
|
||||
|
||||
{/* ── Settings ──────────────────────────────────────── */}
|
||||
<button className="user-menu-item" onClick={() => { setOpen(false); navigate('/settings') }}>
|
||||
<span className="user-menu-item__icon">⚙️</span>
|
||||
<span>Ρυθμίσεις</span>
|
||||
</button>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button className="user-menu-item user-menu-item--danger" onClick={handleLogout}>
|
||||
<span className="user-menu-item__icon">⏏</span>
|
||||
<span>Αποσύνδεση</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
waiter_pwa/src/context/NotificationContext.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
|
||||
const NotificationContext = createContext(null)
|
||||
|
||||
export function useNotifications() {
|
||||
return useContext(NotificationContext)
|
||||
}
|
||||
|
||||
// ─── Persistent banner (one message at a time, stacked) ───────────────────────
|
||||
|
||||
function NotificationBanner({ message, onAck }) {
|
||||
const tableIds = (() => { try { return JSON.parse(message.table_ids || '[]') } catch { return [] } })()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
background: '#1e1b4b', border: '1px solid #6366f1',
|
||||
borderRadius: 14, padding: '12px 14px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
animation: 'slideIn 0.25s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: 22, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{message.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{message.sender_name}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#e2e8f0', lineHeight: 1.4 }}>
|
||||
{message.body}
|
||||
</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>
|
||||
Τραπέζι{tableIds.length > 1 ? 'α' : ''}: {tableIds.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAck(message.id)}
|
||||
style={{
|
||||
flexShrink: 0, height: 32, padding: '0 12px',
|
||||
borderRadius: 8, border: 'none',
|
||||
background: '#4f46e5', color: 'white',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>OK ✓</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const { token, user } = useAuthStore()
|
||||
const [pendingMessages, setPendingMessages] = useState([])
|
||||
const [recentMessages, setRecentMessages] = useState([])
|
||||
|
||||
const fetchUnread = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/unread')
|
||||
setPendingMessages(res.data)
|
||||
} catch { /* offline or unauthenticated — swallow */ }
|
||||
}, [token, user?.id])
|
||||
|
||||
const fetchRecent = useCallback(async () => {
|
||||
if (!token || !user) return
|
||||
try {
|
||||
const res = await client.get('/api/messages/recent?limit=10')
|
||||
setRecentMessages(res.data)
|
||||
} catch { }
|
||||
}, [token, user?.id])
|
||||
|
||||
// Initial load + 5s fallback poll (SSE is primary, poll is safety net)
|
||||
useEffect(() => {
|
||||
if (!token || !user) return
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
const id = setInterval(fetchUnread, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [token, user?.id])
|
||||
|
||||
// SSE message_sent events → add to pending without polling
|
||||
useEffect(() => {
|
||||
function onSSEEvent(e) {
|
||||
const { type, data } = e.detail
|
||||
if (type !== 'message_sent') return
|
||||
if (!user) return
|
||||
|
||||
// Check if this message targets us (empty = broadcast)
|
||||
const targets = data.target_waiter_ids || []
|
||||
if (targets.length > 0 && !targets.includes(user.id)) return
|
||||
|
||||
const msg = {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
sender_name: data.sender_name,
|
||||
body: data.body,
|
||||
table_ids: data.table_ids,
|
||||
created_at: data.created_at,
|
||||
acked_by: [],
|
||||
}
|
||||
|
||||
setPendingMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev]
|
||||
})
|
||||
setRecentMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [msg, ...prev].slice(0, 10)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('sse-event', onSSEEvent)
|
||||
return () => window.removeEventListener('sse-event', onSSEEvent)
|
||||
}, [user?.id])
|
||||
|
||||
// Fallback: re-fetch unread when SSE reconnects (catches any messages missed during gap)
|
||||
useEffect(() => {
|
||||
function onSSEConnect() {
|
||||
fetchUnread()
|
||||
fetchRecent()
|
||||
}
|
||||
// SSEProvider fires this via setOnline — we listen to the connection store indirectly
|
||||
// through the backend-coming-back-online signal that SSEProvider dispatches
|
||||
window.addEventListener('sse-reconnected', onSSEConnect)
|
||||
return () => window.removeEventListener('sse-reconnected', onSSEConnect)
|
||||
}, [fetchUnread, fetchRecent])
|
||||
|
||||
async function ackMessage(messageId) {
|
||||
try {
|
||||
await client.post(`/api/messages/${messageId}/ack`)
|
||||
setPendingMessages(prev => prev.filter(m => m.id !== messageId))
|
||||
fetchRecent()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const unreadCount = pendingMessages.length
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ pendingMessages, recentMessages, unreadCount, ackMessage, fetchRecent, fetchUnread }}>
|
||||
{children}
|
||||
|
||||
{/* Floating banner stack (max 3 visible) */}
|
||||
{pendingMessages.length > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 64, left: 0, right: 0, zIndex: 9999,
|
||||
padding: '0 12px',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<style>{`@keyframes slideIn { from { transform: translateY(-16px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }`}</style>
|
||||
{pendingMessages.slice(0, 3).map(msg => (
|
||||
<div key={msg.id} style={{ pointerEvents: 'all' }}>
|
||||
<NotificationBanner message={msg} onAck={ackMessage} />
|
||||
</div>
|
||||
))}
|
||||
{pendingMessages.length > 3 && (
|
||||
<div style={{
|
||||
textAlign: 'center', fontSize: 12, color: '#94a3b8',
|
||||
pointerEvents: 'all',
|
||||
}}>
|
||||
+{pendingMessages.length - 3} ακόμα μηνύματα
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
189
waiter_pwa/src/context/SSEContext.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createContext, useContext, useCallback, useEffect, useRef } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import { useSSE } from '../hooks/useSSE'
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
import { flushOfflinePayments } from '../services/offlinePayments'
|
||||
|
||||
const SSEContext = createContext(null)
|
||||
|
||||
export function useSSEContext() {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000
|
||||
|
||||
export function SSEProvider({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
const { setLost, setOnline } = useConnectionStore()
|
||||
const sseAlive = useRef(false)
|
||||
const heartbeatRef = useRef(null)
|
||||
|
||||
// Keep setLost/setOnline in refs so heartbeat/event closures are never stale
|
||||
const setLostRef = useRef(setLost)
|
||||
const setOnlineRef = useRef(setOnline)
|
||||
useEffect(() => { setLostRef.current = setLost }, [setLost])
|
||||
useEffect(() => { setOnlineRef.current = setOnline }, [setOnline])
|
||||
|
||||
// ── Snapshot helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const snapshotTables = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/tables/')
|
||||
await db.tables.bulkPut(res.data)
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [])
|
||||
|
||||
const snapshotOrders = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.get('/api/orders/active')
|
||||
const slimOrders = res.data
|
||||
// Fetch full order details (with items) so emergency mode has them
|
||||
const fullOrders = await Promise.all(
|
||||
slimOrders.map(o =>
|
||||
client.get(`/api/orders/${o.id}`)
|
||||
.then(r => ({
|
||||
...r.data,
|
||||
waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [],
|
||||
}))
|
||||
.catch(() => o)
|
||||
)
|
||||
)
|
||||
await db.orders.bulkPut(fullOrders)
|
||||
} catch { /* offline — snapshot stays as-is */ }
|
||||
}, [])
|
||||
|
||||
const fullRefresh = useCallback(async () => {
|
||||
await Promise.all([snapshotTables(), snapshotOrders()])
|
||||
}, [snapshotTables, snapshotOrders])
|
||||
|
||||
// ── SSE event handler ────────────────────────────────────────────────────────
|
||||
|
||||
const handleEvent = useCallback(async (type, data) => {
|
||||
// Dispatch for any UI component listening to window events
|
||||
window.dispatchEvent(new CustomEvent('sse-event', { detail: { type, data } }))
|
||||
|
||||
// Incrementally update IndexedDB snapshot
|
||||
switch (type) {
|
||||
case 'order_updated':
|
||||
case 'order_paid': {
|
||||
// Try to fetch the full order to keep items in the snapshot
|
||||
try {
|
||||
const full = await client.get(`/api/orders/${data.order_id}`)
|
||||
const o = full.data
|
||||
await db.orders.put({
|
||||
...o,
|
||||
waiter_ids: o.waiters?.map(w => w.waiter_id) ?? [],
|
||||
})
|
||||
} catch {
|
||||
// Fallback: update only the slim fields we know
|
||||
const existing = await db.orders.get(data.order_id)
|
||||
await db.orders.put({
|
||||
...(existing || {}),
|
||||
id: data.order_id,
|
||||
table_id: data.table_id,
|
||||
status: data.status,
|
||||
waiter_ids: existing?.waiter_ids || [],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'order_closed': {
|
||||
await db.orders.delete(data.order_id)
|
||||
break
|
||||
}
|
||||
case 'table_list_changed': {
|
||||
await snapshotTables()
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [snapshotTables])
|
||||
|
||||
// ── SSE connection lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
sseAlive.current = true
|
||||
const wasEmergency = useConnectionStore.getState().status === 'emergency'
|
||||
setOnlineRef.current()
|
||||
window.dispatchEvent(new Event('sse-reconnected'))
|
||||
if (wasEmergency) {
|
||||
const result = await flushOfflinePayments()
|
||||
if (result.duplicates > 0 || result.failed > 0) {
|
||||
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
|
||||
}
|
||||
}
|
||||
await fullRefresh()
|
||||
}, [fullRefresh])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
sseAlive.current = false
|
||||
// Don't immediately setLost — heartbeat is the authoritative check
|
||||
}, [])
|
||||
|
||||
const { reconnect } = useSSE({
|
||||
token,
|
||||
enabled: !!token,
|
||||
onEvent: handleEvent,
|
||||
onConnect: handleConnect,
|
||||
onDisconnect: handleDisconnect,
|
||||
})
|
||||
|
||||
// ── Heartbeat ────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
async function beat() {
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
const currentStatus = useConnectionStore.getState().status
|
||||
if (currentStatus === 'lost' || currentStatus === 'emergency') {
|
||||
if (currentStatus === 'emergency') {
|
||||
const result = await flushOfflinePayments()
|
||||
if (result.duplicates > 0 || result.failed > 0) {
|
||||
window.dispatchEvent(new CustomEvent('offline-sync-result', { detail: result }))
|
||||
}
|
||||
}
|
||||
setOnlineRef.current()
|
||||
reconnect()
|
||||
await fullRefresh()
|
||||
}
|
||||
} catch {
|
||||
if (!sseAlive.current) {
|
||||
setLostRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
heartbeatRef.current = setInterval(beat, HEARTBEAT_INTERVAL)
|
||||
return () => clearInterval(heartbeatRef.current)
|
||||
// reconnect and fullRefresh are stable (useCallback with no changing deps)
|
||||
}, [token, reconnect, fullRefresh])
|
||||
|
||||
// ── React to failed API requests (immediate detection) ───────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
function onBackendOffline() {
|
||||
if (!sseAlive.current) {
|
||||
setLostRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener('backend-offline', onBackendOffline)
|
||||
return () => window.removeEventListener('backend-offline', onBackendOffline)
|
||||
}, [])
|
||||
|
||||
// ── Initial snapshot on login ─────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fullRefresh()
|
||||
}, [token, fullRefresh])
|
||||
|
||||
return (
|
||||
<SSEContext.Provider value={{ reconnect, fullRefresh }}>
|
||||
{children}
|
||||
</SSEContext.Provider>
|
||||
)
|
||||
}
|
||||
15
waiter_pwa/src/db/posdb.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import Dexie from 'dexie'
|
||||
|
||||
/**
|
||||
* Local IndexedDB snapshot — written by SSE events and full GETs.
|
||||
* Read-only in Emergency Mode when the server is unreachable.
|
||||
*/
|
||||
const db = new Dexie('pos_snapshot')
|
||||
|
||||
db.version(1).stores({
|
||||
tables: 'id, group_id, is_active', // TableOut snapshots
|
||||
orders: 'id, table_id, status', // ActiveOrderSlim + OrderOut snapshots
|
||||
offline_payments: '++localId, uuid, synced', // queued emergency payments
|
||||
})
|
||||
|
||||
export default db
|
||||
93
waiter_pwa/src/hooks/useSSE.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const INITIAL_RECONNECT_DELAY = 3000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
|
||||
/**
|
||||
* Opens an SSE connection to /api/sse/stream?token=<jwt>.
|
||||
*
|
||||
* Callbacks (onEvent, onConnect, onDisconnect) are stored in refs so they are
|
||||
* always current without causing the EventSource to reconnect when they change.
|
||||
*
|
||||
* The connection is created/destroyed only when `token` or `enabled` changes.
|
||||
*/
|
||||
export function useSSE({ token, onEvent, onConnect, onDisconnect, enabled = true }) {
|
||||
// Keep callbacks in refs so the EventSource closure always calls the latest version
|
||||
const onEventRef = useRef(onEvent)
|
||||
const onConnectRef = useRef(onConnect)
|
||||
const onDisconnectRef = useRef(onDisconnect)
|
||||
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
|
||||
useEffect(() => { onConnectRef.current = onConnect }, [onConnect])
|
||||
useEffect(() => { onDisconnectRef.current = onDisconnect }, [onDisconnect])
|
||||
|
||||
const esRef = useRef(null)
|
||||
const reconnectTimer = useRef(null)
|
||||
const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY)
|
||||
const unmounted = useRef(false)
|
||||
// Expose reconnect so SSEContext can trigger it after heartbeat recovery
|
||||
const reconnectRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !enabled) return
|
||||
unmounted.current = false
|
||||
|
||||
function connect() {
|
||||
if (unmounted.current) return
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
|
||||
const url = `/api/sse/stream?token=${encodeURIComponent(token)}`
|
||||
const es = new EventSource(url)
|
||||
esRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
reconnectDelay.current = INITIAL_RECONNECT_DELAY
|
||||
onConnectRef.current?.()
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const { type, data } = JSON.parse(e.data)
|
||||
onEventRef.current?.(type, data)
|
||||
} catch {
|
||||
// malformed event — ignore
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
onDisconnectRef.current?.()
|
||||
if (unmounted.current) return
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
reconnectDelay.current = Math.min(
|
||||
reconnectDelay.current * 1.5,
|
||||
MAX_RECONNECT_DELAY
|
||||
)
|
||||
connect()
|
||||
}, reconnectDelay.current)
|
||||
}
|
||||
}
|
||||
|
||||
reconnectRef.current = connect
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
clearTimeout(reconnectTimer.current)
|
||||
esRef.current?.close()
|
||||
esRef.current = null
|
||||
}
|
||||
}, [token, enabled])
|
||||
|
||||
// Stable reference — never changes, so heartbeat useEffect dep array stays stable
|
||||
const reconnect = useCallback(() => {
|
||||
clearTimeout(reconnectTimer.current)
|
||||
reconnectDelay.current = INITIAL_RECONNECT_DELAY
|
||||
reconnectRef.current?.()
|
||||
}, [])
|
||||
|
||||
return { reconnect }
|
||||
}
|
||||
675
waiter_pwa/src/index.css
Normal file
@@ -0,0 +1,675 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* Prevent text selection everywhere — app behaves like native */
|
||||
*, *::before, *::after {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
input, textarea, [contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@keyframes tab-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.25; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gate-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
:root {
|
||||
/* "Free" table card — dark theme: muted blue-slate */
|
||||
--card-free-bg: #243044;
|
||||
--card-free-text: #94b8d4;
|
||||
--card-free-muted: rgba(148,184,212,0.45);
|
||||
|
||||
/* Dark theme — deep navy */
|
||||
--bg: #0d1520;
|
||||
--bg2: #1a2535;
|
||||
--bg3: #243044;
|
||||
--bg4: #2e3d54;
|
||||
--text: #edf2f7;
|
||||
--text2: #94a3b8;
|
||||
--muted: #5a7390;
|
||||
--accent: #f59e0b;
|
||||
--accent-fg: #1c1000;
|
||||
--accent-dim: #6b3a00;
|
||||
--success: #22c55e;
|
||||
--success-fg: #052e16;
|
||||
--danger: #f87171;
|
||||
--danger-sat: #ef4444;
|
||||
--danger-dim: #450a0a;
|
||||
--primary: #3b82f6;
|
||||
--primary-fg: #ffffff;
|
||||
--border: #253245;
|
||||
--shadow: rgba(0,0,0,0.35);
|
||||
font-family: system-ui, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* "Free" table card — light theme: cool light grey */
|
||||
--card-free-bg: #dde5ef;
|
||||
--card-free-text: #3d5270;
|
||||
--card-free-muted: rgba(61,82,112,0.45);
|
||||
|
||||
/* Light theme — warm slate / off-white */
|
||||
--bg: #f1f5f9;
|
||||
--bg2: #ffffff;
|
||||
--bg3: #e8edf4;
|
||||
--bg4: #dce3ee;
|
||||
--text: #1e293b;
|
||||
--text2: #475569;
|
||||
--muted: #7a8fa6;
|
||||
--accent: #e08c00;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-dim: #fef3c7;
|
||||
--success: #16a34a;
|
||||
--success-fg: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--danger-sat: #dc2626;
|
||||
--danger-dim: #fee2e2;
|
||||
--primary: #2563eb;
|
||||
--primary-fg: #ffffff;
|
||||
--border: #cdd6e0;
|
||||
--shadow: rgba(0,0,0,0.10);
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
overscroll-behavior: none;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────── */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100svh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.page--centered {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Top Bar ─────────────────────────────────────────────── */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 56px;
|
||||
}
|
||||
.top-bar__title {
|
||||
flex: 1;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.top-bar__user { font-size: 13px; color: var(--muted); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
min-height: 48px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn--primary { background: var(--primary); color: var(--primary-fg); }
|
||||
.btn--accent { background: var(--accent); color: var(--accent-fg); }
|
||||
.btn--success { background: var(--success); color: var(--success-fg); }
|
||||
.btn--danger { background: var(--danger-sat); color: #fff; }
|
||||
.btn--secondary{ background: var(--bg3); color: var(--text); }
|
||||
.btn--lg { min-height: 64px; font-size: 17px; border-radius: 14px; }
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-btn--danger { color: var(--danger); }
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── PIN Pad ─────────────────────────────────────────────── */
|
||||
.pin-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg2);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.pin-btn:active { background: var(--bg3); }
|
||||
.pin-btn--secondary { background: transparent; color: var(--muted); }
|
||||
.pin-btn--confirm { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.pin-btn--confirm:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── Login ───────────────────────────────────────────────── */
|
||||
.login-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.app-title { font-size: 32px; font-weight: 700; color: var(--accent); }
|
||||
.app-subtitle { font-size: 14px; color: var(--muted); }
|
||||
.login-greeting { font-size: 16px; color: var(--text); }
|
||||
.text-input {
|
||||
width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
.text-input:focus { border-color: var(--accent); }
|
||||
.error-msg { color: var(--danger); font-size: 14px; text-align: center; }
|
||||
|
||||
/* ── Zone Tab Bar (replaces old filter-tabs) ─────────────── */
|
||||
.zone-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.zone-tab-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Table Grid — density-driven via inline style ─────────── */
|
||||
/* Cards use inline styles per density, grid columns come from JS */
|
||||
.table-card-v2:active { transform: scale(0.96); }
|
||||
|
||||
/* ── Cart badge ──────────────────────────────────────────── */
|
||||
.cart-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Category Tabs ───────────────────────────────────────── */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.category-tabs__sticky {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 0 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg2);
|
||||
z-index: 2;
|
||||
}
|
||||
.category-tabs__scroll-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
.category-tabs__scroll {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
.category-tabs__scroll::-webkit-scrollbar { display: none; }
|
||||
.cat-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 2px solid transparent;
|
||||
background: var(--bg3);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.12s;
|
||||
}
|
||||
.cat-tab--active { background: var(--accent); color: var(--accent-fg); }
|
||||
.cat-tab--viewall {
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Category All Modal ──────────────────────────────────── */
|
||||
.cat-all-modal {
|
||||
position: fixed;
|
||||
inset: 20px;
|
||||
background: var(--bg2);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cat-all-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-all-modal__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.cat-all-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.cat-all-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.cat-all-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
.cat-all-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76px;
|
||||
max-height: 76px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.cat-all-tile--active { outline: 3px solid #fff; }
|
||||
.cat-all-tile__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cat-all-tile__name {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Product Grid ────────────────────────────────────────── */
|
||||
.product-picker { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.product-area { flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
|
||||
|
||||
/* Sub-category accordion */
|
||||
.subcat-accordion { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; }
|
||||
.subcat-section { border-radius: 12px; overflow: hidden; background: var(--bg2); border: 1px solid var(--border); }
|
||||
.subcat-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.subcat-header:active { background: var(--bg3); }
|
||||
.subcat-header--open { background: var(--bg3); }
|
||||
.subcat-header__pill {
|
||||
width: 4px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.subcat-header__name { flex: 1; font-size: 14px; font-weight: 600; }
|
||||
.subcat-header__count {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
background: var(--bg3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.subcat-header--open .subcat-header__count { background: var(--bg); }
|
||||
.subcat-header__chevron { flex-shrink: 0; color: var(--muted); transition: transform 200ms ease; }
|
||||
.subcat-body { padding: 0 0 6px; }
|
||||
.subcat-body .product-grid { padding: 8px 10px; overflow-y: unset; }
|
||||
.subcat-general .product-grid { padding: 8px 10px; }
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.product-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.product-btn:active { background: var(--bg3); }
|
||||
|
||||
.product-btn__thumb {
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.product-btn__thumb-inner {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.product-btn__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.product-btn__initials {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.product-btn__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.product-btn__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
/* always occupy exactly 2 lines */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: calc(1.35em * 2);
|
||||
}
|
||||
.product-btn__price { font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
/* ── Cart Panel ──────────────────────────────────────────── */
|
||||
.cart-panel {
|
||||
background: var(--bg2);
|
||||
border-top: 2px solid var(--border);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cart-panel__title { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.cart-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Order Summary ───────────────────────────────────────── */
|
||||
.order-summary { display: flex; flex-direction: column; gap: 0; overflow-y: auto; flex: 1; padding: 12px; }
|
||||
.order-item { padding: 12px 10px; border-bottom: 1px solid var(--border); }
|
||||
.order-item--last { border-bottom: none; }
|
||||
.order-item--paid { opacity: 0.5; }
|
||||
.order-item--cancelled { opacity: 0.3; text-decoration: line-through; }
|
||||
.order-item--selected { background: rgba(245,158,11,0.10); border-radius: 8px; }
|
||||
.order-item__row { display: flex; align-items: center; gap: 8px; }
|
||||
.order-item__name { flex: 1; font-size: 17px; font-weight: 600; }
|
||||
.order-item__qty { font-size: 15px; color: var(--muted); }
|
||||
.order-item__price { font-size: 16px; color: var(--text); font-weight: 600; }
|
||||
.order-item__modifier { font-size: 13px; color: var(--muted); padding-left: 16px; margin-top: 3px; }
|
||||
.order-summary__total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
border-top: 2px solid var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px; margin-left: 4px; }
|
||||
.badge--paid { background: var(--success); color: var(--success-fg); }
|
||||
.badge--cancelled{ background: var(--danger-dim); color: var(--danger); }
|
||||
.badge--draft { background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
/* ── Detail Body ─────────────────────────────────────────── */
|
||||
.detail-body { display: flex; flex-direction: column; flex: 1; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.action-bar .btn { flex: 1; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-overlay--top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.modal-sheet {
|
||||
background: var(--bg2);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 16px 20px 32px;
|
||||
width: 100%;
|
||||
max-height: 85svh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-sheet--top {
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 12px 20px 24px;
|
||||
}
|
||||
.modal-handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--bg3);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
.modal-sheet--top .modal-handle {
|
||||
margin: 8px auto 0;
|
||||
order: 99;
|
||||
}
|
||||
.modal-title { font-size: 20px; font-weight: 700; text-align: center; }
|
||||
.modal-price { font-size: 18px; color: var(--accent); text-align: center; font-weight: 600; }
|
||||
.modal-section h3 { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.modal-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-option input { width: 20px; height: 20px; accent-color: var(--accent); }
|
||||
.option-price { margin-left: auto; color: var(--accent); font-size: 13px; }
|
||||
.modal-option--remove { color: var(--muted); }
|
||||
.modal-notes {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
resize: none;
|
||||
}
|
||||
.modal-qty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qty-value { font-size: 24px; font-weight: 700; min-width: 36px; text-align: center; }
|
||||
|
||||
/* ── User Menu Dropdown ──────────────────────────────────── */
|
||||
.user-menu-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.user-menu-item:hover { background: var(--bg3); }
|
||||
.user-menu-item--disabled { color: var(--muted); cursor: not-allowed; }
|
||||
.user-menu-item--disabled:hover { background: transparent; }
|
||||
.user-menu-item--danger { color: var(--danger); }
|
||||
.user-menu-item__icon { font-size: 17px; flex-shrink: 0; }
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
10
waiter_pwa/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
843
waiter_pwa/src/pages/AddItemsPage.jsx
Normal file
@@ -0,0 +1,843 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import ProductPicker from '../components/ProductPicker'
|
||||
import OrderDrawer from '../components/OrderDrawer'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function AddItemsPage() {
|
||||
const { tableId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const isNewTable = searchParams.get('new') === '1'
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [categories, setCategories] = useState([])
|
||||
const [products, setProducts] = useState([])
|
||||
const [cart, setCart] = useState([])
|
||||
const [orderId, setOrderId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [printAck, setPrintAck] = useState(null)
|
||||
const [cartOpen, setCartOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [catRes, prodRes, statusRes] = await Promise.all([
|
||||
client.get('/api/products/categories'),
|
||||
client.get('/api/products/'),
|
||||
client.get(`/api/tables/${tableId}/status`),
|
||||
])
|
||||
setCategories(catRes.data)
|
||||
setProducts(prodRes.data)
|
||||
setOrderId(statusRes.data.active_order_id)
|
||||
|
||||
// Pre-populate cart from "order again" if present
|
||||
const stored = sessionStorage.getItem('orderAgainItems')
|
||||
if (stored) {
|
||||
sessionStorage.removeItem('orderAgainItems')
|
||||
try {
|
||||
const items = JSON.parse(stored)
|
||||
const initialCart = items.map(it => ({
|
||||
...it,
|
||||
_key: Date.now() + Math.random(),
|
||||
}))
|
||||
setCart(initialCart)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [tableId])
|
||||
|
||||
// Back button: if this was a new table and nothing was added, leave the table FREE
|
||||
function handleBack() {
|
||||
if (isNewTable && cart.length === 0) {
|
||||
navigate('/tables', { replace: true })
|
||||
} else {
|
||||
navigate(`/tables/${tableId}`)
|
||||
}
|
||||
}
|
||||
|
||||
function addToCart(item) {
|
||||
setCart(prev => {
|
||||
// Try to find an identical item already in the cart to stack onto.
|
||||
// Two items are identical when every meaningful field matches exactly.
|
||||
const { _key: _k, _drawerState: _ds, ...newCore } = item
|
||||
const matchIdx = prev.findIndex(existing => {
|
||||
const { _key, _drawerState, ...existCore } = existing
|
||||
return JSON.stringify(existCore) === JSON.stringify(newCore)
|
||||
})
|
||||
if (matchIdx !== -1) {
|
||||
const next = [...prev]
|
||||
next[matchIdx] = { ...next[matchIdx], quantity: next[matchIdx].quantity + (item.quantity ?? 1) }
|
||||
return next
|
||||
}
|
||||
return [...prev, { ...item, _key: Date.now() + Math.random() }]
|
||||
})
|
||||
}
|
||||
|
||||
function removeFromCart(key) {
|
||||
setCart(prev => prev.filter(i => i._key !== key))
|
||||
}
|
||||
|
||||
function changeCartQty(key, newQty) {
|
||||
if (newQty <= 0) {
|
||||
removeFromCart(key)
|
||||
} else {
|
||||
setCart(prev => prev.map(i => i._key === key ? { ...i, quantity: newQty } : i))
|
||||
}
|
||||
}
|
||||
|
||||
function openEditDrawer(cartItem) {
|
||||
const product = products.find(p => p.id === cartItem.product_id)
|
||||
if (!product) return
|
||||
setCartOpen(false)
|
||||
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
|
||||
}
|
||||
|
||||
function handleEditSave(updatedItem) {
|
||||
setCart(prev => prev.map(i =>
|
||||
i._key === editItem.cartKey ? { ...updatedItem, _key: i._key } : i
|
||||
))
|
||||
setEditItem(null)
|
||||
}
|
||||
|
||||
async function sendOrder() {
|
||||
if (cart.length === 0) return
|
||||
setSending(true)
|
||||
setError('')
|
||||
setPrintAck(null)
|
||||
setCartOpen(false)
|
||||
try {
|
||||
// For new (free) tables, open the order now — lazily
|
||||
let activeOrderId = orderId
|
||||
if (!activeOrderId) {
|
||||
const { data: newOrder } = await client.post('/api/orders/', { table_id: Number(tableId) })
|
||||
activeOrderId = newOrder.id
|
||||
setOrderId(activeOrderId)
|
||||
}
|
||||
|
||||
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
|
||||
items: cart.map(({ _key, _drawerState, ...item }) => item),
|
||||
})
|
||||
const printResults = res.data.print_results ?? []
|
||||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||||
setPrintAck({ allOk, results: printResults })
|
||||
if (allOk) setTimeout(() => navigate('/tables'), 1200)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function retryNow() {
|
||||
if (!orderId) return
|
||||
setRetrying(true)
|
||||
try {
|
||||
const res = await client.post(`/api/orders/${orderId}/retry-print`)
|
||||
const printResults = res.data.print_results ?? []
|
||||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||||
setPrintAck({ allOk, results: printResults })
|
||||
if (allOk) setTimeout(() => navigate('/tables'), 1200)
|
||||
} catch { } finally { setRetrying(false) }
|
||||
}
|
||||
|
||||
function saveAsDraft() { navigate(`/tables/${tableId}`, { replace: true }) }
|
||||
function leaveAndContinue() { navigate(`/tables/${tableId}`, { replace: true }) }
|
||||
|
||||
function getProduct(id) { return products.find(p => p.id === id) }
|
||||
|
||||
// Returns structured sections for the expanded cart view
|
||||
function buildItemSections(item, product) {
|
||||
const sections = []
|
||||
|
||||
if (item.selected_options?.length) {
|
||||
const prefIds = new Set(
|
||||
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
|
||||
)
|
||||
// Build a map: prefChoiceId → preference set name
|
||||
const prefSetByChoiceId = {}
|
||||
;(product?.preference_sets || []).forEach(ps => {
|
||||
ps.choices.forEach(c => { prefSetByChoiceId[c.id] = ps.name })
|
||||
})
|
||||
const quickNames = new Set((product?.quick_options || []).map(o => o.name))
|
||||
const extraIds = new Set((product?.options || []).map(o => o.id))
|
||||
|
||||
// Group prefs: { prefSetName, choiceName, subName }
|
||||
const prefGroups = []
|
||||
// Group extras: { name, subName, qty } — one entry per unique (id)
|
||||
const extraGroups = []
|
||||
const quickLines = []
|
||||
|
||||
let i = 0
|
||||
const opts = item.selected_options
|
||||
while (i < opts.length) {
|
||||
const o = opts[i]
|
||||
if (prefIds.has(o.id)) {
|
||||
// Collect sub immediately following (id === null)
|
||||
const setName = prefSetByChoiceId[o.id] ?? ''
|
||||
let subName = null
|
||||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||
subName = opts[i + 1].name
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
// Merge into existing prefGroup for same setName, or create new
|
||||
const existing = prefGroups.find(g => g.setName === setName)
|
||||
if (existing) {
|
||||
// multiple choices from same set (shouldn't normally happen, but handle gracefully)
|
||||
existing.values.push(subName ? `${o.name} · ${subName}` : o.name)
|
||||
} else {
|
||||
prefGroups.push({ setName, values: [subName ? `${o.name} · ${subName}` : o.name] })
|
||||
}
|
||||
} else if (o.id != null && extraIds.has(o.id)) {
|
||||
// Collect sub immediately following
|
||||
let subName = null
|
||||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||
subName = opts[i + 1].name
|
||||
i += 2
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
// Merge duplicates
|
||||
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||||
if (existing) existing.qty++
|
||||
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||||
} else if (quickNames.has(o.name)) {
|
||||
quickLines.push(o)
|
||||
i++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate quick lines: multiple entries of same name → single entry with qty
|
||||
const quickDeduped = []
|
||||
quickLines.forEach(o => {
|
||||
const existing = quickDeduped.find(x => x.name === o.name)
|
||||
if (existing) existing._qty = (existing._qty || 1) + 1
|
||||
else quickDeduped.push({ ...o, _qty: 1 })
|
||||
})
|
||||
|
||||
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
|
||||
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||
}
|
||||
|
||||
if (item.removed_ingredients?.length) {
|
||||
sections.push({ type: 'removed', lines: item.removed_ingredients.map(n => ({ name: n })) })
|
||||
}
|
||||
|
||||
if (item.notes) {
|
||||
sections.push({ type: 'note', lines: [{ name: item.notes }] })
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// Simple flat summary for the collapsed one-liner
|
||||
function buildItemSummary(item) {
|
||||
const lines = []
|
||||
if (item.selected_options?.length) {
|
||||
item.selected_options.forEach(o => {
|
||||
if (o.price_delta && o.price_delta !== 0)
|
||||
lines.push(`${o.name} (${o.price_delta > 0 ? '+' : ''}${o.price_delta.toFixed(2)} €)`)
|
||||
else lines.push(o.name)
|
||||
})
|
||||
}
|
||||
if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`)
|
||||
if (item.notes) lines.push(item.notes)
|
||||
return lines
|
||||
}
|
||||
|
||||
// Print-failure dialog
|
||||
if (printAck && !printAck.allOk) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`, { replace: true })}>←</button>
|
||||
<span className="top-bar__title">Πρόβλημα εκτύπωσης</span>
|
||||
</header>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, padding: 20, overflowY: 'auto' }}>
|
||||
<div style={{ background: '#7f1d1d', borderRadius: 14, padding: '14px 16px', border: '1px solid #ef4444' }}>
|
||||
<p style={{ fontWeight: 700, fontSize: 15, color: '#fca5a5', marginBottom: 6 }}>⚠ Η παραγγελία αποθηκεύτηκε</p>
|
||||
<p style={{ fontSize: 13, color: '#fca5a5', lineHeight: 1.5 }}>Ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.</p>
|
||||
</div>
|
||||
{printAck.results.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, background: r.success ? '#14532d' : '#431407', border: `1px solid ${r.success ? '#22c55e' : '#c2410c'}`, borderRadius: 12, padding: '10px 14px' }}>
|
||||
<span style={{ fontSize: 20 }}>{r.success ? '✓' : '✗'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontWeight: 600, fontSize: 14, color: r.success ? '#86efac' : '#fdba74' }}>{r.printer_name}</p>
|
||||
{!r.success && <p style={{ fontSize: 12, color: '#fdba74', marginTop: 2 }}>Εκτυπωτής μη προσβάσιμος</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: 12, color: '#64748b', textAlign: 'center', margin: '4px 0' }}>Επιλέξτε πώς να συνεχίσετε:</p>
|
||||
<button className="btn btn--primary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, opacity: retrying ? 0.7 : 1 }} onClick={retryNow} disabled={retrying}>
|
||||
<span style={{ fontSize: 22 }}>🔄</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>{retrying ? 'Επανάληψη…' : 'Επανάληψη τώρα'}</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.8, margin: 0, marginTop: 2 }}>Δοκιμή αποστολής στον εκτυπωτή ξανά</p>
|
||||
</div>
|
||||
</button>
|
||||
<button className="btn btn--secondary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }} onClick={saveAsDraft}>
|
||||
<span style={{ fontSize: 22 }}>📋</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Αποθήκευση ως προσχέδιο</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα μένουν στο τραπέζι με πορτοκαλί ένδειξη</p>
|
||||
</div>
|
||||
</button>
|
||||
<button style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, background: '#1e293b', border: '1px solid #334155', borderRadius: 12, color: '#cbd5e1', cursor: 'pointer' }} onClick={leaveAndContinue}>
|
||||
<span style={{ fontSize: 22 }}>🕐</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Συνέχεια (προσχέδιο)</p>
|
||||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα εμφανίζονται ως εκκρεμή στο dashboard</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact names for the strip preview (max 3 items shown)
|
||||
const stripItems = cart.slice(-3).reverse()
|
||||
const hiddenCount = cart.length > 3 ? cart.length - 3 : 0
|
||||
|
||||
return (
|
||||
<div className="page" style={{ position: 'relative' }}>
|
||||
<header className="top-bar">
|
||||
<button className="icon-btn" onClick={handleBack}>←</button>
|
||||
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{/* Search button */}
|
||||
<button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
|
||||
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Categories button */}
|
||||
<button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Cart button with badge */}
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={() => setCartOpen(true)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
{cart.length > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
minWidth: 18, height: 18, borderRadius: 9,
|
||||
background: 'var(--accent)', color: 'var(--accent-fg)',
|
||||
fontSize: 11, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
}}>{cart.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Product picker takes all remaining space */}
|
||||
{categories.length > 0 && (
|
||||
<ProductPicker
|
||||
categories={categories}
|
||||
products={products}
|
||||
onAdd={addToCart}
|
||||
viewAllOpen={viewAllOpen}
|
||||
setViewAllOpen={setViewAllOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
|
||||
<div style={{
|
||||
background: 'var(--bg2)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '10px 12px 14px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{/* Floating compact cart — shown only when there are items */}
|
||||
{cart.length > 0 && (
|
||||
<div
|
||||
onClick={() => setCartOpen(true)}
|
||||
style={{
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 10,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{stripItems.map(item => {
|
||||
const p = getProduct(item.product_id)
|
||||
return (
|
||||
<div key={item._key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{p?.name ?? `#${item.product_id}`}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
|
||||
+{hiddenCount} ακόμα — δείτε όλα →
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full-width send button */}
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||||
</button>
|
||||
|
||||
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={() => setCartOpen(false)}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
opacity: cartOpen ? 1 : 0,
|
||||
pointerEvents: cartOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 240ms ease',
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0,
|
||||
width: 'min(88vw, 380px)',
|
||||
background: 'var(--bg)',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
transform: cartOpen ? 'translateX(0)' : 'translateX(100%)',
|
||||
transition: 'transform 280ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
zIndex: 51,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
boxShadow: '-8px 0 32px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Παραγγελία</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'}</div>
|
||||
</div>
|
||||
<button onClick={() => setCartOpen(false)} style={{ background: 'var(--bg3)', border: 'none', borderRadius: '50%', width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text)' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{cart.length === 0 ? (
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '40px 0', fontSize: 14 }}>Η παραγγελία είναι κενή.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{cart.map(item => {
|
||||
const product = getProduct(item.product_id)
|
||||
const summaryLines = buildItemSummary(item)
|
||||
const sections = buildItemSections(item, product)
|
||||
return (
|
||||
<CartItem
|
||||
key={item._key}
|
||||
item={item}
|
||||
product={product}
|
||||
summaryLines={summaryLines}
|
||||
sections={sections}
|
||||
onEdit={() => openEditDrawer(item)}
|
||||
onRemove={() => removeFromCart(item._key)}
|
||||
onChangeQty={qty => changeCartQty(item._key, qty)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer footer */}
|
||||
<div style={{ padding: '12px 12px 20px', borderTop: '1px solid var(--border)', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn--primary btn--lg"
|
||||
style={{ width: '100%' }}
|
||||
onClick={sendOrder}
|
||||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||||
>
|
||||
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Edit drawer */}
|
||||
{editItem && (
|
||||
<OrderDrawer
|
||||
product={editItem.product}
|
||||
isOpen={!!editItem}
|
||||
onClose={() => setEditItem(null)}
|
||||
onAdd={handleEditSave}
|
||||
initialState={editItem.drawerState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Search modal ─────────────────────────────────────────────────────── */}
|
||||
{searchOpen && (
|
||||
<SearchModal
|
||||
products={products}
|
||||
query={searchQuery}
|
||||
setQuery={setSearchQuery}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onAdd={item => { addToCart(item); setSearchOpen(false) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full-screen success overlay — blocks all interaction while navigating */}
|
||||
{printAck?.allOk && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.72)',
|
||||
animation: 'fadeInOverlay 180ms ease',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#14532d', border: '2px solid #22c55e',
|
||||
borderRadius: 20, padding: '36px 48px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||||
animation: 'popIn 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||||
}}>
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="11" stroke="#22c55e" strokeWidth="2"/>
|
||||
<path d="M7 12.5l3.5 3.5 6.5-7" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ color: '#86efac', fontWeight: 700, fontSize: 18, letterSpacing: 0.3 }}>
|
||||
Εκτυπώθηκε Επιτυχώς
|
||||
</span>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes fadeInOverlay { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes popIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cart Item (used in the side drawer) ───────────────────────────────────────
|
||||
|
||||
const SECTION_META = {
|
||||
prefs: { icon: '◉', label: null },
|
||||
quick: { icon: '>', label: null },
|
||||
extras: { icon: '+', label: null },
|
||||
removed: { icon: '−', label: null },
|
||||
note: { icon: 'i', label: null },
|
||||
}
|
||||
|
||||
function SectionIcon({ type }) {
|
||||
const icons = {
|
||||
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
|
||||
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||||
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
|
||||
}
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||||
}
|
||||
|
||||
function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onChangeQty }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const hasDetails = sections.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{/* Whole header row is always clickable to expand (qty stepper is always available) */}
|
||||
<div
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Chevron — always shown */}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms', flexShrink: 0, color: 'var(--muted)' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product?.name ?? `#${item.product_id}`}
|
||||
</div>
|
||||
{!expanded && hasDetails && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{summaryLines[0]}{summaryLines.length > 1 ? ` +${summaryLines.length - 1}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity on the right */}
|
||||
<span style={{ color: '#f59e0b', fontSize: 13, fontWeight: 700, fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||||
|
||||
{/* Edit — stop propagation so it doesn't toggle expand */}
|
||||
<button onClick={e => { e.stopPropagation(); onEdit() }} style={{ background: 'none', border: '1px solid var(--border)', borderRadius: 7, color: 'var(--muted)', cursor: 'pointer', padding: '3px 9px', fontSize: 12, fontWeight: 500, flexShrink: 0 }}>
|
||||
Επεξ.
|
||||
</button>
|
||||
{/* Remove */}
|
||||
<button onClick={e => { e.stopPropagation(); onRemove() }} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ paddingBottom: 10 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="prefs" />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||||
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||||
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="quick" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="extras" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||||
{line.name}
|
||||
{line.subName && <span> · {line.subName}</span>}
|
||||
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<SectionIcon type="removed" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type="note" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Quick qty row ── */}
|
||||
<div style={{ margin: '8px 12px 0', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
|
||||
padding: '10px 12px 2px',
|
||||
}}>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity - 1) }}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
||||
color: item.quantity <= 1 ? 'var(--muted)' : 'var(--danger)',
|
||||
fontSize: 20, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.quantity <= 1 ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
) : '−'}
|
||||
</button>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', minWidth: 28, textAlign: 'center' }}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity + 1) }}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
||||
color: '#22c55e',
|
||||
fontSize: 20, fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search Modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function SearchModal({ products, query, setQuery, onClose, onAdd }) {
|
||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
|
||||
|
||||
const results = query.trim().length === 0
|
||||
? []
|
||||
: activeProducts.filter(p =>
|
||||
p.name.toLowerCase().includes(query.trim().toLowerCase())
|
||||
)
|
||||
|
||||
function openProduct(p) {
|
||||
// Blur the input first so the keyboard dismisses, then open the drawer
|
||||
document.activeElement?.blur()
|
||||
setDrawerProduct(p)
|
||||
}
|
||||
|
||||
// The modal is position:fixed anchored to bottom:0.
|
||||
// When the soft keyboard opens on mobile the browser shrinks the visual
|
||||
// viewport and fixed elements reposition automatically — the panel sits
|
||||
// right on top of the keyboard without any JS measurement needed.
|
||||
return (
|
||||
<>
|
||||
{/* Dim backdrop — tap to close */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }} />
|
||||
|
||||
{/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't
|
||||
push the input off screen on short viewports */}
|
||||
<div style={{
|
||||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||
zIndex: 201,
|
||||
background: 'var(--bg)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
maxHeight: '60vh',
|
||||
}}>
|
||||
{/* Results scroll area — flex:1 so it takes space above the input */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{query.trim().length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
|
||||
Πληκτρολογήστε για αναζήτηση…
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
|
||||
Δεν βρέθηκαν προϊόντα για «{query}»
|
||||
</p>
|
||||
) : results.map(p => {
|
||||
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => openProduct(p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
width: '100%', padding: '10px 16px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
background: 'var(--bg3)', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{p.image_url
|
||||
? <img src={p.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{Number(p.base_price).toFixed(2)} €
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search input — pinned at the bottom of the panel, above the keyboard */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
|
||||
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Αναζήτηση προϊόντος…"
|
||||
style={{
|
||||
flex: 1, height: 44, background: 'var(--bg2)',
|
||||
border: '1px solid var(--border)', borderRadius: 12,
|
||||
padding: '0 12px', fontSize: 16, color: 'var(--text)',
|
||||
fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'var(--bg3)', border: 'none', borderRadius: '50%',
|
||||
width: 36, height: 36, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product drawer — closes search modal when item is added */}
|
||||
{drawerProduct && (
|
||||
<OrderDrawer
|
||||
product={drawerProduct}
|
||||
isOpen
|
||||
onClose={() => setDrawerProduct(null)}
|
||||
onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
229
waiter_pwa/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PinPad from '../components/PinPad'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
|
||||
|
||||
// ─── Waiter card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function WaiterCard({ waiter, onClick }) {
|
||||
const initials = (waiter.full_name || waiter.nickname || '?')
|
||||
.split(' ')
|
||||
.map(w => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 16px',
|
||||
background: 'var(--bg2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
position: 'relative',
|
||||
transition: 'border-color 0.15s',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: '50%', flexShrink: 0,
|
||||
background: waiter.avatar_url ? 'transparent' : 'var(--bg3)',
|
||||
border: `2px solid ${waiter.on_shift ? '#22c55e' : 'var(--border)'}`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{waiter.avatar_url
|
||||
? <img src={waiter.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: initials
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Name block */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)', wordBreak: 'break-word', whiteSpace: 'normal', lineHeight: 1.3 }}>
|
||||
{waiter.full_name || waiter.nickname || '—'}
|
||||
</div>
|
||||
{waiter.nickname && waiter.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{waiter.nickname}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* On-shift dot */}
|
||||
{waiter.on_shift && (
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 6px #22c55e88',
|
||||
}} title="Σε βάρδια" />
|
||||
)}
|
||||
|
||||
<span style={{ color: 'var(--muted)', fontSize: 18, flexShrink: 0 }}>›</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [waiters, setWaiters] = useState([])
|
||||
const [loadingWaiters, setLoadingWaiters] = useState(true)
|
||||
const [serverUnreachable, setServerUnreachable] = useState(false)
|
||||
const [selectedWaiter, setSelectedWaiter] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/auth/waiters')
|
||||
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
|
||||
.catch(err => {
|
||||
// No response = network error = server unreachable
|
||||
if (!err.response) setServerUnreachable(true)
|
||||
setWaiters([])
|
||||
})
|
||||
.finally(() => setLoadingWaiters(false))
|
||||
}, [])
|
||||
|
||||
async function handlePin(pin) {
|
||||
if (!selectedWaiter) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
// We send waiter id as identifier; backend matches by id+pin
|
||||
const { data } = await client.post('/api/auth/login-by-id', { waiter_id: selectedWaiter.id, pin })
|
||||
login({ id: data.user.id, username: data.user.username, role: data.user.role }, data.access_token)
|
||||
navigate('/tables')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Λανθασμένο PIN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Waiter picker screen ───────────────────────────────────────────────────
|
||||
|
||||
if (!selectedWaiter) {
|
||||
// Sort: on-shift first, then alphabetical
|
||||
const sorted = [...waiters].sort((a, b) => {
|
||||
if (a.on_shift !== b.on_shift) return a.on_shift ? -1 : 1
|
||||
return (a.full_name || a.nickname || '').localeCompare(b.full_name || b.nickname || '')
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Static header — never scrolls */}
|
||||
<div style={{ flexShrink: 0, padding: '40px 16px 20px', textAlign: 'center' }}>
|
||||
<h1 className="app-title" style={{ marginBottom: 10 }}>Xenia POS</h1>
|
||||
<p className="app-subtitle">Ποιος είσαι;</p>
|
||||
</div>
|
||||
|
||||
{/* Scrollable card list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 16px 40px' }}>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||
{loadingWaiters ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Φόρτωση…</p>
|
||||
) : serverUnreachable ? (
|
||||
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>🔌</div>
|
||||
<p style={{ fontSize: 17, fontWeight: 700, color: '#ef4444', marginBottom: 8 }}>
|
||||
Δεν βρέθηκε ο Server
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 24 }}>
|
||||
Δεν είναι δυνατή η σύνδεση με τον Manager.<br />
|
||||
Δεν μπορεί να ξεκινήσει βάρδια χωρίς σύνδεση.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn--secondary"
|
||||
onClick={() => {
|
||||
setLoadingWaiters(true)
|
||||
setServerUnreachable(false)
|
||||
client.get('/api/auth/waiters')
|
||||
.then(r => { setWaiters(r.data); setServerUnreachable(false) })
|
||||
.catch(err => { if (!err.response) setServerUnreachable(true) })
|
||||
.finally(() => setLoadingWaiters(false))
|
||||
}}
|
||||
>
|
||||
⟳ Επανάληψη
|
||||
</button>
|
||||
</div>
|
||||
) : waiters.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 32 }}>Δεν βρέθηκαν σερβιτόροι</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'stretch' }}>
|
||||
{sorted.map(w => (
|
||||
<WaiterCard key={w.id} waiter={w} onClick={() => { setError(''); setSelectedWaiter(w) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── PIN screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
const initials = (selectedWaiter.full_name || selectedWaiter.nickname || '?')
|
||||
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="page page--centered">
|
||||
<div className="login-box">
|
||||
<h1 className="app-title">XeniaPOS</h1>
|
||||
|
||||
{/* Selected waiter mini-card */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
background: 'var(--bg2)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: '10px 14px', marginBottom: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||
background: selectedWaiter.avatar_url ? 'transparent' : 'var(--bg3)',
|
||||
border: `2px solid ${selectedWaiter.on_shift ? '#22c55e' : 'var(--border)'}`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 15, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{selectedWaiter.avatar_url
|
||||
? <img src={selectedWaiter.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: initials
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>
|
||||
{selectedWaiter.full_name || selectedWaiter.nickname}
|
||||
</div>
|
||||
{selectedWaiter.nickname && selectedWaiter.full_name && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>{selectedWaiter.nickname}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelectedWaiter(null); setError('') }}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--muted)', cursor: 'pointer', fontSize: 13, padding: '4px 8px' }}
|
||||
>
|
||||
Αλλαγή
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, marginBottom: 12 }}>Εισάγετε PIN</p>
|
||||
|
||||
<PinPad onSubmit={handlePin} loading={loading} />
|
||||
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
waiter_pwa/src/pages/OfflinePage.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import client from '../api/client'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function OfflinePage() {
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
async function retry() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await client.get('/api/system/health')
|
||||
window.location.href = '/'
|
||||
} catch {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page page--centered">
|
||||
<div style={{ textAlign: 'center', maxWidth: 300 }}>
|
||||
<p style={{ fontSize: 48, marginBottom: 16 }}>📡</p>
|
||||
<h2 style={{ color: '#e2e8f0', marginBottom: 8 }}>Δεν υπάρχει σύνδεση</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: 32 }}>
|
||||
Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi.
|
||||
</p>
|
||||
<button className="btn btn--primary btn--lg" onClick={retry} disabled={checking}>
|
||||
{checking ? 'Έλεγχος…' : 'Επανάληψη'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
345
waiter_pwa/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
import useThemeStore from '../store/themeStore'
|
||||
|
||||
// ─── Tab definitions (stub future tabs here) ──────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ key: 'layout', label: 'Εμφάνιση' },
|
||||
{ key: 'favorites', label: 'Αγαπημένα', disabled: true },
|
||||
]
|
||||
|
||||
// ─── Density option data ──────────────────────────────────────────────────────
|
||||
|
||||
const DENSITY_OPTIONS = [
|
||||
{
|
||||
key: '1x1',
|
||||
label: '1×1',
|
||||
desc: '4 ανά σειρά — μόνο όνομα',
|
||||
preview: <Grid4 />,
|
||||
},
|
||||
{
|
||||
key: '2x1',
|
||||
label: '2×1',
|
||||
desc: '2 ανά σειρά — όνομα + κατάσταση',
|
||||
preview: <Grid2H />,
|
||||
},
|
||||
{
|
||||
key: '2x2',
|
||||
label: '2×2',
|
||||
desc: '2 ανά σειρά — συμπαγής κάρτα',
|
||||
preview: <Grid2 />,
|
||||
},
|
||||
{
|
||||
key: '4x1',
|
||||
label: '4×1',
|
||||
desc: '1 ανά σειρά — οριζόντια λίστα',
|
||||
preview: <Grid1H />,
|
||||
},
|
||||
{
|
||||
key: '4x2',
|
||||
label: '4×2',
|
||||
desc: '1 ανά σειρά — πλήρης κάρτα',
|
||||
preview: <Grid1 />,
|
||||
},
|
||||
{
|
||||
key: '4x3',
|
||||
label: '4×3',
|
||||
desc: '1 ανά σειρά — κάρτα με λίστα παραγγελίας',
|
||||
preview: <Grid1Detail />,
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Mini grid preview SVGs ───────────────────────────────────────────────────
|
||||
|
||||
function Grid4() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
{[0,1,2,3].map(i => (
|
||||
<rect key={i} x={2 + i * 13} y="4" width="11" height="13" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
))}
|
||||
{[0,1,2,3].map(i => (
|
||||
<rect key={i+4} x={2 + i * 13} y="20" width="11" height="13" rx="2" fill="currentColor" opacity="0.55"/>
|
||||
))}
|
||||
{[0,1,2,3].map(i => (
|
||||
<rect key={i+8} x={2 + i * 13} y="36" width="11" height="13" rx="2" fill="currentColor" opacity="0.25"/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Grid2H() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
{[0,1].map(i => (
|
||||
<rect key={i} x={2 + i * 27} y="4" width="25" height="11" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
))}
|
||||
{[0,1].map(i => (
|
||||
<rect key={i+2} x={2 + i * 27} y="19" width="25" height="11" rx="2" fill="currentColor" opacity="0.55"/>
|
||||
))}
|
||||
{[0,1].map(i => (
|
||||
<rect key={i+4} x={2 + i * 27} y="34" width="25" height="11" rx="2" fill="currentColor" opacity="0.25"/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Grid2() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
<rect x="2" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="30" y="4" width="24" height="18" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="2" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
|
||||
<rect x="30" y="26" width="24" height="18" rx="2" fill="currentColor" opacity="0.45"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Grid1H() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
<rect x="2" y="4" width="52" height="11" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="2" y="19" width="52" height="11" rx="2" fill="currentColor" opacity="0.55"/>
|
||||
<rect x="2" y="34" width="52" height="11" rx="2" fill="currentColor" opacity="0.25"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Grid1() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
<rect x="2" y="4" width="52" height="18" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="2" y="27" width="52" height="18" rx="2" fill="currentColor" opacity="0.45"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Grid1Detail() {
|
||||
return (
|
||||
<svg viewBox="0 0 56 48" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: '100%', height: '100%' }}>
|
||||
<rect x="2" y="4" width="52" height="20" rx="2" fill="currentColor" opacity="0.9"/>
|
||||
{/* left section lines */}
|
||||
<rect x="5" y="8" width="14" height="3" rx="1" fill="white" opacity="0.6"/>
|
||||
<rect x="5" y="13" width="9" height="2" rx="1" fill="white" opacity="0.4"/>
|
||||
<rect x="5" y="18" width="11" height="2" rx="1" fill="white" opacity="0.4"/>
|
||||
{/* vertical divider */}
|
||||
<rect x="22" y="7" width="1" height="14" rx="0.5" fill="white" opacity="0.3"/>
|
||||
{/* right section lines */}
|
||||
<rect x="25" y="8" width="24" height="2" rx="1" fill="white" opacity="0.5"/>
|
||||
<rect x="25" y="12" width="20" height="2" rx="1" fill="white" opacity="0.35"/>
|
||||
<rect x="25" y="16" width="22" height="2" rx="1" fill="white" opacity="0.25"/>
|
||||
<rect x="2" y="29" width="52" height="15" rx="2" fill="currentColor" opacity="0.45"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Layout tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function LayoutTab() {
|
||||
const { density, setDensity } = useTableViewStore()
|
||||
const { dark, toggle } = useThemeStore()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, padding: '24px 16px' }}>
|
||||
|
||||
{/* Card density */}
|
||||
<section>
|
||||
<h2 style={sectionTitle}>Κάρτες τραπεζιών</h2>
|
||||
<p style={sectionSub}>Επίλεξε πόσα στοιχεία εμφανίζονται σε κάθε κάρτα.</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
|
||||
{DENSITY_OPTIONS.map(opt => {
|
||||
const active = density === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setDensity(opt.key)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'border-color 0.12s, background 0.12s',
|
||||
}}
|
||||
>
|
||||
{/* Mini preview */}
|
||||
<div style={{
|
||||
width: 56, height: 48, flexShrink: 0,
|
||||
color: active ? 'var(--accent)' : 'var(--muted)',
|
||||
transition: 'color 0.12s',
|
||||
}}>
|
||||
{opt.preview}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 700,
|
||||
color: active ? 'var(--accent)' : 'var(--text)',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{opt.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.4 }}>
|
||||
{opt.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check */}
|
||||
{active && (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ flexShrink: 0, color: 'var(--accent)' }}>
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5"/>
|
||||
<path d="M6.5 10l2.5 2.5 4.5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Theme */}
|
||||
<section>
|
||||
<h2 style={sectionTitle}>Θέμα</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
|
||||
{[
|
||||
{ key: false, icon: '☀️', label: 'Φωτεινό' },
|
||||
{ key: true, icon: '🌙', label: 'Σκοτεινό' },
|
||||
].map(opt => {
|
||||
const active = dark === opt.key
|
||||
return (
|
||||
<button
|
||||
key={String(opt.key)}
|
||||
onClick={() => { if (!active) toggle() }}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||
padding: '18px 12px',
|
||||
borderRadius: 14,
|
||||
border: `2px solid ${active ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: active ? 'var(--accent)' + '18' : 'var(--bg2)',
|
||||
cursor: active ? 'default' : 'pointer',
|
||||
transition: 'border-color 0.12s, background 0.12s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 28 }}>{opt.icon}</span>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 600,
|
||||
color: active ? 'var(--accent)' : 'var(--muted)',
|
||||
}}>{opt.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTitle = {
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--muted)',
|
||||
letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4,
|
||||
}
|
||||
const sectionSub = {
|
||||
fontSize: 14, color: 'var(--muted)', lineHeight: 1.5,
|
||||
}
|
||||
|
||||
// ─── Favorites stub tab ───────────────────────────────────────────────────────
|
||||
|
||||
function FavoritesTab() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 40, flex: 1 }}>
|
||||
<span style={{ fontSize: 40 }}>⭐</span>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Σύντομα διαθέσιμο</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
Τα αγαπημένα προϊόντα θα εμφανίζονται εδώ για γρήγορη παραγγελία.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState('layout')
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Top bar */}
|
||||
<header className="top-bar">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text)', fontSize: 15, fontWeight: 600,
|
||||
padding: '0 4px', minHeight: 44, borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15l-5-5 5-5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
Πίσω
|
||||
</button>
|
||||
<span className="top-bar__title" style={{ textAlign: 'center' }}>Ρυθμίσεις</span>
|
||||
{/* spacer to balance the back button */}
|
||||
<div style={{ width: 72 }} />
|
||||
</header>
|
||||
|
||||
{/* Tab strip */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 0,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg2)',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
disabled={tab.disabled}
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: 'none', border: 'none',
|
||||
borderBottom: activeTab === tab.key ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: tab.disabled
|
||||
? 'var(--muted)'
|
||||
: activeTab === tab.key
|
||||
? 'var(--accent)'
|
||||
: 'var(--text)',
|
||||
fontSize: 14, fontWeight: 600,
|
||||
cursor: tab.disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: tab.disabled ? 0.45 : 1,
|
||||
marginBottom: -1, // overlap the border-bottom
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 0.12s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.disabled && (
|
||||
<span style={{
|
||||
marginLeft: 6, fontSize: 10, fontWeight: 700,
|
||||
background: 'var(--bg3)', color: 'var(--muted)',
|
||||
borderRadius: 4, padding: '1px 5px',
|
||||
verticalAlign: 'middle',
|
||||
}}>σύντομα</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab body */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}>
|
||||
{activeTab === 'layout' && <LayoutTab />}
|
||||
{activeTab === 'favorites' && <FavoritesTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1221
waiter_pwa/src/pages/TableDetailPage.jsx
Normal file
783
waiter_pwa/src/pages/TableListPage.jsx
Normal file
@@ -0,0 +1,783 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import TableCard from '../components/TableCard'
|
||||
import ConnectionBanner from '../components/ConnectionBanner'
|
||||
import EmergencyBar from '../components/EmergencyBar'
|
||||
import UserMenu from '../components/UserMenu'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import useTableColourStore from '../store/tableColourStore'
|
||||
import useConnectionStore from '../store/connectionStore'
|
||||
import useTableViewStore from '../store/tableViewStore'
|
||||
import client from '../api/client'
|
||||
import db from '../db/posdb'
|
||||
import { queueOfflinePayment } from '../services/offlinePayments'
|
||||
import { useNotifications } from '../context/NotificationContext'
|
||||
import { FlagsIcon, TransferIcon, MergeIcon, PrintIcon, WaiterIcon } from '../components/Icons'
|
||||
|
||||
function fmtPrice(v) { return Number(v || 0).toFixed(2) + ' €' }
|
||||
|
||||
// ─── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterIcon({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notification drawer ──────────────────────────────────────────────────────
|
||||
|
||||
function NotificationDrawer({ messages, onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ maxHeight: '80svh' }}>
|
||||
<div className="modal-handle" />
|
||||
<h2 className="modal-title" style={{ marginBottom: 16 }}>Ειδοποιήσεις</h2>
|
||||
{messages.length === 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: 24 }}>Δεν υπάρχουν ειδοποιήσεις</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto', flex: 1 }}>
|
||||
{messages.map(msg => {
|
||||
const tableIds = (() => { try { return JSON.parse(msg.table_ids || '[]') } catch { return [] } })()
|
||||
return (
|
||||
<div key={msg.id} style={{
|
||||
padding: '12px 4px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||||
opacity: msg._acked ? 0.5 : 1,
|
||||
}}>
|
||||
<span style={{ fontSize: 20, flexShrink: 0 }}>📢</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{msg.sender_name && (
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#a5b4fc', marginBottom: 2 }}>{msg.sender_name}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>{msg.body}</div>
|
||||
{tableIds.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>Τραπέζι: {tableIds.join(', ')}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{new Date(msg.created_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Table quick-view modal (long press) ──────────────────────────────────────
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ Icon: FlagsIcon, label: 'Ενδείξεις Τραπεζιού', key: 'flags', color: '#fac823', iconBg: 'rgba(251,191,36,0.15)' },
|
||||
{ Icon: TransferIcon, label: 'Μεταφορά', key: 'transfer', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
|
||||
{ Icon: MergeIcon, label: 'Συγχώνευση', key: 'merge', color: '#6099db', iconBg: 'rgba(96,165,250,0.15)' },
|
||||
{ Icon: PrintIcon, label: 'Εκτύπωση Σύνοψης', key: 'print_synopsis', color: '#cbd5e1', iconBg: 'rgba(148,163,184,0.15)' },
|
||||
{ Icon: WaiterIcon, label: 'Ανάθεση Σερβιτόρου', key: 'assign_waiter', color: '#39b861', iconBg: 'rgba(34,197,94,0.15)' },
|
||||
]
|
||||
|
||||
function TableQuickModal({ table, order, flags, onClose, onNavigate, onAction }) {
|
||||
const tableName = table.label || `T${table.number}`
|
||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const paid = order?.payments?.reduce((s, p) => s + p.amount, 0) || 0
|
||||
const due = Math.max(0, total - paid)
|
||||
|
||||
const statusLabel = {
|
||||
open: 'Ανοιχτό', partially_paid: 'Μερικώς πληρωμένο', paid: 'Πληρωμένο',
|
||||
}[order?.status] || 'Ελεύθερο'
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div style={{ width: '100%', maxWidth: 480, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ background: 'var(--bg2)', borderRadius: '16px 16px 0 0', padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="modal-handle" style={{ marginBottom: 12 }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>{tableName}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--muted)' }}>{statusLabel}</span>
|
||||
</div>
|
||||
{order ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Σύνολο</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtPrice(total)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Πληρωμένο</span>
|
||||
<span style={{ fontWeight: 600, color: '#22c55e' }}>{fmtPrice(paid)}</span>
|
||||
</div>
|
||||
{due > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Υπόλοιπο</span>
|
||||
<span style={{ fontWeight: 700, color: '#f59e0b' }}>{fmtPrice(due)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 12 }}>Δεν υπάρχει ενεργή παραγγελία</p>
|
||||
)}
|
||||
{flags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{flags.map(f => (
|
||||
<div key={f.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: (f.color || '#6295F3') + '22',
|
||||
border: `1px solid ${f.color || '#6295F3'}`,
|
||||
borderRadius: 20, padding: '4px 10px',
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>{f.emoji || '🏷️'}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: f.color || '#6295F3' }}>{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn--primary" style={{ width: '100%', marginTop: 14 }} onClick={() => { onClose(); onNavigate() }}>
|
||||
Άνοιγμα τραπεζιού
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg2)', borderRadius: '0 0 16px 16px', padding: '8px 20px 24px', borderTop: '2px solid var(--border)' }}>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 1, marginBottom: 8, marginTop: 8 }}>ACTIONS</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{QUICK_ACTIONS.map((a, i) => {
|
||||
const disabled = !order && a.key !== 'flags'
|
||||
return (
|
||||
<button key={a.key} disabled={disabled} onClick={() => { onClose(); onAction(a.key) }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 0', background: 'none', border: 'none',
|
||||
borderBottom: i < QUICK_ACTIONS.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.35 : 1, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: a.iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', color: a.color }}>
|
||||
<a.Icon width="18" height="18" />
|
||||
</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: a.color }}>{a.label}</span>
|
||||
{!disabled && <span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Emergency payment modal ──────────────────────────────────────────────────
|
||||
|
||||
function EmergencyPayModal({ table, order, onClose, onPay }) {
|
||||
const [paying, setPaying] = useState(false)
|
||||
const activeItems = order?.items?.filter(i => i.status === 'active') || []
|
||||
const total = activeItems.reduce((s, i) => s + (i.unit_price || 0) * (i.quantity || 1), 0)
|
||||
|
||||
async function handlePay() {
|
||||
setPaying(true)
|
||||
await onPay(order.id, activeItems.map(i => i.id), 'cash')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div style={{ width: '100%', maxWidth: 400, margin: '0 auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-sheet">
|
||||
<div className="modal-handle" />
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🚨</div>
|
||||
<p style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>ΕΚΤΑΚΤΗ ΠΛΗΡΩΜΗ</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginTop: 4 }}>Τραπέζι: <strong>{table.label || `T${table.number}`}</strong></p>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg3)', borderRadius: 12, padding: '12px 16px', marginBottom: 20 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 8 }}>Ενεργά αντικείμενα:</p>
|
||||
{activeItems.length === 0
|
||||
? <p style={{ fontSize: 13, color: 'var(--muted)', fontStyle: 'italic' }}>Δεν υπάρχουν δεδομένα (offline snapshot)</p>
|
||||
: activeItems.map(item => (
|
||||
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, marginBottom: 4 }}>
|
||||
<span style={{ color: 'var(--text)' }}>{item.product?.name || `#${item.product_id}`} ×{item.quantity}</span>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{((item.unit_price || 0) * (item.quantity || 1)).toFixed(2)} €</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div style={{ borderTop: '1px solid var(--border)', marginTop: 10, paddingTop: 10, display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 16 }}>
|
||||
<span>Σύνολο</span>
|
||||
<span style={{ color: '#ef4444' }}>{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
{total === 0
|
||||
? <p style={{ fontSize: 13, color: '#ef4444', marginBottom: 16, lineHeight: 1.5, fontWeight: 600 }}>
|
||||
Δεν είναι δυνατή η πληρωμή χωρίς offline δεδομένα. Άνοιξε το τραπέζι ενώ ο server ήταν online.
|
||||
</p>
|
||||
: <p style={{ fontSize: 12, color: '#f59e0b', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
⚠️ Μόνο μετρητά σε κατάσταση έκτακτης ανάγκης. Η πληρωμή συγχρονίζεται μόλις αποκατασταθεί η σύνδεση.
|
||||
</p>
|
||||
}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button className="btn btn--secondary" style={{ flex: 1 }} onClick={onClose}>Ακύρωση</button>
|
||||
<button
|
||||
style={{ flex: 1, height: 44, borderRadius: 12, border: 'none', background: total === 0 ? '#64748b' : '#dc2626', color: '#fff', fontSize: 15, fontWeight: 700, cursor: (paying || total === 0) ? 'not-allowed' : 'pointer', opacity: (paying || total === 0) ? 0.5 : 1 }}
|
||||
onClick={handlePay} disabled={paying || total === 0}
|
||||
>
|
||||
{paying ? '⟳ Καταχώρηση…' : '✓ Πληρωμή'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Filters modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function FiltersModal({ groups, onClose }) {
|
||||
const {
|
||||
ownerFilter, statusFilter, zoneFilter,
|
||||
setOwnerFilter, setStatusFilter, setZoneFilter,
|
||||
clearFilters, setActiveZoneTab,
|
||||
} = useTableViewStore()
|
||||
|
||||
function toggleZone(id) {
|
||||
const next = zoneFilter.includes(id)
|
||||
? zoneFilter.filter(z => z !== id)
|
||||
: [...zoneFilter, id]
|
||||
setZoneFilter(next)
|
||||
// if we remove a zone that is the active tab, reset to 'all'
|
||||
if (!next.length) setActiveZoneTab('all')
|
||||
}
|
||||
|
||||
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} style={{ alignItems: 'flex-end' }}>
|
||||
<div
|
||||
className="modal-sheet"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ borderRadius: '20px 20px 0 0', paddingBottom: 40, gap: 20 }}
|
||||
>
|
||||
<div className="modal-handle" />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)' }}>Φίλτρα</span>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => { clearFilters(); onClose() }}
|
||||
style={{ fontSize: 13, fontWeight: 600, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px' }}
|
||||
>
|
||||
Καθαρισμός
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Owner: ALL | MINE */}
|
||||
<div>
|
||||
<p style={sectionLabel}>Ανάθεση</p>
|
||||
<div style={segmentedWrap}>
|
||||
{[['all', 'Όλα'], ['mine', 'Δικά μου']].map(([key, lbl]) => (
|
||||
<button key={key} onClick={() => setOwnerFilter(key)} style={segBtn(ownerFilter === key)}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status: ALL | FREE | OPEN | PAID */}
|
||||
<div>
|
||||
<p style={sectionLabel}>Κατάσταση</p>
|
||||
<div style={{ ...segmentedWrap, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
|
||||
{[['all', 'Όλα'], ['free', 'Ελεύθερα'], ['open', 'Ανοιχτά'], ['paid', 'Πληρωμένα']].map(([key, lbl]) => (
|
||||
<button key={key} onClick={() => setStatusFilter(key)} style={{ ...segBtn(statusFilter === key), borderRadius: 10 }}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zones: multi-select, one segmented container per zone */}
|
||||
{groups.length > 0 && (
|
||||
<div>
|
||||
<p style={sectionLabel}>Ζώνες {zoneFilter.length > 0 ? `(${zoneFilter.length} επιλεγμένες)` : ''}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||||
{groups.map(g => {
|
||||
const active = zoneFilter.includes(g.id)
|
||||
return (
|
||||
<div key={g.id} style={segmentedWrap}>
|
||||
<button
|
||||
onClick={() => toggleZone(g.id)}
|
||||
style={{
|
||||
...segBtn(active),
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
|
||||
}}
|
||||
>
|
||||
{g.color && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: active ? 'currentColor' : g.color,
|
||||
flexShrink: 0, opacity: active ? 0.9 : 1,
|
||||
}} />
|
||||
)}
|
||||
{g.name}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn--secondary" style={{ width: '100%' }} onClick={onClose}>Εντάξει</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionLabel = { fontSize: 11, fontWeight: 700, color: 'var(--muted)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 8 }
|
||||
const segmentedWrap = { display: 'flex', gap: 6, background: 'var(--bg3)', borderRadius: 12, padding: 4 }
|
||||
function segBtn(active) {
|
||||
return {
|
||||
flex: 1, padding: '9px 8px', borderRadius: 9, border: 'none',
|
||||
cursor: 'pointer', fontWeight: 600, fontSize: 14,
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TableListPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { status: connStatus } = useConnectionStore()
|
||||
const isEmergency = connStatus === 'emergency'
|
||||
|
||||
const [tables, setTables] = useState([])
|
||||
const [groups, setGroups] = useState([])
|
||||
const [orders, setOrders] = useState([])
|
||||
const [flagDefs, setFlagDefs] = useState([])
|
||||
const [flagAssignments, setFlagAssignments] = useState([])
|
||||
const [waiters, setWaiters] = useState([]) // waiter objects for avatar lookup
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [quickModal, setQuickModal] = useState(null)
|
||||
const [emergencyPayModal, setEmergencyPayModal] = useState(null)
|
||||
const [localPaidOrderIds, setLocalPaidOrderIds] = useState(new Set())
|
||||
|
||||
// pull-to-refresh state
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullY, setPullY] = useState(0)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const pullStart = useRef(null)
|
||||
const scrollRef = useRef(null)
|
||||
const PULL_THRESHOLD = 72
|
||||
|
||||
const navigate = useNavigate()
|
||||
const filterBtnRef = useRef(null)
|
||||
|
||||
const { unreadCount, recentMessages, fetchRecent } = useNotifications() || {}
|
||||
const loadFromBackend = useTableColourStore(s => s.loadFromBackend)
|
||||
|
||||
const {
|
||||
density, ownerFilter, statusFilter, zoneFilter, activeZoneTab, setActiveZoneTab,
|
||||
} = useTableViewStore()
|
||||
|
||||
// ── Load from IndexedDB when offline ──────────────────────────────────────
|
||||
const loadFromDB = useCallback(async () => {
|
||||
const [dbTables, dbOrders] = await Promise.all([db.tables.toArray(), db.orders.toArray()])
|
||||
setTables(dbTables.filter(t => t.is_active !== false))
|
||||
setOrders(dbOrders)
|
||||
setOffline(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (isEmergency) loadFromDB() }, [isEmergency])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setOffline(true)
|
||||
window.addEventListener('backend-offline', handler)
|
||||
return () => window.removeEventListener('backend-offline', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => load()
|
||||
window.addEventListener('sse-reconnected', handler)
|
||||
return () => window.removeEventListener('sse-reconnected', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (connStatus === 'online') setOffline(false) }, [connStatus])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [tablesRes, ordersRes, groupsRes, flagDefsRes, flagAssignRes, settingsRes, waitersRes] = await Promise.all([
|
||||
client.get('/api/tables/'),
|
||||
client.get('/api/orders/active'),
|
||||
client.get('/api/tables/groups'),
|
||||
client.get('/api/flags/defs'),
|
||||
client.get('/api/flags/assignments'),
|
||||
client.get('/api/settings/'),
|
||||
client.get('/api/waiters/on-shift'),
|
||||
])
|
||||
setTables(tablesRes.data)
|
||||
const fullOrders = await Promise.all(
|
||||
ordersRes.data.map(o =>
|
||||
client.get(`/api/orders/${o.id}`)
|
||||
.then(r => ({ ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? o.waiter_ids ?? [] }))
|
||||
.catch(() => o)
|
||||
)
|
||||
)
|
||||
setOrders(fullOrders)
|
||||
setGroups(groupsRes.data)
|
||||
setFlagDefs(flagDefsRes.data)
|
||||
setFlagAssignments(flagAssignRes.data)
|
||||
setWaiters(waitersRes.data)
|
||||
const raw = settingsRes.data?.['ui.table_colours']?.value
|
||||
if (raw) loadFromBackend(raw)
|
||||
setOffline(false)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
// ── SSE live updates ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (isEmergency) return
|
||||
function onSSE(e) {
|
||||
const { type, data } = e.detail
|
||||
if (type === 'order_updated' || type === 'order_paid') {
|
||||
client.get(`/api/orders/${data.order_id}`)
|
||||
.then(r => {
|
||||
const full = { ...r.data, waiter_ids: r.data.waiters?.map(w => w.waiter_id) ?? [] }
|
||||
setOrders(prev => {
|
||||
const exists = prev.find(o => o.id === data.order_id)
|
||||
return exists ? prev.map(o => o.id === data.order_id ? full : o) : [...prev, full]
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
setOrders(prev => {
|
||||
const existing = prev.find(o => o.id === data.order_id)
|
||||
if (existing) return prev.map(o => o.id === data.order_id ? { ...o, status: data.status, table_id: data.table_id } : o)
|
||||
return [...prev, { id: data.order_id, table_id: data.table_id, status: data.status, waiter_ids: [] }]
|
||||
})
|
||||
})
|
||||
} else if (type === 'order_closed') {
|
||||
setOrders(prev => prev.filter(o => o.id !== data.order_id))
|
||||
} else if (type === 'table_flags_changed') {
|
||||
client.get('/api/flags/assignments').then(r => setFlagAssignments(r.data)).catch(() => {})
|
||||
} else if (type === 'table_list_changed') {
|
||||
client.get('/api/tables/').then(r => setTables(r.data)).catch(() => {})
|
||||
}
|
||||
}
|
||||
window.addEventListener('sse-event', onSSE)
|
||||
return () => window.removeEventListener('sse-event', onSSE)
|
||||
}, [isEmergency])
|
||||
|
||||
// ── Emergency payment ──────────────────────────────────────────────────────
|
||||
async function handleEmergencyPay(orderId, itemIds, paymentMethod) {
|
||||
await queueOfflinePayment({ orderId, itemIds, paymentMethod })
|
||||
setLocalPaidOrderIds(prev => new Set([...prev, orderId]))
|
||||
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'paid' } : o))
|
||||
await db.orders.where('id').equals(orderId).modify({ status: 'paid' })
|
||||
}
|
||||
|
||||
// ── Derived maps ───────────────────────────────────────────────────────────
|
||||
const flagDefMap = Object.fromEntries(flagDefs.map(f => [f.id, f]))
|
||||
const tableFlagsMap = {}
|
||||
flagAssignments.forEach(a => {
|
||||
if (!tableFlagsMap[a.table_id]) tableFlagsMap[a.table_id] = []
|
||||
const def = flagDefMap[a.flag_id]
|
||||
if (def) tableFlagsMap[a.table_id].push(def)
|
||||
})
|
||||
const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w]))
|
||||
|
||||
function getOrder(tableId) { return orders.find(o => o.table_id === tableId) }
|
||||
function isMyOrder(order) { return !!(order && user && order.waiter_ids?.includes(user.id)) }
|
||||
function getOrderWaiters(order) {
|
||||
if (!order) return []
|
||||
return (order.waiter_ids || []).map(id => waiterMap[id]).filter(Boolean)
|
||||
}
|
||||
|
||||
// ── Filtering logic ────────────────────────────────────────────────────────
|
||||
// Zones visible in top bar = those allowed by zoneFilter (or all if empty)
|
||||
const allowedZoneIds = zoneFilter.length > 0 ? new Set(zoneFilter) : null
|
||||
|
||||
// visibleGroups = groups shown in the top bar
|
||||
const visibleGroups = groups.filter(g => !allowedZoneIds || allowedZoneIds.has(g.id))
|
||||
|
||||
// Validate activeZoneTab against current allowedZoneIds
|
||||
// If the active tab is no longer visible, reset to 'all'
|
||||
const effectiveZoneTab = (
|
||||
activeZoneTab === 'all' ||
|
||||
visibleGroups.some(g => g.id === activeZoneTab)
|
||||
) ? activeZoneTab : 'all'
|
||||
|
||||
const filtered = tables.filter(t => {
|
||||
const order = getOrder(t.id)
|
||||
|
||||
// Status filter
|
||||
if (statusFilter === 'free' && order) return false
|
||||
if (statusFilter === 'open' && (!order || order.status === 'paid' || order.status === 'partially_paid')) return false
|
||||
if (statusFilter === 'paid' && order?.status !== 'paid' && order?.status !== 'partially_paid') return false
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter === 'mine' && !isMyOrder(order)) return false
|
||||
|
||||
// Zone filter from modal (multi-select restricts which zones are allowed)
|
||||
if (allowedZoneIds && !allowedZoneIds.has(t.group_id ?? 'none')) return false
|
||||
|
||||
// Active zone tab (secondary, single-select within allowed)
|
||||
if (effectiveZoneTab !== 'all' && t.group_id !== effectiveZoneTab) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// ── Pull-to-refresh handlers ───────────────────────────────────────────────
|
||||
function onPullTouchStart(e) {
|
||||
if (scrollRef.current?.scrollTop > 0) return
|
||||
pullStart.current = e.touches[0].clientY
|
||||
}
|
||||
function onPullTouchMove(e) {
|
||||
if (pullStart.current === null) return
|
||||
const dy = e.touches[0].clientY - pullStart.current
|
||||
if (dy > 0 && scrollRef.current?.scrollTop <= 0) {
|
||||
e.preventDefault()
|
||||
setPulling(true)
|
||||
setPullY(Math.min(dy, PULL_THRESHOLD * 1.5))
|
||||
}
|
||||
}
|
||||
async function onPullTouchEnd() {
|
||||
if (!pulling) return
|
||||
if (pullY >= PULL_THRESHOLD) {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}
|
||||
setPulling(false)
|
||||
setPullY(0)
|
||||
pullStart.current = null
|
||||
}
|
||||
|
||||
// ── Grid columns per density ───────────────────────────────────────────────
|
||||
const gridCols = {
|
||||
'1x1': 'repeat(4, 1fr)',
|
||||
'2x1': 'repeat(2, 1fr)',
|
||||
'2x2': 'repeat(2, 1fr)',
|
||||
'4x1': '1fr',
|
||||
'4x2': '1fr',
|
||||
'4x3': '1fr',
|
||||
}[density] || 'repeat(2, 1fr)'
|
||||
|
||||
const hasActiveFilters = ownerFilter !== 'all' || statusFilter !== 'all' || zoneFilter.length > 0
|
||||
|
||||
function handleQuickAction(tableId, actionKey) {
|
||||
navigate(`/tables/${tableId}?action=${actionKey}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="top-bar">
|
||||
<span className="top-bar__title">Τραπέζια</span>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowNotifs(true); fetchRecent?.() }}
|
||||
style={{
|
||||
position: 'relative', background: 'none', border: 'none',
|
||||
color: 'var(--text)', fontSize: 22, cursor: 'pointer',
|
||||
minWidth: 44, minHeight: 44, borderRadius: 8,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M19.3399 14.49L18.3399 12.83C18.1299 12.46 17.9399 11.76 17.9399 11.35V8.82C17.9399 6.47 16.5599 4.44 14.5699 3.49C14.0499 2.57 13.0899 2 11.9899 2C10.8999 2 9.91994 2.59 9.39994 3.52C7.44994 4.49 6.09994 6.5 6.09994 8.82V11.35C6.09994 11.76 5.90994 12.46 5.69994 12.82L4.68994 14.49C4.28994 15.16 4.19994 15.9 4.44994 16.58C4.68994 17.25 5.25994 17.77 5.99994 18.02C7.93994 18.68 9.97994 19 12.0199 19C14.0599 19 16.0999 18.68 18.0399 18.03C18.7399 17.8 19.2799 17.27 19.5399 16.58C19.7999 15.89 19.7299 15.13 19.3399 14.49Z" fill="currentColor"/>
|
||||
<path d="M14.8297 20.01C14.4097 21.17 13.2997 22 11.9997 22C11.2097 22 10.4297 21.68 9.87969 21.11C9.55969 20.81 9.31969 20.41 9.17969 20C9.30969 20.02 9.43969 20.03 9.57969 20.05C9.80969 20.08 10.0497 20.11 10.2897 20.13C10.8597 20.18 11.4397 20.21 12.0197 20.21C12.5897 20.21 13.1597 20.18 13.7197 20.13C13.9297 20.11 14.1397 20.1 14.3397 20.07C14.4997 20.05 14.6597 20.03 14.8297 20.01Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{(unreadCount || 0) > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
{isEmergency ? <EmergencyBar /> : (offline && <ConnectionBanner />)}
|
||||
|
||||
{/* ── Zone tab bar ─────────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
background: 'var(--bg)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
overflowX: 'auto', scrollbarWidth: 'none',
|
||||
}}>
|
||||
{/* ALL tab */}
|
||||
<ZoneTab
|
||||
label="Όλα"
|
||||
active={effectiveZoneTab === 'all'}
|
||||
onClick={() => setActiveZoneTab('all')}
|
||||
/>
|
||||
|
||||
{/* Per-zone tabs */}
|
||||
{visibleGroups.map(g => (
|
||||
<ZoneTab
|
||||
key={g.id}
|
||||
label={g.name}
|
||||
color={g.color}
|
||||
active={effectiveZoneTab === g.id}
|
||||
onClick={() => setActiveZoneTab(effectiveZoneTab === g.id ? 'all' : g.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Table grid ───────────────────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ flex: 1, overflowY: 'auto', minHeight: 0, overscrollBehavior: 'contain' }}
|
||||
onTouchStart={onPullTouchStart}
|
||||
onTouchMove={onPullTouchMove}
|
||||
onTouchEnd={onPullTouchEnd}
|
||||
>
|
||||
{/* Pull-to-refresh indicator */}
|
||||
{(pulling || refreshing) && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: Math.min(pullY, PULL_THRESHOLD),
|
||||
color: 'var(--muted)', fontSize: 13, fontWeight: 600,
|
||||
overflow: 'hidden', transition: pulling ? 'none' : 'height 0.2s',
|
||||
}}>
|
||||
{refreshing ? '⟳ Ανανέωση…' : pullY >= PULL_THRESHOLD ? '↑ Αφήστε για ανανέωση' : '↓ Τραβήξτε για ανανέωση'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: density === '1x1' ? 8 : 10,
|
||||
padding: '12px 12px 88px',
|
||||
alignContent: 'start',
|
||||
}}>
|
||||
{filtered.map(t => {
|
||||
const order = getOrder(t.id)
|
||||
const tableFlags = tableFlagsMap[t.id] || []
|
||||
const grp = groups.find(g => g.id === t.group_id)
|
||||
const alreadyPaidLocally = order && localPaidOrderIds.has(order.id)
|
||||
const orderWaiters = getOrderWaiters(order)
|
||||
|
||||
function handleClick() {
|
||||
if (isEmergency) {
|
||||
if (order && !alreadyPaidLocally && order.status !== 'paid' && order.status !== 'closed') {
|
||||
setEmergencyPayModal({ table: t, order })
|
||||
}
|
||||
return
|
||||
}
|
||||
const destination = order ? `/tables/${t.id}` : `/tables/${t.id}/add?new=1`
|
||||
navigate(destination)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCard
|
||||
key={t.id}
|
||||
table={t}
|
||||
order={alreadyPaidLocally ? { ...order, status: 'paid' } : order}
|
||||
isMine={isMyOrder(order)}
|
||||
flags={tableFlags}
|
||||
groupName={grp?.name || ''}
|
||||
waiterObjects={orderWaiters}
|
||||
density={density}
|
||||
onClick={handleClick}
|
||||
onLongPress={isEmergency ? undefined : () => setQuickModal({ table: t, order, flags: tableFlags })}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter FAB ───────────────────────────────────────────────────────── */}
|
||||
<button
|
||||
ref={filterBtnRef}
|
||||
onClick={() => setShowFilters(true)}
|
||||
style={{
|
||||
position: 'fixed', bottom: 24, right: 24,
|
||||
width: 52, height: 52, borderRadius: '50%', border: 'none',
|
||||
background: hasActiveFilters ? '#ea6c00' : '#f97316',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.2)',
|
||||
zIndex: 40,
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
>
|
||||
<FilterIcon size={20} />
|
||||
{hasActiveFilters && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
background: '#ef4444', color: '#fff',
|
||||
fontSize: 9, fontWeight: 800,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{(ownerFilter !== 'all' ? 1 : 0) + (statusFilter !== 'all' ? 1 : 0) + (zoneFilter.length > 0 ? 1 : 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ── Modals ────────────────────────────────────────────────────────────── */}
|
||||
{showNotifs && (
|
||||
<NotificationDrawer messages={recentMessages || []} onClose={() => setShowNotifs(false)} />
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<FiltersModal groups={groups} onClose={() => setShowFilters(false)} anchorRef={filterBtnRef} />
|
||||
)}
|
||||
|
||||
{quickModal && (
|
||||
<TableQuickModal
|
||||
table={quickModal.table}
|
||||
order={quickModal.order}
|
||||
flags={quickModal.flags}
|
||||
onClose={() => setQuickModal(null)}
|
||||
onNavigate={() => navigate(`/tables/${quickModal.table.id}`)}
|
||||
onAction={(key) => handleQuickAction(quickModal.table.id, key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{emergencyPayModal && (
|
||||
<EmergencyPayModal
|
||||
table={emergencyPayModal.table}
|
||||
order={emergencyPayModal.order}
|
||||
onClose={() => setEmergencyPayModal(null)}
|
||||
onPay={handleEmergencyPay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Zone tab pill ────────────────────────────────────────────────────────────
|
||||
|
||||
function ZoneTab({ label, color, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '7px 12px', borderRadius: 20, border: 'none',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
fontWeight: 600, fontSize: 13,
|
||||
background: active ? 'var(--accent)' : 'var(--bg3)',
|
||||
color: active ? 'var(--accent-fg)' : 'var(--muted)',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
}}
|
||||
>
|
||||
{color && (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
opacity: active ? 1 : 0.7,
|
||||
}} />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
61
waiter_pwa/src/services/offlinePayments.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import db from '../db/posdb'
|
||||
import client from '../api/client'
|
||||
|
||||
/**
|
||||
* Queue an emergency payment locally.
|
||||
* Called in Emergency Mode when the server is unreachable.
|
||||
*/
|
||||
export async function queueOfflinePayment({ orderId, itemIds, paymentMethod }) {
|
||||
const uuid = crypto.randomUUID()
|
||||
await db.offline_payments.add({
|
||||
uuid,
|
||||
orderId,
|
||||
itemIds,
|
||||
paymentMethod,
|
||||
offlineAt: new Date().toISOString(),
|
||||
synced: 0,
|
||||
isDuplicate: 0,
|
||||
})
|
||||
return uuid
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all unsynced offline payments to the server.
|
||||
* Called when the server comes back online.
|
||||
* Returns a summary of { synced, duplicates, failed }.
|
||||
*/
|
||||
export async function flushOfflinePayments() {
|
||||
// Boolean is not a valid IndexedDB key — load all and filter in JS
|
||||
const all = await db.offline_payments.toArray()
|
||||
const pending = all.filter(p => !p.synced)
|
||||
const results = { synced: 0, duplicates: 0, failed: 0 }
|
||||
|
||||
for (const payment of pending) {
|
||||
try {
|
||||
const res = await client.post(`/api/orders/${payment.orderId}/pay-offline`, {
|
||||
uuid: payment.uuid,
|
||||
item_ids: payment.itemIds,
|
||||
payment_method: payment.paymentMethod,
|
||||
offline_at: payment.offlineAt,
|
||||
})
|
||||
const isDuplicate = res.data.is_duplicate
|
||||
await db.offline_payments.update(payment.localId, {
|
||||
synced: 1,
|
||||
isDuplicate: isDuplicate ? 1 : 0,
|
||||
})
|
||||
isDuplicate ? results.duplicates++ : results.synced++
|
||||
} catch {
|
||||
results.failed++
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unsynced pending payments (to show badge / warning).
|
||||
*/
|
||||
export async function pendingPaymentCount() {
|
||||
const all = await db.offline_payments.toArray()
|
||||
return all.filter(p => !p.synced).length
|
||||
}
|
||||
28
waiter_pwa/src/store/authStore.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const savedUsername = localStorage.getItem('savedUsername')
|
||||
|
||||
const useAuthStore = create((set) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('token') || null,
|
||||
savedUsername: savedUsername || null,
|
||||
|
||||
login(user, token) {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('savedUsername', user.username)
|
||||
set({ user, token, savedUsername: user.username })
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('savedUsername')
|
||||
set({ user: null, token: null, savedUsername: null })
|
||||
},
|
||||
|
||||
clearSavedUsername() {
|
||||
localStorage.removeItem('savedUsername')
|
||||
set({ savedUsername: null })
|
||||
},
|
||||
}))
|
||||
|
||||
export default useAuthStore
|
||||
33
waiter_pwa/src/store/connectionStore.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Tracks the live connection state and emergency mode flag.
|
||||
*
|
||||
* States:
|
||||
* 'online' — server reachable, SSE connected, normal operation
|
||||
* 'lost' — server unreachable, modal shown (Wait / Emergency)
|
||||
* 'emergency' — user chose emergency mode, working from IndexedDB snapshot
|
||||
*/
|
||||
const useConnectionStore = create((set, get) => ({
|
||||
status: 'online', // 'online' | 'lost' | 'emergency'
|
||||
lostAt: null, // Date when connection was lost
|
||||
|
||||
setLost: () => {
|
||||
if (get().status === 'online') {
|
||||
set({ status: 'lost', lostAt: new Date() })
|
||||
}
|
||||
},
|
||||
|
||||
setOnline: () => set({ status: 'online', lostAt: null }),
|
||||
|
||||
enterEmergency: () => set({ status: 'emergency' }),
|
||||
|
||||
// Called when server comes back while in emergency mode — triggers sync then go online
|
||||
exitEmergency: () => set({ status: 'online', lostAt: null }),
|
||||
|
||||
isOnline: () => get().status === 'online',
|
||||
isLost: () => get().status === 'lost',
|
||||
isEmergency: () => get().status === 'emergency',
|
||||
}))
|
||||
|
||||
export default useConnectionStore
|
||||
21
waiter_pwa/src/store/shiftStore.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const useShiftStore = create((set) => ({
|
||||
shift: null,
|
||||
businessDay: null,
|
||||
selfStartAllowed: true,
|
||||
selfEndAllowed: true,
|
||||
gateStatus: 'loading', // 'loading' | 'closed' | 'needs_start' | 'waiting_manager' | 'ready'
|
||||
|
||||
setShift: (shift) => set({ shift }),
|
||||
setBusinessDay: (day) => set({ businessDay: day }),
|
||||
setSelfStartAllowed: (v) => set({ selfStartAllowed: v }),
|
||||
setSelfEndAllowed: (v) => set({ selfEndAllowed: v }),
|
||||
setGateStatus: (s) => set({ gateStatus: s }),
|
||||
// Called when waiter ends their shift — sends them back to the start screen
|
||||
clearShift: () => set({ shift: null, gateStatus: 'needs_start' }),
|
||||
// Called on logout
|
||||
clear: () => set({ shift: null, businessDay: null, gateStatus: 'loading' }),
|
||||
}))
|
||||
|
||||
export default useShiftStore
|
||||
90
waiter_pwa/src/store/tableColourStore.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export const DEFAULT_COLOURS = {
|
||||
light: {
|
||||
free: {
|
||||
cardBg: '#dde5ef',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#3d5270',
|
||||
badgeText: '#3d5270',
|
||||
},
|
||||
mine: {
|
||||
cardBg: '#e8610a',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#e8610a',
|
||||
},
|
||||
open: {
|
||||
cardBg: '#FF8F60',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#FF8F60',
|
||||
},
|
||||
partially_paid: {
|
||||
cardBg: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#d4a800',
|
||||
},
|
||||
paid: {
|
||||
cardBg: '#81D264',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
free: {
|
||||
cardBg: '#243044',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#94b8d4',
|
||||
badgeText: '#94b8d4',
|
||||
},
|
||||
mine: {
|
||||
cardBg: '#e8610a',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#e8610a',
|
||||
},
|
||||
open: {
|
||||
cardBg: '#FF8F60',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#FF8F60',
|
||||
},
|
||||
partially_paid: {
|
||||
cardBg: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#d4a800',
|
||||
},
|
||||
paid: {
|
||||
cardBg: '#81D264',
|
||||
badgeBg: 'rgba(255,255,255,0.92)',
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const useTableColourStore = create((set) => ({
|
||||
colours: DEFAULT_COLOURS,
|
||||
loadFromBackend: (raw) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.light && parsed?.dark) {
|
||||
// Deep-merge so any status keys added after the settings were saved
|
||||
// (e.g. 'paid') still fall back to their defaults.
|
||||
const merged = { light: {}, dark: {} }
|
||||
for (const mode of ['light', 'dark']) {
|
||||
for (const status of Object.keys(DEFAULT_COLOURS[mode])) {
|
||||
merged[mode][status] = { ...DEFAULT_COLOURS[mode][status], ...(parsed[mode][status] || {}) }
|
||||
}
|
||||
}
|
||||
set({ colours: merged })
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
}))
|
||||
|
||||
export default useTableColourStore
|
||||
39
waiter_pwa/src/store/tableViewStore.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
// density: '1x1' | '2x1' | '2x2' | '4x1' | '4x2' | '4x3'
|
||||
// ownerFilter: 'all' | 'mine'
|
||||
// statusFilter: 'all' | 'free' | 'open' | 'paid'
|
||||
// zoneFilter: Set of zone IDs (serialized as array in localStorage)
|
||||
// activeZoneTab: zone id string or 'all'
|
||||
|
||||
const useTableViewStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
density: '2x2',
|
||||
ownerFilter: 'all',
|
||||
statusFilter: 'all',
|
||||
zoneFilter: [], // array of zone ids (serialized fine in JSON)
|
||||
activeZoneTab: 'all',
|
||||
|
||||
setDensity: (density) => set({ density }),
|
||||
setOwnerFilter: (ownerFilter) => set({ ownerFilter }),
|
||||
setStatusFilter: (statusFilter) => set({ statusFilter }),
|
||||
setZoneFilter: (zoneFilter) => set({ zoneFilter }),
|
||||
setActiveZoneTab: (activeZoneTab) => set({ activeZoneTab }),
|
||||
|
||||
clearFilters: () => set({
|
||||
ownerFilter: 'all',
|
||||
statusFilter: 'all',
|
||||
zoneFilter: [],
|
||||
activeZoneTab: 'all',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'table-view-prefs',
|
||||
// future: could sync to backend here
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
export default useTableViewStore
|
||||
12
waiter_pwa/src/store/themeStore.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
const useThemeStore = create(persist(
|
||||
(set) => ({
|
||||
dark: true,
|
||||
toggle: () => set(s => ({ dark: !s.dark })),
|
||||
}),
|
||||
{ name: 'pos-theme' }
|
||||
))
|
||||
|
||||
export default useThemeStore
|
||||
38
waiter_pwa/vite.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['all'],
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'TableServe',
|
||||
short_name: 'TableServe',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0f172a',
|
||||
theme_color: '#0f172a',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
runtimeCaching: [],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||