feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>POS Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,9 @@ server {
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600;
|
||||
|
||||
BIN
manager_dashboard/public/fonts/GoogleSans-Variable-Italic.ttf
Normal file
BIN
manager_dashboard/public/fonts/GoogleSans-Variable-Italic.ttf
Normal file
Binary file not shown.
BIN
manager_dashboard/public/fonts/GoogleSans-Variable.ttf
Normal file
BIN
manager_dashboard/public/fonts/GoogleSans-Variable.ttf
Normal file
Binary file not shown.
@@ -2,24 +2,57 @@ import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||
import useAuthStore from './store/authStore'
|
||||
import AppLayout from './layouts/AppLayout'
|
||||
import client from './api/client'
|
||||
|
||||
// Auth / setup
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SetupWizard from './pages/SetupWizard'
|
||||
|
||||
// Standalone (no shell)
|
||||
import KdsPage from './pages/KdsPage'
|
||||
|
||||
// Operation
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TablesPage from './pages/TablesPage'
|
||||
import OrderDetailPage from './pages/OrderDetailPage'
|
||||
import ManagementPage from './pages/ManagementPage'
|
||||
import ReportsPage from './pages/reports/ReportsPage'
|
||||
import SettingsPage from './pages/Settings/SettingsPage'
|
||||
import OnlineOrdersPage from './pages/OnlineOrdersPage'
|
||||
|
||||
// CRM
|
||||
import ContactsPage from './pages/crm/ContactsPage'
|
||||
import CustomersPage from './pages/crm/CustomersPage'
|
||||
|
||||
// Financials
|
||||
import ExpensesPage from './pages/financials/ExpensesPage'
|
||||
import TabsPage from './pages/financials/TabsPage'
|
||||
|
||||
// Management
|
||||
import ProductsPage from './pages/Management/ProductsPage'
|
||||
import TablesConfigPage from './pages/Management/TablesConfigPage'
|
||||
import StaffPage from './pages/Management/StaffPage'
|
||||
import ManagementSchedulePage from './pages/Management/SchedulePage'
|
||||
import PrepZonesConfigPage from './pages/Management/PrepZonesConfigPage'
|
||||
import PricingPage from './pages/Management/PricingPage'
|
||||
|
||||
// Inventory
|
||||
import ThrowawaysPage from './pages/inventory/ThrowawaysPage'
|
||||
import StockPage from './pages/inventory/StockPage'
|
||||
|
||||
// Reports
|
||||
import TodayPage from './pages/reports/TodayPage'
|
||||
import StorePage from './pages/reports/StorePage'
|
||||
import StaffReportPage from './pages/reports/StaffReportPage'
|
||||
import ProductsReportPage from './pages/reports/ProductsReportPage'
|
||||
import PrepZonesPage from './pages/reports/PrepZonesPage'
|
||||
import ExpensesReportPage from './pages/reports/ExpensesReportPage'
|
||||
import TechnicalPage from './pages/reports/TechnicalPage'
|
||||
import MiscPage from './pages/reports/MiscPage'
|
||||
|
||||
// Other
|
||||
import AppsPage from './pages/AppsPage'
|
||||
import PhonePage from './pages/PhonePage'
|
||||
import SettingsPage from './pages/Settings/SettingsPage'
|
||||
import NotesPage from './pages/NotesPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ExpensesPage from './pages/ExpensesPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import TabsPage from './pages/TabsPage'
|
||||
import WastePage from './pages/WastePage'
|
||||
import KdsPage from './pages/KdsPage'
|
||||
import SchedulePage from './pages/SchedulePage'
|
||||
import client from './api/client'
|
||||
import PermissionGuard from './components/PermissionGuard'
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
@@ -29,8 +62,6 @@ function Spinner() {
|
||||
)
|
||||
}
|
||||
|
||||
// Rehydrates user from stored token before rendering any routes.
|
||||
// Prevents the flicker where a valid token causes a redirect to /login on refresh.
|
||||
function AuthRehydrator({ children }) {
|
||||
const { token, user, rehydrate, logout } = useAuthStore()
|
||||
const [ready, setReady] = useState(false)
|
||||
@@ -55,7 +86,6 @@ function RequireAuth({ children }) {
|
||||
return token ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Checks /api/setup/status on mount and redirects to /setup if no managers exist.
|
||||
function SetupGuard({ children }) {
|
||||
const [checked, setChecked] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
@@ -65,9 +95,7 @@ function SetupGuard({ children }) {
|
||||
.then(({ data }) => {
|
||||
if (data.needs_setup) navigate('/setup', { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
// Backend unreachable — proceed, login will surface the error.
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setChecked(true))
|
||||
}, [navigate])
|
||||
|
||||
@@ -82,24 +110,62 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupWizard />} />
|
||||
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
|
||||
<Route path="/kds" element={<RequireAuth><KdsPage /></RequireAuth>} />
|
||||
|
||||
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="operations" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="tables" element={<TablesPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
<Route path="management" element={<ManagementPage />} />
|
||||
<Route path="notes" element={<NotesPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="expenses" element={<ExpensesPage />} />
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="tabs" element={<TabsPage />} />
|
||||
<Route path="waste" element={<WastePage />} />
|
||||
<Route path="kds" element={<KdsPage />} />
|
||||
<Route path="schedule" element={<SchedulePage />} />
|
||||
|
||||
{/* ── Operation ── */}
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="tables" element={<TablesPage />} />
|
||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||
|
||||
{/* ── CRM ── */}
|
||||
<Route path="crm/contacts" element={<ContactsPage />} />
|
||||
<Route path="crm/customers" element={<CustomersPage />} />
|
||||
|
||||
{/* ── Financials ── */}
|
||||
<Route path="financials/expenses" element={<ExpensesPage />} />
|
||||
<Route path="financials/tabs" element={<TabsPage />} />
|
||||
|
||||
{/* ── Management (Headquarters) ── */}
|
||||
<Route path="management/products" element={<PermissionGuard perm="manageMenu"><ProductsPage /></PermissionGuard>} />
|
||||
<Route path="management/tables" element={<PermissionGuard perm="manageTables"><TablesConfigPage /></PermissionGuard>} />
|
||||
<Route path="management/staff" element={<PermissionGuard perm="manageStaff"><StaffPage /></PermissionGuard>} />
|
||||
<Route path="management/schedule" element={<PermissionGuard perm="manageStaff"><ManagementSchedulePage /></PermissionGuard>} />
|
||||
<Route path="management/prep-zones" element={<PermissionGuard perm="manageMenu"><PrepZonesConfigPage /></PermissionGuard>} />
|
||||
<Route path="management/pricing" element={<PermissionGuard perm="manageMenu"><PricingPage /></PermissionGuard>} />
|
||||
|
||||
{/* ── Inventory ── */}
|
||||
<Route path="inventory/throwaways" element={<ThrowawaysPage />} />
|
||||
<Route path="inventory/stock" element={<StockPage />} />
|
||||
|
||||
{/* ── Reports (Overwatch) ── */}
|
||||
<Route path="reports/today" element={<PermissionGuard perm="viewReports"><TodayPage /></PermissionGuard>} />
|
||||
<Route path="reports/store" element={<PermissionGuard perm="viewReports"><StorePage /></PermissionGuard>} />
|
||||
<Route path="reports/staff" element={<PermissionGuard perm="viewReports"><StaffReportPage /></PermissionGuard>} />
|
||||
<Route path="reports/products" element={<PermissionGuard perm="viewReports"><ProductsReportPage /></PermissionGuard>} />
|
||||
<Route path="reports/prep-zones" element={<PermissionGuard perm="viewReports"><PrepZonesPage /></PermissionGuard>} />
|
||||
<Route path="reports/expenses" element={<PermissionGuard perm="viewReports"><ExpensesReportPage /></PermissionGuard>} />
|
||||
<Route path="reports/technical" element={<PermissionGuard perm="viewReports"><TechnicalPage /></PermissionGuard>} />
|
||||
<Route path="reports/misc" element={<PermissionGuard perm="viewReports"><MiscPage /></PermissionGuard>} />
|
||||
|
||||
{/* ── Apps & Settings ── */}
|
||||
<Route path="apps" element={<AppsPage />} />
|
||||
<Route path="apps/phone" element={<PhonePage />} />
|
||||
<Route path="settings" element={<PermissionGuard perm="manageSettings"><SettingsPage /></PermissionGuard>} />
|
||||
<Route path="notes" element={<NotesPage />} />
|
||||
|
||||
{/* ── Legacy redirects (old URLs → new locations) ── */}
|
||||
<Route path="contacts" element={<Navigate to="/crm/contacts" replace />} />
|
||||
<Route path="customers" element={<Navigate to="/crm/customers" replace />} />
|
||||
<Route path="expenses" element={<Navigate to="/financials/expenses" replace />} />
|
||||
<Route path="tabs" element={<Navigate to="/financials/tabs" replace />} />
|
||||
<Route path="waste" element={<Navigate to="/inventory/throwaways" replace />} />
|
||||
<Route path="schedule" element={<Navigate to="/management/schedule" replace />} />
|
||||
<Route path="management" element={<Navigate to="/management/products" replace />} />
|
||||
<Route path="reports" element={<Navigate to="/reports/today" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthRehydrator>
|
||||
|
||||
25
manager_dashboard/src/components/PermissionGuard.jsx
Normal file
25
manager_dashboard/src/components/PermissionGuard.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import usePermissions from '../hooks/usePermissions'
|
||||
|
||||
/**
|
||||
* Wraps a route element. Renders a 403 screen if the user lacks the required permission.
|
||||
* Usage: <PermissionGuard perm="viewReports"><TodayPage /></PermissionGuard>
|
||||
*/
|
||||
export default function PermissionGuard({ perm, children }) {
|
||||
const perms = usePermissions()
|
||||
|
||||
if (!perms[perm]) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
|
||||
<ShieldAlert size={48} className="text-red-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-800">Δεν έχετε πρόσβαση</h2>
|
||||
<p className="text-gray-500 max-w-sm">
|
||||
Δεν έχετε τα απαραίτητα δικαιώματα για αυτή τη σελίδα.
|
||||
Επικοινωνήστε με τον διαχειριστή εάν νομίζετε ότι πρόκειται για λάθος.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
358
manager_dashboard/src/components/PhoneListener.jsx
Normal file
358
manager_dashboard/src/components/PhoneListener.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Mounts once inside AppLayout. Holds the WebSocket connection to /api/phone/ws,
|
||||
* matches incoming callers against the customer list, and renders the call popup
|
||||
* over whatever page is currently open.
|
||||
*/
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { PhoneIncoming, X, Mail, Phone, MessageSquare, Tag,
|
||||
ShoppingBag, TrendingUp, Clock, User } from 'lucide-react'
|
||||
import client from '../api/client'
|
||||
import usePhoneStore from '../store/phoneStore'
|
||||
|
||||
/* ─── Phone normalisation ─────────────────────────────────────────────────── */
|
||||
function normalisePhone(raw) {
|
||||
if (!raw) return ''
|
||||
let s = raw.replace(/[\s\-().+]/g, '')
|
||||
if (s.startsWith('0030')) s = s.slice(4)
|
||||
else if (s.startsWith('30') && s.length > 10) s = s.slice(2)
|
||||
return s
|
||||
}
|
||||
function phonesMatch(a, b) {
|
||||
const na = normalisePhone(a), nb = normalisePhone(b)
|
||||
return na.length > 0 && na === nb
|
||||
}
|
||||
|
||||
function buildWsUrl() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${proto}://${window.location.host}/api/phone/ws`
|
||||
}
|
||||
|
||||
/* ─── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('el-GR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
function formatCurrency(n) {
|
||||
return `€${Number(n || 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
/* ─── Ripple rings ────────────────────────────────────────────────────────── */
|
||||
function RippleRings({ color }) {
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} style={{
|
||||
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
|
||||
border: `2px solid ${color}`, opacity: 0,
|
||||
animation: `phoneRipple 2s ease-out ${i * 0.6}s infinite`,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Known customer modal ────────────────────────────────────────────────── */
|
||||
function KnownCustomerModal({ call, customer, orders, onClose }) {
|
||||
const recent = (orders || []).slice(0, 3)
|
||||
return (
|
||||
<div style={{ fontFamily: "'DM Sans','Segoe UI',sans-serif" }}>
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9990,
|
||||
background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(8px)',
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'fixed', zIndex: 9991,
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%,-50%)',
|
||||
width: '100%', maxWidth: 480, padding: '0 16px',
|
||||
animation: 'slideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#0f1117', borderRadius: 20, overflow: 'hidden',
|
||||
boxShadow: '0 32px 80px rgba(0,0,0,0.8),0 0 0 1px rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{ height: 3, background: 'linear-gradient(90deg,#d4a853,#f0c87a,#d4a853)' }} />
|
||||
|
||||
<div style={{ padding: '24px 24px 20px', position: 'relative' }}>
|
||||
<button onClick={onClose} style={{
|
||||
position: 'absolute', top: 20, right: 20,
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#9ca3af', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.12)'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.07)'; e.currentTarget.style.color = '#9ca3af' }}
|
||||
><X size={14} /></button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ position: 'relative', width: 44, height: 44 }}>
|
||||
<RippleRings color="#d4a853" />
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg,#d4a853,#f0c87a)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
position: 'relative', zIndex: 1,
|
||||
}}><PhoneIncoming size={18} color="#0f1117" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.12em', color: '#d4a853', textTransform: 'uppercase' }}>Εισερχόμενη Κλήση</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{formatTime(call.at)} · {call.caller}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg,#1e293b,#334155)',
|
||||
border: '1px solid rgba(212,168,83,0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, fontWeight: 700, color: '#d4a853',
|
||||
}}>{customer.name?.[0]?.toUpperCase() || '?'}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', lineHeight: 1.2, letterSpacing: '-0.01em' }}>{customer.name}</div>
|
||||
{customer.nickname && (
|
||||
<div style={{ fontSize: 13, color: '#d4a853', marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Tag size={11} />{customer.nickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'rgba(255,255,255,0.06)', margin: '0 24px' }} />
|
||||
|
||||
<div style={{ padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{customer.email && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Mail size={13} color="#6b7280" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: '#d1d5db' }}>{customer.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{customer.phone && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Phone size={13} color="#6b7280" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: '#d1d5db', fontVariantNumeric: 'tabular-nums' }}>{customer.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{customer.notes && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 2 }}>
|
||||
<MessageSquare size={13} color="#6b7280" style={{ flexShrink: 0, marginTop: 1 }} />
|
||||
<span style={{ fontSize: 13, color: '#9ca3af', lineHeight: 1.5, fontStyle: 'italic' }}>"{customer.notes}"</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
margin: '0 24px',
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: 12, display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||
}}>
|
||||
<div style={{ padding: '12px 16px', borderRight: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
<div style={{ fontSize: 10, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Επισκέψεις</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ShoppingBag size={14} color="#d4a853" />{customer.visit_count ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontSize: 10, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Σύνολο</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendingUp size={14} color="#d4a853" />{formatCurrency(customer.total_spent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 24px 0' }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: '#6b7280', textTransform: 'uppercase', marginBottom: 8 }}>Πρόσφατες Παραγγελίες</div>
|
||||
{recent.length === 0 ? (
|
||||
<div style={{ padding: '12px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.03)', border: '1px dashed rgba(255,255,255,0.08)', fontSize: 12, color: '#4b5563', textAlign: 'center' }}>
|
||||
Δεν υπάρχουν παραγγελίες ακόμα
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{recent.map(order => (
|
||||
<div key={order.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<Clock size={12} color="#6b7280" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, color: '#d1d5db', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDate(order.opened_at)}
|
||||
{order.table_name && <span style={{ color: '#6b7280' }}> · {order.table_name}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 1 }}>{order.item_count} είδη</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#d4a853', fontVariantNumeric: 'tabular-nums' }}>{formatCurrency(order.total)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<button onClick={onClose} style={{
|
||||
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(212,168,83,0.12)', color: '#d4a853', fontSize: 13, fontWeight: 600,
|
||||
letterSpacing: '0.02em', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(212,168,83,0.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(212,168,83,0.12)' }}
|
||||
>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Unknown caller modal ────────────────────────────────────────────────── */
|
||||
function UnknownCallerModal({ call, onClose }) {
|
||||
return (
|
||||
<div style={{ fontFamily: "'DM Sans','Segoe UI',sans-serif" }}>
|
||||
<div onClick={onClose} style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9990,
|
||||
background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(8px)',
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'fixed', zIndex: 9991,
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%,-50%)',
|
||||
width: '100%', maxWidth: 360, padding: '0 16px',
|
||||
animation: 'slideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#0f1117', borderRadius: 20, overflow: 'hidden',
|
||||
boxShadow: '0 32px 80px rgba(0,0,0,0.8),0 0 0 1px rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{ height: 3, background: 'linear-gradient(90deg,#3b82f6,#6366f1,#3b82f6)' }} />
|
||||
<div style={{ padding: '28px 24px 24px', position: 'relative' }}>
|
||||
<button onClick={onClose} style={{
|
||||
position: 'absolute', top: 20, right: 20,
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#9ca3af', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.12)'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.07)'; e.currentTarget.style.color = '#9ca3af' }}
|
||||
><X size={14} /></button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 20 }}>
|
||||
<div style={{ position: 'relative', width: 44, height: 44 }}>
|
||||
<RippleRings color="#3b82f6" />
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg,#1d4ed8,#3b82f6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
position: 'relative', zIndex: 1,
|
||||
}}><PhoneIncoming size={18} color="#fff" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.12em', color: '#3b82f6', textTransform: 'uppercase' }}>Εισερχόμενη Κλήση</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{formatTime(call.at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '20px 0 8px' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}><User size={24} color="#4b5563" /></div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280' }}>Άγνωστος καλών</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: '#f9fafb', letterSpacing: '0.02em', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{call.caller || '—'}
|
||||
</div>
|
||||
{call.ext && <div style={{ fontSize: 12, color: '#6b7280' }}>Εσωτερικό: {call.ext}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<button onClick={onClose} style={{
|
||||
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(59,130,246,0.1)', color: '#3b82f6', fontSize: 13, fontWeight: 600,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(59,130,246,0.18)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(59,130,246,0.1)' }}
|
||||
>Κλείσιμο</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── PhoneListener ───────────────────────────────────────────────────────── */
|
||||
export default function PhoneListener() {
|
||||
const { activeCall, dismissCall, setActiveCall, addCallToLog } = usePhoneStore()
|
||||
const [customers, setCustomers] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
client.get('/api/customers/')
|
||||
.then(r => setCustomers(r.data || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleIncoming = useCallback(async ({ caller, ext }) => {
|
||||
const at = new Date()
|
||||
const matched = customers.find(c => phonesMatch(c.phone, caller))
|
||||
let orders = null
|
||||
if (matched) {
|
||||
try {
|
||||
const r = await client.get(`/api/customers/${matched.id}/orders`)
|
||||
orders = r.data?.orders ?? []
|
||||
} catch { orders = [] }
|
||||
}
|
||||
const entry = { caller, ext, at, customer: matched || null, orders }
|
||||
addCallToLog(entry)
|
||||
setActiveCall(entry)
|
||||
}, [customers, addCallToLog, setActiveCall])
|
||||
|
||||
useEffect(() => {
|
||||
let ws, dead = false
|
||||
function connect() {
|
||||
if (dead) return
|
||||
ws = new WebSocket(buildWsUrl())
|
||||
ws.onclose = () => { if (!dead) setTimeout(connect, 3000) }
|
||||
ws.onerror = () => ws.close()
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
if (data.type === 'incoming_call') handleIncoming({ caller: data.caller, ext: data.ext })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
connect()
|
||||
return () => { dead = true; ws?.close() }
|
||||
}, [handleIncoming])
|
||||
|
||||
if (!activeCall) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes slideUp {
|
||||
from{opacity:0;transform:translate(-50%,calc(-50% + 24px))}
|
||||
to{opacity:1;transform:translate(-50%,-50%)}
|
||||
}
|
||||
@keyframes phoneRipple {
|
||||
0%{transform:scale(1);opacity:0.6}
|
||||
100%{transform:scale(2.8);opacity:0}
|
||||
}
|
||||
`}</style>
|
||||
{activeCall.customer
|
||||
? <KnownCustomerModal call={activeCall} customer={activeCall.customer} orders={activeCall.orders} onClose={dismissCall} />
|
||||
: <UnknownCallerModal call={activeCall} onClose={dismissCall} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,227 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat, CalendarDays } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard, LayoutGrid, ShoppingBag,
|
||||
BookUser, Users, Receipt, CreditCard,
|
||||
Package, TableProperties, UserCog, CalendarDays, Trash2, Boxes,
|
||||
BarChart2, TrendingUp, Users2, ShoppingCart, Tag, Activity, Printer, Ban, Percent, ConciergeBell, FileText,
|
||||
AppWindow, Settings, ChevronDown, CookingPot, StickyNote,
|
||||
} from 'lucide-react'
|
||||
import { getIncomingOrders } from '../api/client'
|
||||
import { isFeatureEnabled } from '../hooks/usePhase2Features'
|
||||
import usePermissions from '../hooks/usePermissions'
|
||||
|
||||
// Phase 2 feature IDs mapped to their sidebar routes (must match usePhase2Features defs)
|
||||
const PHASE2_ROUTES = new Set(['/notes', '/expenses', '/contacts', '/customers', '/tabs', '/waste', '/kds', '/schedule'])
|
||||
// ─── Nav structure ────────────────────────────────────────────────────────────
|
||||
|
||||
// `perm` — key from usePermissions(). If set, item is hidden when user lacks that permission.
|
||||
function buildNav(pendingCount) {
|
||||
return [
|
||||
{
|
||||
category: 'Operation',
|
||||
items: [
|
||||
{ type: 'solo', to: '/dashboard', icon: LayoutDashboard, label: 'Εικόνα' },
|
||||
{ type: 'solo', to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||
{ type: 'solo', to: '/online-orders', icon: ShoppingBag, label: 'Online Παραγγ.', badge: pendingCount, phase2: 'online-orders' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Financial',
|
||||
items: [
|
||||
{
|
||||
type: 'group', id: 'crm', icon: BookUser, label: 'CRM',
|
||||
children: [
|
||||
{ to: '/crm/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' },
|
||||
{ to: '/crm/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group', id: 'financials', icon: Receipt, label: 'Οικονομικά',
|
||||
children: [
|
||||
{ to: '/financials/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' },
|
||||
{ to: '/financials/tabs', icon: CreditCard, label: 'Καρτέλες Πελ.', phase2: 'tabs' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Headquarters',
|
||||
items: [
|
||||
{
|
||||
type: 'group', id: 'management', icon: Package, label: 'Διαχείριση',
|
||||
children: [
|
||||
{ to: '/management/products', icon: Package, label: 'Προϊόντα', perm: 'manageMenu' },
|
||||
{ to: '/management/tables', icon: TableProperties, label: 'Τραπέζια', perm: 'manageTables' },
|
||||
{ to: '/management/staff', icon: UserCog, label: 'Προσωπικό', perm: 'manageStaff' },
|
||||
{ to: '/management/schedule', icon: CalendarDays, label: 'Πρόγραμμα', perm: 'manageStaff', phase2: 'schedule' },
|
||||
{ to: '/management/prep-zones', icon: CookingPot, label: 'Ζώνες Ετοιμασίας', perm: 'manageMenu' },
|
||||
{ to: '/management/pricing', icon: Percent, label: 'Προσφορές & Τιμές', perm: 'manageMenu' },
|
||||
{ to: '/notes', icon: StickyNote, label: 'Σημειώσεις' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group', id: 'inventory', icon: Boxes, label: 'Αποθήκη',
|
||||
children: [
|
||||
{ to: '/inventory/throwaways', icon: Trash2, label: 'Απορρίμματα', phase2: 'waste' },
|
||||
{ to: '/inventory/stock', icon: Boxes, label: 'Απόθεμα', placeholder: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Overwatch',
|
||||
perm: 'viewReports',
|
||||
items: [
|
||||
{
|
||||
type: 'group', id: 'reports', icon: BarChart2, label: 'Αναφορές',
|
||||
children: [
|
||||
{ to: '/reports/today', icon: Activity, label: 'Σήμερα' },
|
||||
{ to: '/reports/store', icon: TrendingUp, label: 'Κατάστημα' },
|
||||
{ to: '/reports/staff', icon: Users2, label: 'Προσωπικό' },
|
||||
{ to: '/reports/products', icon: ShoppingCart, label: 'Προϊόντα' },
|
||||
{ to: '/reports/prep-zones', icon: ConciergeBell, label: 'Ζώνες Ετοιμασίας' },
|
||||
{ to: '/reports/expenses', icon: FileText, label: 'Έξοδα' },
|
||||
{ to: '/reports/technical', icon: Printer, label: 'Τεχνικά' },
|
||||
{ to: '/reports/misc', icon: Tag, label: 'Λοιπά' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const BOTTOM_ITEMS = [
|
||||
{ to: '/apps', icon: AppWindow, label: 'Εφαρμογές' },
|
||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις', perm: 'manageSettings' },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isChildActive(children, pathname) {
|
||||
return children.some(c => pathname === c.to || pathname.startsWith(c.to + '/'))
|
||||
}
|
||||
|
||||
// ─── Child link ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ChildLink({ to, icon: Icon, label, placeholder }) {
|
||||
if (placeholder) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 mx-2 rounded-md text-[13px] text-primary-400 cursor-default select-none">
|
||||
<Icon size={14} className="shrink-0 opacity-50" />
|
||||
<span>{label}</span>
|
||||
<span className="ml-auto text-[10px] font-semibold bg-primary-700 text-primary-300 px-1.5 py-0.5 rounded">soon</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-3 py-2 mx-2 rounded-md text-[13px] font-medium transition-colors ` +
|
||||
(isActive
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-primary-200 hover:bg-primary-700 hover:text-white')
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span>{label}</span>
|
||||
{isActive && <span className="ml-auto w-1 h-1 rounded-full bg-sky-400 shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Group item (collapsible parent) ─────────────────────────────────────────
|
||||
|
||||
function GroupItem({ id, icon: Icon, label, children, openGroup, onToggle, perms }) {
|
||||
const location = useLocation()
|
||||
const hasActive = isChildActive(children, location.pathname)
|
||||
const isOpen = openGroup === id
|
||||
|
||||
// Filter out phase2-gated children and children the user lacks permission for
|
||||
const visibleChildren = children.filter(c => {
|
||||
if (c.phase2 && !isFeatureEnabled(c.phase2)) return false
|
||||
if (c.perm && !perms[c.perm]) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (visibleChildren.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onToggle(id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ` +
|
||||
(hasActive
|
||||
? 'text-white bg-primary-700'
|
||||
: 'text-primary-100 hover:bg-primary-700 hover:text-white')}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-0.5 mb-1 space-y-0.5 py-1 border-l border-primary-600 ml-5">
|
||||
{visibleChildren.map(child => (
|
||||
<ChildLink key={child.to} {...child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Solo item (direct link) ──────────────────────────────────────────────────
|
||||
|
||||
function SoloItem({ to, icon: Icon, label, badge, onNavigate }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ` +
|
||||
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700 hover:text-white')
|
||||
}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Icon size={18} />
|
||||
{badge > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center leading-none">
|
||||
{badge > 9 ? '9+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [openGroup, setOpenGroup] = useState(null)
|
||||
const [, featTick] = useState(0)
|
||||
const location = useLocation()
|
||||
const pollRef = useRef(null)
|
||||
const perms = usePermissions()
|
||||
|
||||
// Online orders badge polling
|
||||
useEffect(() => {
|
||||
async function fetchPending() {
|
||||
try {
|
||||
const { data } = await getIncomingOrders()
|
||||
setPendingCount(data.length)
|
||||
} catch { /* silently ignore — sidebar badge is non-critical */ }
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
fetchPending()
|
||||
pollRef.current = setInterval(fetchPending, 20_000)
|
||||
@@ -36,62 +239,93 @@ export default function Sidebar() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ALL_NAV = [
|
||||
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
||||
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount, phase2: 'online-orders' },
|
||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||
{ to: '/notes', icon: NotebookPen, label: 'Σημειώσεις', phase2: 'notes' },
|
||||
{ to: '/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' },
|
||||
{ to: '/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' },
|
||||
{ to: '/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' },
|
||||
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες', phase2: 'tabs' },
|
||||
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα', phase2: 'waste' },
|
||||
{ to: '/kds', icon: ChefHat, label: 'KDS', phase2: 'kds' },
|
||||
{ to: '/schedule', icon: CalendarDays, label: 'Πρόγραμμα', phase2: 'schedule' },
|
||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||
]
|
||||
// Auto-open the group that contains the active route on location change
|
||||
useEffect(() => {
|
||||
const nav = buildNav(0)
|
||||
for (const section of nav) {
|
||||
for (const item of section.items) {
|
||||
if (item.type === 'group' && isChildActive(item.children, location.pathname)) {
|
||||
setOpenGroup(item.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
// Filter out disabled Phase 2 entries
|
||||
const NAV = ALL_NAV.filter(item => !item.phase2 || isFeatureEnabled(item.phase2))
|
||||
function handleGroupToggle(id) {
|
||||
setOpenGroup(prev => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
function handleSoloClick() {
|
||||
setOpenGroup(null)
|
||||
}
|
||||
|
||||
const nav = buildNav(pendingCount)
|
||||
|
||||
return (
|
||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||
{/* Logo / collapse toggle */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
|
||||
{!collapsed && <span className="font-bold text-lg tracking-wide">XeniaPOS</span>}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
<aside className="flex flex-col bg-primary-800 text-white shrink-0 w-56 h-full sidebar-scroll">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center px-4 py-4 border-b border-primary-700 shrink-0">
|
||||
<span className="font-bold text-lg tracking-wide">XeniaPOS</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{NAV.map(({ to, icon: Icon, label, badge }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-3 rounded-lg font-medium transition-colors min-h-[44px] ` +
|
||||
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
||||
{/* Main nav */}
|
||||
<nav className="flex-1 py-3 px-2 space-y-4">
|
||||
{nav.map(({ category, perm: sectionPerm, items }) => {
|
||||
// Hide entire section if user lacks section-level permission
|
||||
if (sectionPerm && !perms[sectionPerm]) return null
|
||||
|
||||
const visibleItems = items.filter(item => {
|
||||
if (item.type === 'solo') {
|
||||
if (item.perm && !perms[item.perm]) return false
|
||||
return !item.phase2 || isFeatureEnabled(item.phase2)
|
||||
}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Icon size={20} />
|
||||
{badge > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center leading-none">
|
||||
{badge > 9 ? '9+' : badge}
|
||||
if (item.type === 'group') {
|
||||
const visible = item.children.filter(c => {
|
||||
if (c.phase2 && !isFeatureEnabled(c.phase2)) return false
|
||||
if (c.perm && !perms[c.perm]) return false
|
||||
return true
|
||||
})
|
||||
return visible.length > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (visibleItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 px-3 mb-1.5 select-none" style={{ opacity: 0.45 }}>
|
||||
<span className="text-[9.5px] font-bold uppercase tracking-widest text-primary-100 whitespace-nowrap">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" style={{ height: 1, background: 'rgba(255,255,255,0.2)' }} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{visibleItems.map(item =>
|
||||
item.type === 'solo' ? (
|
||||
<SoloItem key={item.to} {...item} onNavigate={handleSoloClick} />
|
||||
) : (
|
||||
<GroupItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
openGroup={openGroup}
|
||||
onToggle={handleGroupToggle}
|
||||
perms={perms}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <span className="text-sm">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Divider + bottom items */}
|
||||
<div className="shrink-0 px-2 pb-3 border-t border-primary-700 pt-3 space-y-0.5">
|
||||
{BOTTOM_ITEMS.filter(item => !item.perm || perms[item.perm]).map(item => (
|
||||
<SoloItem key={item.to} {...item} onNavigate={handleSoloClick} />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ function fmtDateTime(iso) {
|
||||
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const MANAGER_ROLES = new Set(['superadmin', 'owner', 'store_manager', 'staff_manager'])
|
||||
function roleBadge(role) {
|
||||
if (role === 'manager' || role === 'sysadmin') {
|
||||
if (MANAGER_ROLES.has(role)) {
|
||||
return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' }
|
||||
}
|
||||
return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' }
|
||||
|
||||
49
manager_dashboard/src/hooks/usePermissions.js
Normal file
49
manager_dashboard/src/hooks/usePermissions.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Returns a permissions object derived from the logged-in user.
|
||||
* Superadmin always has every permission regardless of stored flags.
|
||||
*/
|
||||
export default function usePermissions() {
|
||||
const user = useAuthStore(s => s.user)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
isSuperadmin: false,
|
||||
accessDashboard: false,
|
||||
accessWaiterApp: false,
|
||||
accessKds: false,
|
||||
cancelOrders: false,
|
||||
applyDiscounts: false,
|
||||
modifyPrices: false,
|
||||
openOrders: false,
|
||||
closeOrders: false,
|
||||
viewReports: false,
|
||||
manageStaff: false,
|
||||
manageTables: false,
|
||||
manageMenu: false,
|
||||
manageSettings: false,
|
||||
}
|
||||
}
|
||||
|
||||
const superadmin = user.role === 'superadmin'
|
||||
|
||||
const p = (flag) => superadmin || !!user[flag]
|
||||
|
||||
return {
|
||||
isSuperadmin: superadmin,
|
||||
accessDashboard: p('perm_access_dashboard'),
|
||||
accessWaiterApp: p('perm_access_waiter_app'),
|
||||
accessKds: p('perm_access_kds'),
|
||||
cancelOrders: p('perm_cancel_orders'),
|
||||
applyDiscounts: p('perm_apply_discounts'),
|
||||
modifyPrices: p('perm_modify_prices'),
|
||||
openOrders: p('perm_open_orders'),
|
||||
closeOrders: p('perm_close_orders'),
|
||||
viewReports: p('perm_view_reports'),
|
||||
manageStaff: p('perm_manage_staff'),
|
||||
manageTables: p('perm_manage_tables'),
|
||||
manageMenu: p('perm_manage_menu'),
|
||||
manageSettings: p('perm_manage_settings'),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
@font-face {
|
||||
font-family: 'Google Sans';
|
||||
src: url('/fonts/GoogleSans-Variable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Google Sans';
|
||||
src: url('/fonts/GoogleSans-Variable-Italic.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -34,3 +49,31 @@
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar scrollbar — pre-allocates gutter so content never shifts, fades in on hover */
|
||||
.sidebar-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.25s;
|
||||
}
|
||||
.sidebar-scroll:hover {
|
||||
scrollbar-color: rgba(255,255,255,0.22) transparent;
|
||||
}
|
||||
/* Webkit */
|
||||
.sidebar-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.sidebar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar-scroll::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 99px;
|
||||
transition: background 0.25s;
|
||||
}
|
||||
.sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.22);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,84 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, createContext, useContext } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, createContext } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Lock, AlertTriangle, ShieldAlert } from 'lucide-react'
|
||||
import { Lock, AlertTriangle, ShieldAlert, Search, Bell, ChevronRight } from 'lucide-react'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
import UserMenuButton from '../components/UserMenuButton'
|
||||
import EditProfileModal from '../components/EditProfileModal'
|
||||
import useLicenseStatus from '../hooks/useLicenseStatus'
|
||||
import PhoneListener from '../components/PhoneListener'
|
||||
|
||||
export const LicenseContext = createContext(null)
|
||||
|
||||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||||
|
||||
// ─── Route → title/breadcrumb map ────────────────────────────────────────────
|
||||
|
||||
const ROUTE_META = {
|
||||
'/dashboard': { title: 'Εικόνα' },
|
||||
'/tables': { title: 'Τραπέζια' },
|
||||
'/online-orders': { title: 'Online Παραγγελίες' },
|
||||
'/crm/contacts': { title: 'Επαφές', crumbs: ['CRM', 'Επαφές'] },
|
||||
'/crm/customers': { title: 'Πελάτες', crumbs: ['CRM', 'Πελάτες'] },
|
||||
'/financials/expenses': { title: 'Έξοδα', crumbs: ['Οικονομικά', 'Έξοδα'] },
|
||||
'/financials/tabs': { title: 'Καρτέλες Πελατών', crumbs: ['Οικονομικά', 'Καρτέλες Πελατών'] },
|
||||
'/management/products': { title: 'Προϊόντα', crumbs: ['Διαχείριση', 'Προϊόντα'] },
|
||||
'/management/tables': { title: 'Τραπέζια', crumbs: ['Διαχείριση', 'Τραπέζια'] },
|
||||
'/management/staff': { title: 'Προσωπικό', crumbs: ['Διαχείριση', 'Προσωπικό'] },
|
||||
'/management/schedule': { title: 'Πρόγραμμα', crumbs: ['Διαχείριση', 'Πρόγραμμα'] },
|
||||
'/inventory/throwaways': { title: 'Απορρίμματα', crumbs: ['Αποθήκη', 'Απορρίμματα'] },
|
||||
'/inventory/stock': { title: 'Απόθεμα', crumbs: ['Αποθήκη', 'Απόθεμα'] },
|
||||
'/reports/today': { title: 'Σήμερα', crumbs: ['Αναφορές', 'Σήμερα'] },
|
||||
'/reports/store': { title: 'Κατάστημα', crumbs: ['Αναφορές', 'Κατάστημα'] },
|
||||
'/reports/staff': { title: 'Προσωπικό', crumbs: ['Αναφορές', 'Προσωπικό'] },
|
||||
'/reports/products': { title: 'Προϊόντα', crumbs: ['Αναφορές', 'Προϊόντα'] },
|
||||
'/reports/prep-zones': { title: 'Ζώνες Ετοιμασίας', crumbs: ['Αναφορές', 'Ζώνες Ετοιμασίας'] },
|
||||
'/reports/expenses': { title: 'Έξοδα', crumbs: ['Αναφορές', 'Έξοδα'] },
|
||||
'/reports/technical': { title: 'Τεχνικά', crumbs: ['Αναφορές', 'Τεχνικά'] },
|
||||
'/reports/misc': { title: 'Λοιπά', crumbs: ['Αναφορές', 'Λοιπά'] },
|
||||
'/apps': { title: 'Εφαρμογές' },
|
||||
'/apps/phone': { title: 'Τηλέφωνο', crumbs: ['Εφαρμογές', 'Τηλέφωνο'] },
|
||||
'/settings': { title: 'Ρυθμίσεις' },
|
||||
'/notes': { title: 'Σημειώσεις' },
|
||||
}
|
||||
|
||||
function PageTitle() {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// Match exact first, then try prefix for dynamic routes like /orders/:id
|
||||
let meta = ROUTE_META[pathname]
|
||||
if (!meta && pathname.startsWith('/orders/')) {
|
||||
meta = { title: 'Order Detail', crumbs: ['Order Detail'] }
|
||||
}
|
||||
|
||||
if (!meta) return <span className="text-[15px] font-semibold text-slate-800">XeniaPOS</span>
|
||||
|
||||
if (!meta.crumbs) {
|
||||
return <span className="text-[15px] font-semibold text-slate-800">{meta.title}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1.5">
|
||||
{meta.crumbs.map((crumb, i) => {
|
||||
const isLast = i === meta.crumbs.length - 1
|
||||
return (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <ChevronRight size={13} className="text-slate-300 shrink-0" />}
|
||||
<span className={isLast
|
||||
? 'text-[14px] font-semibold text-slate-800'
|
||||
: 'text-[13px] text-slate-400 font-medium'
|
||||
}>
|
||||
{crumb}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── License Banner ───────────────────────────────────────────────────────────
|
||||
|
||||
function LicenseBanner({ license }) {
|
||||
@@ -26,7 +92,6 @@ function LicenseBanner({ license }) {
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
// Admin lock (deferred or enforced)
|
||||
if (lock_reason === 'admin') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
|
||||
@@ -38,7 +103,6 @@ function LicenseBanner({ license }) {
|
||||
)
|
||||
}
|
||||
|
||||
// License fully expired + grace over → blocked
|
||||
if (isBlocked && lock_reason === 'expired') {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
return (
|
||||
@@ -49,7 +113,6 @@ function LicenseBanner({ license }) {
|
||||
)
|
||||
}
|
||||
|
||||
// In grace period (expired but still allowed to operate)
|
||||
if (inGracePeriod) {
|
||||
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
|
||||
const remaining = grace_days_remaining ?? '?'
|
||||
@@ -61,7 +124,6 @@ function LicenseBanner({ license }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Expiry warning (≤5 days remaining)
|
||||
if (showExpiryWarning) {
|
||||
const days = days_until_expiry
|
||||
return (
|
||||
@@ -75,7 +137,7 @@ function LicenseBanner({ license }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Lock Screen — PIN only, always. No password ever. ────────────────────────
|
||||
// ─── Lock Screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LockScreen({ username, displayName, onUnlock, onLogout }) {
|
||||
const [pin, setPin] = useState('')
|
||||
@@ -96,7 +158,7 @@ function LockScreen({ username, displayName, onUnlock, onLogout }) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login', { username, pin: usedPin })
|
||||
if (data.user.role !== 'manager' && data.user.role !== 'sysadmin') {
|
||||
if (!data.user.perm_access_dashboard) {
|
||||
setError('Not a manager account.')
|
||||
setPin('')
|
||||
return
|
||||
@@ -182,7 +244,6 @@ function LockScreen({ username, displayName, onUnlock, onLogout }) {
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, savedUsername, logout, lock, unlock, locked } = useAuthStore()
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const license = useLicenseStatus()
|
||||
@@ -193,19 +254,11 @@ export default function AppLayout() {
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
// Single ref object — updated every render so the interval always sees fresh values
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { user, locked, securitySettings, logout, lock, navigate }
|
||||
|
||||
const lastActivityRef = useRef(Date.now())
|
||||
|
||||
// ── Clock ──────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Single long-lived interval — never restarts ────────────────────────────
|
||||
useEffect(() => {
|
||||
function onActivity() { lastActivityRef.current = Date.now() }
|
||||
|
||||
@@ -225,14 +278,12 @@ export default function AppLayout() {
|
||||
const lockSecs = autoLock ? parseInt(get('security.auto_lock_seconds', '300'), 10) : Infinity
|
||||
const logoutSecs = autoLogout ? parseInt(get('security.auto_logout_seconds', '1800'), 10) : Infinity
|
||||
|
||||
// Auto-logout runs regardless of lock state
|
||||
if (idle >= logoutSecs) {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-lock only fires when not already locked
|
||||
if (!locked && idle >= lockSecs) {
|
||||
lock()
|
||||
}
|
||||
@@ -242,9 +293,8 @@ export default function AppLayout() {
|
||||
clearInterval(id)
|
||||
EVENTS.forEach(ev => window.removeEventListener(ev, onActivity))
|
||||
}
|
||||
}, []) // runs once on mount, reads everything from stateRef
|
||||
}, [])
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true, state: { manualLogout: true } })
|
||||
@@ -255,7 +305,6 @@ export default function AppLayout() {
|
||||
lastActivityRef.current = Date.now()
|
||||
}
|
||||
|
||||
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
const loginUsername = user?.username || savedUsername || ''
|
||||
const displayName = user?.full_name || loginUsername
|
||||
|
||||
@@ -272,11 +321,42 @@ export default function AppLayout() {
|
||||
)}
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<LicenseBanner license={license} />
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0">
|
||||
<span className="text-[13px] font-semibold text-slate-600 tabular-nums">{timeStr}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0 gap-4">
|
||||
{/* Left: page title / breadcrumbs */}
|
||||
<div className="flex items-center min-w-0">
|
||||
<PageTitle />
|
||||
</div>
|
||||
|
||||
{/* Right: search + bell + lock + user */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Search (placeholder) */}
|
||||
<div className="relative hidden sm:flex items-center">
|
||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
className="pl-8 pr-3 py-1.5 text-[13px] bg-slate-50 border border-slate-200 rounded-lg w-44 focus:outline-none focus:ring-2 focus:ring-sky-500/30 focus:border-sky-400 transition placeholder:text-slate-400"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notifications (placeholder) */}
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-slate-200" />
|
||||
|
||||
{/* Lock */}
|
||||
<button
|
||||
onClick={lock}
|
||||
title="Lock"
|
||||
@@ -284,6 +364,8 @@ export default function AppLayout() {
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<UserMenuButton
|
||||
displayName={displayName}
|
||||
onEditProfile={() => setProfileOpen(true)}
|
||||
@@ -292,10 +374,13 @@ export default function AppLayout() {
|
||||
{profileOpen && <EditProfileModal onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<PhoneListener />
|
||||
</div>
|
||||
</LicenseContext.Provider>
|
||||
)
|
||||
|
||||
38
manager_dashboard/src/pages/AppsPage.jsx
Normal file
38
manager_dashboard/src/pages/AppsPage.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChefHat, Phone } from 'lucide-react'
|
||||
|
||||
export default function AppsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 max-w-2xl">
|
||||
<button
|
||||
onClick={() => navigate('/kds')}
|
||||
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-sky-300 hover:shadow-md transition-all text-center group"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-50 text-sky-600 group-hover:bg-sky-100 transition-colors">
|
||||
<ChefHat size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-slate-800">KDS</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Kitchen Display</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/apps/phone')}
|
||||
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-emerald-300 hover:shadow-md transition-all text-center group"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100 transition-colors">
|
||||
<Phone size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-slate-800">Τηλέφωνο</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Caller ID</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,8 +132,7 @@ export default function ContactsPage() {
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ contact: null })}
|
||||
|
||||
@@ -247,8 +247,7 @@ export default function CustomersPage() {
|
||||
<div style={{ padding: '18px 24px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πελάτες</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ customer: null })}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
839
manager_dashboard/src/pages/DesktopOrderingTab.jsx
Normal file
839
manager_dashboard/src/pages/DesktopOrderingTab.jsx
Normal file
@@ -0,0 +1,839 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import ManagerOrderDrawer from './ManagerOrderDrawer'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FAV_CAT_ID = '__favorites__'
|
||||
const SVC_CAT_ID = '__service__'
|
||||
const DECIMAL_UNITS = new Set(['kg', 'liter', 'gram', 'ml'])
|
||||
const DECIMAL_UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function productAllowsQuickAdd(product) {
|
||||
if (!product.quick_add_enabled) return false
|
||||
for (const ps of (product.preference_sets || [])) {
|
||||
if (ps.default_choice_id == null) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function buildQuickAddItem(product) {
|
||||
const qty = DECIMAL_UNITS.has(product.unit_type) ? 1.0 : 1
|
||||
const selectedOptions = []
|
||||
for (const ps of (product.preference_sets || [])) {
|
||||
const defaultChoice = ps.choices?.find(c => c.id === ps.default_choice_id)
|
||||
if (defaultChoice) {
|
||||
selectedOptions.push({ id: defaultChoice.id, name: defaultChoice.name, price_delta: defaultChoice.extra_cost ?? 0 })
|
||||
}
|
||||
}
|
||||
return {
|
||||
product_id: product.id,
|
||||
quantity: qty,
|
||||
unit_type: product.unit_type || 'piece',
|
||||
unit_price: product.base_price,
|
||||
notes: '',
|
||||
selected_options: selectedOptions,
|
||||
removed_ingredients: [],
|
||||
}
|
||||
}
|
||||
|
||||
function fmtQty(qty, unitType) {
|
||||
const label = DECIMAL_UNIT_LABELS[unitType]
|
||||
if (!label) return `×${qty}`
|
||||
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
|
||||
return `${qty}${label}`
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
// ── Product card for the desktop grid ─────────────────────────────────────────
|
||||
|
||||
function DesktopProductCard({ product, onOpen, onQuickAdd }) {
|
||||
const canQuickAdd = productAllowsQuickAdd(product)
|
||||
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpen(product)}
|
||||
style={{
|
||||
background: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 120ms ease, border-color 120ms ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = '#cbd5e1' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = '#e2e8f0' }}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div style={{
|
||||
width: '100%', aspectRatio: '16/9', maxHeight: 80,
|
||||
background: '#f1f5f9', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <span style={{ fontSize: 22, fontWeight: 700, color: '#94a3b8', letterSpacing: -1 }}>{initials}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding: '8px 10px 10px', flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{product.name}
|
||||
</div>
|
||||
{product.tags?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{product.tags.slice(0, 2).map(tag => (
|
||||
<span key={tag} style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', background: '#f1f5f9', borderRadius: 99, color: '#64748b', border: '1px solid #e2e8f0' }}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: '#f59e0b' }}>{Number(product.base_price).toFixed(2)} €</span>
|
||||
{canQuickAdd && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onQuickAdd(product) }}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: '50%',
|
||||
background: '#fff7ed', border: '1.5px solid #fed7aa',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Service item widget ───────────────────────────────────────────────────────
|
||||
|
||||
function ServiceItemWidget({ product, customerCount, onAdd }) {
|
||||
const [qty, setQty] = useState(0)
|
||||
const hasPrice = product.base_price != null && product.base_price > 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', flex: 1, minWidth: 0 }}>{product.name}</span>
|
||||
<span style={{ fontSize: 12, color: hasPrice ? '#f59e0b' : '#94a3b8', fontWeight: 600, flexShrink: 0 }}>
|
||||
{hasPrice ? `${Number(product.base_price).toFixed(2)} €` : 'no charge'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button onClick={() => setQty(q => Math.max(0, q - 1))}
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: qty === 0 ? '#f8fafc' : 'rgba(245,158,11,0.1)', color: qty === 0 ? '#94a3b8' : '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>−</button>
|
||||
<button onClick={() => setQty(Math.max(1, customerCount ?? 1))} title={`Ορισμός σε ${customerCount ?? 1}`}
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => setQty(q => q + 1)}
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>+</button>
|
||||
<span style={{ fontSize: 20, fontWeight: 800, color: qty === 0 ? '#94a3b8' : '#1e293b', flex: 1, textAlign: 'center' }}>{qty}</span>
|
||||
<button
|
||||
disabled={qty === 0}
|
||||
onClick={() => {
|
||||
if (qty === 0) return
|
||||
onAdd({
|
||||
product_id: product.id,
|
||||
quantity: qty,
|
||||
unit_type: 'piece',
|
||||
unit_price: product.base_price ?? 0,
|
||||
notes: '',
|
||||
selected_options: [],
|
||||
removed_ingredients: [],
|
||||
is_service_item: true,
|
||||
})
|
||||
setQty(0)
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||||
background: qty === 0 ? '#f1f5f9' : '#f59e0b',
|
||||
color: qty === 0 ? '#94a3b8' : '#fff',
|
||||
fontSize: 12, fontWeight: 700, cursor: qty === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>ADD</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cart item row ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CartItemRow({ item, product, onRemove, onChangeQty, onEdit }) {
|
||||
const isDecimal = DECIMAL_UNITS.has(product?.unit_type)
|
||||
const optionSummary = item.selected_options?.length
|
||||
? item.selected_options.map(o => o.name).join(', ')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{product?.name ?? `#${item.product_id}`}
|
||||
</div>
|
||||
{optionSummary && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{optionSummary}
|
||||
</div>
|
||||
)}
|
||||
{item.notes && (
|
||||
<div style={{ fontSize: 11, color: '#f59e0b', marginTop: 1, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Qty controls */}
|
||||
{isDecimal ? (
|
||||
<button onClick={onEdit} style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 6, padding: '2px 8px', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
{fmtQty(item.quantity, product?.unit_type)}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<button onClick={() => onChangeQty(item.quantity - 1)}
|
||||
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: item.quantity <= 1 ? '#ef4444' : '#475569', fontSize: 14, fontWeight: 700 }}>
|
||||
{item.quantity <= 1
|
||||
? <svg width="10" height="10" 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: 13, fontWeight: 700, color: '#1e293b', minWidth: 16, textAlign: 'center' }}>{item.quantity}</span>
|
||||
<button onClick={() => onChangeQty(item.quantity + 1)}
|
||||
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#16a34a', fontSize: 14, fontWeight: 700 }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#475569', minWidth: 50, textAlign: 'right', flexShrink: 0 }}>
|
||||
€{((item.unit_price ?? 0) * item.quantity + (item.price_adjustment ?? 0) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
|
||||
{/* Edit + Remove */}
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<button onClick={onEdit}
|
||||
style={{ padding: '2px 7px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>
|
||||
Επεξ.
|
||||
</button>
|
||||
<button onClick={onRemove}
|
||||
style={{ width: 24, height: 24, borderRadius: '50%', border: 'none', background: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search overlay ────────────────────────────────────────────────────────────
|
||||
|
||||
function SearchOverlay({ products, onClose, onOpen }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
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()))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
|
||||
<div style={{
|
||||
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 201, width: 'min(580px, 90vw)',
|
||||
background: 'white', borderRadius: 16,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
maxHeight: '60vh',
|
||||
}}>
|
||||
{/* Input */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: '#94a3b8', 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
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Αναζήτηση προϊόντος…"
|
||||
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 16, color: '#1e293b', background: 'transparent', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button onClick={onClose} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#64748b' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{query.trim().length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '20px', fontSize: 14 }}>Πληκτρολογήστε για αναζήτηση…</p>
|
||||
) : results.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '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={() => { onOpen(p); onClose() }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '10px 16px', background: 'none', border: 'none', cursor: 'pointer', borderBottom: '1px solid #f1f5f9', textAlign: 'left' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 8, flexShrink: 0, background: '#f1f5f9', 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: 12, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>{Number(p.base_price).toFixed(2)} €</div>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ color: '#cbd5e1', flexShrink: 0 }}>
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DesktopOrderingTab({ orderId, tableId, orderStatus }) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery({
|
||||
queryKey: ['products-ordering'],
|
||||
queryFn: async () => {
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
client.get('/api/products/'),
|
||||
client.get('/api/products/categories'),
|
||||
])
|
||||
return { products: prodRes.data, categories: catRes.data }
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const products = productsData?.products ?? []
|
||||
const categories = productsData?.categories ?? []
|
||||
|
||||
const serviceProducts = products.filter(p => p.is_service_item)
|
||||
const regularProducts = products.filter(p => !p.is_service_item || p.category_id)
|
||||
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(null)
|
||||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [expandedSubs, setExpandedSubs] = useState({})
|
||||
|
||||
// Drawer for product customization
|
||||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||||
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
||||
|
||||
// Cart
|
||||
const [cart, setCart] = useState([])
|
||||
const [orderNote, setOrderNote] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
// Customer count (loaded from order)
|
||||
const { data: orderData } = useQuery({
|
||||
queryKey: ['order', orderId],
|
||||
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
|
||||
enabled: !!orderId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const customerCount = orderData?.customer_count ?? null
|
||||
|
||||
// Courses settings
|
||||
const { data: posSettings } = useQuery({
|
||||
queryKey: ['pos-settings-courses'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const coursesEnabled = posSettings?.['orders.courses_enabled']?.value === 'true'
|
||||
const courses = coursesEnabled ? (() => { try { return JSON.parse(posSettings?.['orders.courses']?.value || '[]') } catch { return [] } })() : []
|
||||
|
||||
// Set initial category once products load
|
||||
useEffect(() => {
|
||||
if (activeCat === null && topLevel.length > 0) {
|
||||
const firstId = serviceProducts.length > 0 ? SVC_CAT_ID : topLevel[0]?.id
|
||||
setActiveCat(firstId ?? null)
|
||||
}
|
||||
}, [topLevel.length, serviceProducts.length])
|
||||
|
||||
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(id === FAV_CAT_ID || id === SVC_CAT_ID ? {} : buildDefaultExpanded(id))
|
||||
}
|
||||
|
||||
const isSvcActive = activeCat === SVC_CAT_ID
|
||||
|
||||
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
|
||||
const directProducts = regularProducts.filter(p => p.category_id === activeCat)
|
||||
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
|
||||
|
||||
// Cart management
|
||||
function addToCart(item) {
|
||||
setCart(prev => {
|
||||
const { _key: _k, _drawerState: _ds, quantity: _q, ...newCore } = item
|
||||
const matchIdx = prev.findIndex(existing => {
|
||||
const { _key, _drawerState, quantity: _eq, ...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 handleQuickAdd(product) {
|
||||
const base = buildQuickAddItem(product)
|
||||
const item = product.is_service_item ? { ...base, is_service_item: true } : base
|
||||
addToCart(item)
|
||||
toast.success(`${product.name} προστέθηκε`, { duration: 800 })
|
||||
}
|
||||
|
||||
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
|
||||
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
|
||||
setDrawerProduct(null)
|
||||
}
|
||||
|
||||
function handleDrawerAdd(item) {
|
||||
if (editItem) {
|
||||
setCart(prev => prev.map(i => i._key === editItem.cartKey ? { ...item, _key: i._key } : i))
|
||||
setEditItem(null)
|
||||
} else {
|
||||
const finalItem = drawerProduct?.is_service_item ? { ...item, is_service_item: true } : item
|
||||
addToCart(finalItem)
|
||||
setDrawerProduct(null)
|
||||
}
|
||||
}
|
||||
|
||||
const cartTotal = cart.reduce((s, i) => s + (i.unit_price ?? 0) * i.quantity + (i.price_adjustment ?? 0) * i.quantity, 0)
|
||||
const isOpen = ['open', 'partially_paid'].includes(orderStatus)
|
||||
|
||||
const addItemsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let activeOrderId = orderId
|
||||
if (!activeOrderId) {
|
||||
const { data: newOrder } = await client.post('/api/orders/', { table_id: tableId })
|
||||
activeOrderId = newOrder.id
|
||||
}
|
||||
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
|
||||
items: cart.map(({ _key, _drawerState, ...item }) => item),
|
||||
order_note: orderNote || null,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const printResults = data.print_results ?? []
|
||||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||||
if (!allOk) {
|
||||
toast.error('Παραγγελία αποθηκεύτηκε αλλά κάποιος εκτυπωτής δεν ανταποκρίθηκε')
|
||||
} else {
|
||||
toast.success('Παραγγελία εστάλη επιτυχώς')
|
||||
}
|
||||
setCart([])
|
||||
setOrderNote('')
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
qc.invalidateQueries({ queryKey: ['orders-active'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err?.response?.data?.detail || 'Σφάλμα αποστολής παραγγελίας')
|
||||
},
|
||||
})
|
||||
|
||||
// Keyboard shortcut for search
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); setSearchOpen(true) }
|
||||
if (e.key === 'Escape') { setDrawerProduct(null); setEditItem(null); setSearchOpen(false) }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
if (productsLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
|
||||
Φόρτωση προϊόντων…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
|
||||
Η παραγγελία δεν είναι ανοιχτή.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeDrawerProduct = editItem ? editItem.product : drawerProduct
|
||||
const drawerOpen = !!activeDrawerProduct
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', minHeight: 0, gap: 0 }}>
|
||||
|
||||
{/* ── LEFT: Category + Product browser ─────────────────────────────── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, borderRight: '1px solid #e2e8f0' }}>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, background: '#f8fafc' }}>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 6, overflowX: 'auto', scrollbarWidth: 'none' }}>
|
||||
{/* SERVICE chip */}
|
||||
{serviceProducts.length > 0 && (
|
||||
<button onClick={() => selectCategory(SVC_CAT_ID)}
|
||||
style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 16, border: isSvcActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
|
||||
background: isSvcActive ? '#f59e0b' : '#fff7ed', color: isSvcActive ? '#fff' : '#d97706',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||
</svg>
|
||||
Service
|
||||
</button>
|
||||
)}
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
const bg = cat.color
|
||||
? isActive ? cat.color : hexToRgba(cat.color, 0.15)
|
||||
: isActive ? '#f59e0b' : '#f8fafc'
|
||||
const color = cat.color
|
||||
? isActive ? '#fff' : cat.color
|
||||
: isActive ? '#fff' : '#64748b'
|
||||
return (
|
||||
<button key={cat.id} onClick={() => selectCategory(cat.id)}
|
||||
style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 16,
|
||||
border: isActive && cat.color ? `2px solid ${cat.color}` : isActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
|
||||
background: bg, color,
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>{cat.name}</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => setViewAllOpen(true)}
|
||||
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
Όλες →
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setSearchOpen(true)}
|
||||
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
|
||||
<svg width="13" height="13" 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>
|
||||
</div>
|
||||
|
||||
{/* Product area */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 14px' }}>
|
||||
|
||||
{isSvcActive ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 10 }}>
|
||||
{serviceProducts.map(p => (
|
||||
<ServiceItemWidget key={p.id} product={p} customerCount={customerCount} onAdd={item => addToCart(item)} />
|
||||
))}
|
||||
</div>
|
||||
) : !hasSubcats ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{directProducts.map(p => (
|
||||
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
|
||||
))}
|
||||
</div>
|
||||
{directProducts.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '40px 0', fontSize: 14 }}>Δεν υπάρχουν προϊόντα</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sections.map(section => {
|
||||
const key = section._isGeneral ? '__general__' : String(section.id)
|
||||
const isExpanded = !!expandedSubs[key]
|
||||
const sectionProducts = section._isGeneral
|
||||
? section.products
|
||||
: regularProducts.filter(p => p.category_id === section.id)
|
||||
if (sectionProducts.length === 0) return null
|
||||
|
||||
if (section._isGeneral) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{sectionProducts.map(p => (
|
||||
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const accentColor = section.color ?? activeParent?.color ?? null
|
||||
return (
|
||||
<div key={key} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', background: isExpanded ? '#f8fafc' : 'white',
|
||||
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{accentColor && <span style={{ width: 10, height: 10, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />}
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 700, color: '#1e293b' }}>{section.name}</span>
|
||||
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>{sectionProducts.length}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${isExpanded ? 180 : 0}deg)`, transition: 'transform 180ms', color: '#94a3b8', flexShrink: 0 }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '10px 12px 12px', background: '#fafbfc', borderTop: '1px solid #f1f5f9' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{sectionProducts.map(p => (
|
||||
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: Cart panel or Drawer panel ────────────────────────────── */}
|
||||
<div style={{ width: 320, flexShrink: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{drawerOpen ? (
|
||||
<ManagerOrderDrawer
|
||||
key={activeDrawerProduct.id}
|
||||
product={activeDrawerProduct}
|
||||
initialState={editItem?.drawerState}
|
||||
onClose={() => { setDrawerProduct(null); setEditItem(null) }}
|
||||
onAdd={handleDrawerAdd}
|
||||
courses={courses}
|
||||
/>
|
||||
) : (
|
||||
/* Cart */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
|
||||
{/* Cart header */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>Νέα Παραγγελία</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>
|
||||
{cart.length === 0 ? 'Επιλέξτε προϊόντα από τα αριστερά' : `${cart.length} ${cart.length === 1 ? 'προϊόν' : 'προϊόντα'}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px' }}>
|
||||
{cart.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: '40px 0' }}>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style={{ color: '#e2e8f0' }}>
|
||||
<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="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<p style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center' }}>Το καλάθι είναι άδειο</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{cart.map(item => {
|
||||
const product = products.find(p => p.id === item.product_id)
|
||||
return (
|
||||
<CartItemRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
product={product}
|
||||
onRemove={() => removeFromCart(item._key)}
|
||||
onChangeQty={qty => changeCartQty(item._key, qty)}
|
||||
onEdit={() => openEditDrawer(item)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order note */}
|
||||
{cart.length > 0 && (
|
||||
<div style={{ padding: '8px 16px', flexShrink: 0 }}>
|
||||
<textarea
|
||||
value={orderNote}
|
||||
onChange={e => setOrderNote(e.target.value)}
|
||||
placeholder="Σημείωση παραγγελίας (προαιρετικό)…"
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%', resize: 'none', padding: '8px 10px',
|
||||
background: 'white', border: '1px solid #e2e8f0',
|
||||
borderRadius: 8, fontSize: 12, color: '#1e293b', lineHeight: 1.5,
|
||||
outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cart footer */}
|
||||
<div style={{ padding: '10px 16px 16px', borderTop: '1px solid #e2e8f0', flexShrink: 0, background: 'white' }}>
|
||||
{cart.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 13, color: '#64748b', fontWeight: 600 }}>Σύνολο</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 800, color: '#1e293b' }}>€{cartTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => addItemsMutation.mutate()}
|
||||
disabled={cart.length === 0 || addItemsMutation.isPending}
|
||||
style={{
|
||||
width: '100%', height: 44, borderRadius: 22,
|
||||
background: cart.length === 0 ? '#e2e8f0' : '#f59e0b',
|
||||
border: 'none', color: cart.length === 0 ? '#94a3b8' : '#fff',
|
||||
fontSize: 15, fontWeight: 700, fontFamily: 'inherit',
|
||||
cursor: cart.length === 0 ? 'default' : 'pointer',
|
||||
transition: 'background 150ms ease',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
}}
|
||||
>
|
||||
{addItemsMutation.isPending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||||
</button>
|
||||
{cart.length > 0 && (
|
||||
<button onClick={() => setCart([])}
|
||||
style={{ width: '100%', marginTop: 6, padding: '6px 0', background: 'none', border: 'none', color: '#94a3b8', fontSize: 12, cursor: 'pointer' }}>
|
||||
Εκκαθάριση καλαθιού
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View all categories modal */}
|
||||
{viewAllOpen && (
|
||||
<>
|
||||
<div onClick={() => setViewAllOpen(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
|
||||
<div style={{
|
||||
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 201, width: 'min(500px, 90vw)',
|
||||
background: 'white', borderRadius: 16,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: '#1e293b' }}>Κατηγορίες</span>
|
||||
<button onClick={() => setViewAllOpen(false)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{serviceProducts.length > 0 && (
|
||||
<button onClick={() => { selectCategory(SVC_CAT_ID); setViewAllOpen(false) }}
|
||||
style={{ padding: '14px 10px', borderRadius: 12, background: '#fff7ed', border: isSvcActive ? '2px solid #f59e0b' : '1px solid #fed7aa', cursor: 'pointer', fontSize: 13, fontWeight: 700, color: '#d97706', textAlign: 'center' }}>
|
||||
Service
|
||||
</button>
|
||||
)}
|
||||
{topLevel.map(cat => {
|
||||
const isActive = activeCat === cat.id
|
||||
return (
|
||||
<button key={cat.id} onClick={() => { selectCategory(cat.id); setViewAllOpen(false) }}
|
||||
style={{
|
||||
padding: '14px 10px', borderRadius: 12, cursor: 'pointer', fontSize: 13, fontWeight: 700, textAlign: 'center',
|
||||
background: cat.color || '#f1f5f9',
|
||||
color: cat.color ? '#fff' : '#475569',
|
||||
border: isActive ? '2px solid #f59e0b' : '1px solid transparent',
|
||||
boxShadow: cat.color ? 'inset 0 0 0 100px rgba(0,0,0,0.15)' : 'none',
|
||||
}}>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search modal */}
|
||||
{searchOpen && (
|
||||
<SearchOverlay
|
||||
products={products}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpen={p => { setDrawerProduct(p); setSearchOpen(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -320,8 +320,7 @@ export default function ExpensesPage() {
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Έξοδα</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>
|
||||
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,7 @@ export default function LoginPage() {
|
||||
const [loadingInit, setLoadingInit] = useState(true)
|
||||
|
||||
const [selectedManager, setSelectedManager] = useState(null)
|
||||
const [manualMode, setManualMode] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [pin, setPin] = useState('')
|
||||
@@ -125,6 +126,10 @@ export default function LoginPage() {
|
||||
const autofill = settings?.autofill_username !== false
|
||||
const singleManager = managers.length === 1 ? managers[0] : null
|
||||
|
||||
// Suppressed in manual mode so the free-text input is shown instead
|
||||
const effectiveUsername = manualMode ? null
|
||||
: (selectedManager?.username || (autofill && singleManager ? singleManager.username : null))
|
||||
|
||||
// Auto-login: none method + single manager → attempt exactly once, skip if manual logout
|
||||
useEffect(() => {
|
||||
if (!loadingInit && loginMethod === 'none' && singleManager && !manualLogout && !autoLoginAttempted.current) {
|
||||
@@ -141,11 +146,10 @@ export default function LoginPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login-no-auth', { username: uname })
|
||||
const role = data.user.role
|
||||
if (role !== 'manager' && role !== 'sysadmin') { setLoading(false); return }
|
||||
if (!data.user.perm_access_dashboard) { setLoading(false); return }
|
||||
clearManualLogoutFlag()
|
||||
login(data.user, data.access_token)
|
||||
navigate('/operations', { replace: true })
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -153,7 +157,7 @@ export default function LoginPage() {
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e?.preventDefault()
|
||||
const uname = selectedManager?.username || (autofill && singleManager ? singleManager.username : username.trim())
|
||||
const uname = effectiveUsername || username.trim()
|
||||
if (!uname) return
|
||||
if (loginMethod === 'password' && !password) return
|
||||
if (loginMethod === 'pin' && pin.length < 4) return
|
||||
@@ -166,8 +170,7 @@ export default function LoginPage() {
|
||||
else if (loginMethod === 'pin') body.pin = pin
|
||||
|
||||
const { data } = await client.post('/api/auth/login', body)
|
||||
const role = data.user.role
|
||||
if (role !== 'manager' && role !== 'sysadmin') {
|
||||
if (!data.user.perm_access_dashboard) {
|
||||
setError('This account does not have manager access.')
|
||||
setPin('')
|
||||
setPassword('')
|
||||
@@ -175,7 +178,7 @@ export default function LoginPage() {
|
||||
}
|
||||
clearManualLogoutFlag()
|
||||
login(data.user, data.access_token)
|
||||
navigate('/operations', { replace: true })
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Invalid credentials')
|
||||
setPin('')
|
||||
@@ -191,7 +194,7 @@ export default function LoginPage() {
|
||||
}, [pin])
|
||||
|
||||
// Show spinner while loading OR while auto-login is in progress (none mode, not manual logout)
|
||||
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout)) {
|
||||
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout && !manualMode)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
@@ -199,10 +202,6 @@ export default function LoginPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Determine effective username to show
|
||||
const effectiveUsername = selectedManager?.username
|
||||
|| (autofill && singleManager ? singleManager.username : null)
|
||||
|
||||
// Multi-manager + none method: show picker first
|
||||
const needsPicker = loginMethod === 'none' && managers.length > 1 && !selectedManager
|
||||
|
||||
@@ -229,56 +228,74 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username — show if not autofilled */}
|
||||
{!effectiveUsername && (
|
||||
<>
|
||||
{managers.length > 1 ? (
|
||||
<div className="space-y-2">
|
||||
{managers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setUsername(m.username)}
|
||||
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
|
||||
username === m.username
|
||||
? 'border-sky-400 bg-sky-50'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{(m.full_name || m.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
|
||||
<p className="text-[11px] text-slate-400">{m.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Username section */}
|
||||
{manualMode ? (
|
||||
<LabelledInput
|
||||
icon={User}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
) : effectiveUsername ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{effectiveUsername.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
|
||||
<p className="text-[11px] text-slate-400">Manager</p>
|
||||
</div>
|
||||
) : (
|
||||
<LabelledInput
|
||||
icon={User}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show who we're logging in as when autofilled */}
|
||||
{effectiveUsername && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{effectiveUsername.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
|
||||
<p className="text-[11px] text-slate-400">Manager</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setManualMode(true); setSelectedManager(null); setUsername(''); setPassword(''); setPin(''); setError('') }}
|
||||
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
|
||||
>
|
||||
Sign in as different account
|
||||
</button>
|
||||
</div>
|
||||
) : managers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{managers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setUsername(m.username)}
|
||||
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
|
||||
username === m.username
|
||||
? 'border-sky-400 bg-sky-50'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
|
||||
{(m.full_name || m.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
|
||||
<p className="text-[11px] text-slate-400">{m.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setManualMode(true); setUsername(''); setPassword(''); setPin(''); setError('') }}
|
||||
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
|
||||
>
|
||||
Sign in as different account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<LabelledInput
|
||||
icon={User}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password input */}
|
||||
@@ -333,6 +350,7 @@ export default function LoginPage() {
|
||||
{loading && loginMethod === 'pin' && (
|
||||
<p className="text-center text-[13px] text-slate-400">Verifying…</p>
|
||||
)}
|
||||
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
810
manager_dashboard/src/pages/Management/PrepZonesConfigPage.jsx
Normal file
810
manager_dashboard/src/pages/Management/PrepZonesConfigPage.jsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Plus, Pencil, Trash2, Printer, X, Check, ChevronDown,
|
||||
Zap, Settings2, ArrowUpDown, Layers, CheckSquare, SkipForward,
|
||||
GripVertical, ChevronUp, AlertTriangle, FlameKindling,
|
||||
} from 'lucide-react'
|
||||
import client from '../../api/client'
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchZones = () => client.get('/api/prep-zones').then(r => r.data)
|
||||
const fetchPrinters = () => client.get('/api/system/printers').then(r => r.data)
|
||||
const fetchCategories = () => client.get('/api/products/categories').then(r => r.data)
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AUTO_PRINT_OPTIONS = [
|
||||
{ value: 'none', label: 'Χωρίς εκτύπωση', desc: 'Μη αυτόματη εκτύπωση' },
|
||||
{ value: 'master', label: 'Μόνο κύριος', desc: 'Εκτύπωση στον κύριο' },
|
||||
{ value: 'all', label: 'Όλοι εκτυπωτές', desc: 'Master + δευτερεύοντες' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'order_time', label: 'Σειρά παραγγελίας', desc: 'Όπως προστέθηκαν στο καλάθι' },
|
||||
{ value: 'item_count', label: 'Πλήθος (φθίνον)', desc: 'Πρώτα τα αντικείμενα με τη μεγαλύτερη ποσότητα' },
|
||||
{ value: 'alpha', label: 'Αλφαβητικά', desc: 'Ταξινόμηση κατά όνομα' },
|
||||
]
|
||||
|
||||
const EMPTY_ZONE = {
|
||||
name: '', description: '', notification_name: '',
|
||||
printer_ids: [], master_printer_id: null,
|
||||
auto_print: 'none', master_copies: 1, secondary_copies: 1,
|
||||
sort_items_by: 'order_time', group_by_category: false, category_order: [], print_checkboxes: false,
|
||||
bypass_pending: false, bypass_kds: false, auto_ready_to_served: false,
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ value, onChange, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange(!value)}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none
|
||||
${value ? 'bg-primary-600' : 'bg-gray-200'} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform duration-200
|
||||
${value ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopySpinner({ value, onChange, min = 1, max = 9 }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
|
||||
>−</button>
|
||||
<span className="w-9 text-center text-sm font-bold text-gray-800 tabular-nums">{value}×</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
|
||||
>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Click-outside hook for dropdowns ─────────────────────────────────────────
|
||||
|
||||
function useClickOutside(ref, onClose) {
|
||||
useEffect(() => {
|
||||
function handle(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handle)
|
||||
return () => document.removeEventListener('mousedown', handle)
|
||||
}, [ref, onClose])
|
||||
}
|
||||
|
||||
// ── Master printer single-select ──────────────────────────────────────────────
|
||||
|
||||
function MasterPrinterPicker({ printers, value, onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
useClickOutside(ref, () => setOpen(false))
|
||||
const selected = printers.find(p => p.id === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-medium text-gray-800 truncate">{selected.name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono ml-auto shrink-0">{selected.ip_address}:{selected.port}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">— Χωρίς κύριο —</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(null); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${!value ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<span className="w-4 h-4 rounded border border-gray-300 flex items-center justify-center shrink-0">
|
||||
{!value && <Check size={10} className="text-gray-600" />}
|
||||
</span>
|
||||
<span className="text-gray-400 italic text-xs">Χωρίς κύριο εκτυπωτή</span>
|
||||
</button>
|
||||
{printers.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => { onChange(p.id); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${value === p.id ? 'bg-primary-50' : ''}`}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${value === p.id ? 'bg-primary-600 border-primary-600' : 'border-gray-300'}`}>
|
||||
{value === p.id && <Check size={10} className="text-white" />}
|
||||
</span>
|
||||
<Printer size={13} className="text-gray-400 shrink-0" />
|
||||
<span className={`flex-1 ${value === p.id ? 'font-semibold text-primary-700' : 'text-gray-700'}`}>{p.name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Secondary printers multi-select ──────────────────────────────────────────
|
||||
|
||||
function SecondaryPrinterPicker({ printers, selected, masterId, onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
useClickOutside(ref, () => setOpen(false))
|
||||
|
||||
const selectedSet = new Set(selected)
|
||||
const eligible = printers.filter(p => p.id !== masterId)
|
||||
const selectedPrinters = eligible.filter(p => selectedSet.has(p.id))
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(selectedSet)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
onChange([...next])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors min-h-[38px]"
|
||||
>
|
||||
<span className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||||
{selectedPrinters.length === 0 ? (
|
||||
<span className="text-gray-400">— Χωρίς δευτερεύοντες —</span>
|
||||
) : (
|
||||
selectedPrinters.map(p => (
|
||||
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
<Printer size={10} />{p.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
|
||||
{eligible.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-gray-400 text-center italic">
|
||||
{printers.length <= 1 ? 'Δεν υπάρχουν άλλοι εκτυπωτές' : 'Ο μόνος εκτυπωτής είναι ο κύριος'}
|
||||
</div>
|
||||
) : eligible.map(p => {
|
||||
const on = selectedSet.has(p.id)
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => toggle(p.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${on ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${on ? 'bg-gray-700 border-gray-700' : 'border-gray-300'}`}>
|
||||
{on && <Check size={10} className="text-white" />}
|
||||
</span>
|
||||
<Printer size={13} className="text-gray-400 shrink-0" />
|
||||
<span className={`flex-1 ${on ? 'font-semibold text-gray-800' : 'text-gray-700'}`}>{p.name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-gray-100 px-3 py-2 text-xs text-gray-400 italic">
|
||||
Κλικ έξω για κλείσιμο
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Category order modal ──────────────────────────────────────────────────────
|
||||
|
||||
function CategoryOrderModal({ categories, order, onSave, onClose }) {
|
||||
const ranked = order.map(id => categories.find(c => c.id === id)).filter(Boolean)
|
||||
const unranked = categories.filter(c => !order.includes(c.id))
|
||||
const [list, setList] = useState([...ranked, ...unranked])
|
||||
const dragIdx = useRef(null)
|
||||
|
||||
const onDragStart = (i) => { dragIdx.current = i }
|
||||
const onDragOver = (e, i) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx.current === null || dragIdx.current === i) return
|
||||
const next = [...list]
|
||||
const [moved] = next.splice(dragIdx.current, 1)
|
||||
next.splice(i, 0, moved)
|
||||
dragIdx.current = i
|
||||
setList(next)
|
||||
}
|
||||
const onDragEnd = () => { dragIdx.current = null }
|
||||
const move = (i, dir) => {
|
||||
const j = i + dir
|
||||
if (j < 0 || j >= list.length) return
|
||||
const next = [...list];
|
||||
[next[i], next[j]] = [next[j], next[i]]
|
||||
setList(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">Σειρά Κατηγοριών Εκτύπωσης</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Σύρετε για αλλαγή σειράς</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3 max-h-80 overflow-y-auto space-y-1">
|
||||
{list.map((cat, i) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(i)}
|
||||
onDragOver={e => onDragOver(e, i)}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 bg-gray-50 rounded-xl border border-gray-100 cursor-grab active:cursor-grabbing select-none group hover:bg-white hover:border-gray-200 hover:shadow-sm transition-all"
|
||||
>
|
||||
<GripVertical size={14} className="text-gray-300 group-hover:text-gray-400 shrink-0" />
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cat.color || '#94a3b8' }} />
|
||||
<span className="text-sm text-gray-700 font-medium flex-1 min-w-0 truncate">{cat.name}</span>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => move(i, -1)} disabled={i === 0}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(i, 1)} disabled={i === list.length - 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-300 font-mono w-5 text-right shrink-0">{i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
{list.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-gray-400">Δεν υπάρχουν κατηγορίες</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-gray-100 flex gap-2 justify-end">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSave(list.map(c => c.id)); onClose() }}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Check size={14} /> Αποθήκευση σειράς
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Section header ────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ icon: Icon_, label, children }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 pb-1.5 border-b border-gray-100">
|
||||
<Icon_ size={13} className="text-gray-400" />
|
||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Toggle row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ToggleRow({ label, desc, value, onChange, disabled, children, warning }) {
|
||||
return (
|
||||
<div className={`rounded-xl border transition-colors ${value ? 'border-primary-200 bg-primary-50/40' : 'border-gray-200 bg-gray-50/40'} ${disabled ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-gray-800">{label}</span>
|
||||
{warning && <AlertTriangle size={12} className="text-amber-500 shrink-0" />}
|
||||
</div>
|
||||
{desc && <p className="text-xs text-gray-500 mt-0.5 leading-relaxed">{desc}</p>}
|
||||
</div>
|
||||
<Toggle value={value} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
{value && children && (
|
||||
<div className="px-4 pb-3">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zone Form ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ZoneForm({ initial, printers, categories, onSave, onCancel, saving }) {
|
||||
const z = initial || EMPTY_ZONE
|
||||
const [name, setName] = useState(z.name)
|
||||
const [description, setDesc] = useState(z.description || '')
|
||||
const [notifName, setNotifName] = useState(z.notification_name || '')
|
||||
const [allPrinterIds, setAllPrinterIds] = useState(z.printer_ids || [])
|
||||
const [masterId, setMasterId] = useState(z.master_printer_id ?? null)
|
||||
const [autoPrint, setAutoPrint] = useState(z.auto_print || 'none')
|
||||
const [masterCopies, setMasterCopies] = useState(z.master_copies || 1)
|
||||
const [secondaryCopies, setSecondaryCopies] = useState(z.secondary_copies || 1)
|
||||
const [sortBy, setSortBy] = useState(z.sort_items_by || 'order_time')
|
||||
const [groupCat, setGroupCat] = useState(z.group_by_category || false)
|
||||
const [catOrder, setCatOrder] = useState(z.category_order || [])
|
||||
const [checkboxes, setCheckboxes] = useState(z.print_checkboxes || false)
|
||||
const [bypassPending, setBypassPending] = useState(z.bypass_pending || false)
|
||||
const [bypassKds, setBypassKds] = useState(z.bypass_kds || false)
|
||||
const [autoReadyServed, setAutoReadyServed] = useState(z.auto_ready_to_served || false)
|
||||
const [catModal, setCatModal] = useState(false)
|
||||
|
||||
const secondaryIds = allPrinterIds.filter(id => id !== masterId)
|
||||
|
||||
const handleMasterChange = (id) => {
|
||||
setMasterId(id)
|
||||
if (id && !allPrinterIds.includes(id)) setAllPrinterIds(prev => [...prev, id])
|
||||
}
|
||||
|
||||
const handleSecondaryChange = (ids) => {
|
||||
const next = new Set(ids)
|
||||
if (masterId) next.add(masterId)
|
||||
setAllPrinterIds([...next])
|
||||
}
|
||||
|
||||
const handleBypassKds = (v) => { setBypassKds(v); if (v) setBypassPending(true) }
|
||||
const handleBypassPending = (v) => { setBypassPending(v); if (!v) setBypassKds(false) }
|
||||
|
||||
const valid = name.trim().length > 0
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
notification_name: notifName.trim() || null,
|
||||
printer_ids: allPrinterIds,
|
||||
master_printer_id: masterId || null,
|
||||
auto_print: autoPrint,
|
||||
master_copies: masterCopies,
|
||||
secondary_copies: secondaryCopies,
|
||||
sort_items_by: sortBy,
|
||||
group_by_category: groupCat,
|
||||
category_order: catOrder,
|
||||
print_checkboxes: checkboxes,
|
||||
bypass_pending: bypassPending,
|
||||
bypass_kds: bypassKds,
|
||||
auto_ready_to_served: autoReadyServed,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
|
||||
{/* Identity */}
|
||||
<Section icon={Settings2} label="Στοιχεία Ζώνης">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Όνομα ζώνης *</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="π.χ. Kitchen – Grill" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Όνομα ειδοποίησης</label>
|
||||
<input className="input" value={notifName} onChange={e => setNotifName(e.target.value)}
|
||||
placeholder="π.χ. Κουζίνα" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Περιγραφή</label>
|
||||
<input className="input" value={description} onChange={e => setDesc(e.target.value)}
|
||||
placeholder="Προαιρετική περιγραφή" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Printers */}
|
||||
<Section icon={Printer} label="Εκτυπωτές">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Κύριος εκτυπωτής</label>
|
||||
<MasterPrinterPicker printers={printers} value={masterId} onChange={handleMasterChange} />
|
||||
<p className="mt-1 text-xs text-gray-400">Ένας μόνο, υψηλή προτεραιότητα.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Δευτερεύοντες εκτυπωτές</label>
|
||||
<SecondaryPrinterPicker
|
||||
printers={printers} selected={secondaryIds}
|
||||
masterId={masterId} onChange={handleSecondaryChange}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">Μόνο στη λειτουργία «Όλοι».</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Αυτόματη εκτύπωση</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{AUTO_PRINT_OPTIONS.map(opt => (
|
||||
<button key={opt.value} type="button" onClick={() => setAutoPrint(opt.value)}
|
||||
className={`px-3 py-2.5 rounded-xl border text-left transition-all
|
||||
${autoPrint === opt.value
|
||||
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
|
||||
<div className={`text-xs font-bold leading-tight ${autoPrint === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
|
||||
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoPrint !== 'none' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Αντίτυπα κύριου</label>
|
||||
<CopySpinner value={masterCopies} onChange={setMasterCopies} />
|
||||
</div>
|
||||
{autoPrint === 'all' && (
|
||||
<div>
|
||||
<label className="label">Αντίτυπα δευτερευόντων</label>
|
||||
<CopySpinner value={secondaryCopies} onChange={setSecondaryCopies} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Ticket formatting */}
|
||||
<Section icon={ArrowUpDown} label="Μορφοποίηση Δελτίου">
|
||||
<div>
|
||||
<label className="label">Ταξινόμηση αντικειμένων</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
<button key={opt.value} type="button" onClick={() => setSortBy(opt.value)}
|
||||
className={`px-3 py-2.5 rounded-xl border text-left transition-all
|
||||
${sortBy === opt.value
|
||||
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
|
||||
<div className={`text-xs font-bold leading-tight ${sortBy === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
|
||||
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ToggleRow
|
||||
label="Ομαδοποίηση κατά κατηγορία"
|
||||
desc="Αντικείμενα ομαδοποιούνται ανά κατηγορία."
|
||||
value={groupCat} onChange={setGroupCat}
|
||||
>
|
||||
<button type="button" onClick={() => setCatModal(true)}
|
||||
className="mt-1 inline-flex items-center gap-1.5 text-xs font-semibold text-primary-600 hover:text-primary-700 bg-white border border-primary-200 rounded-lg px-3 py-1.5 hover:bg-primary-50 transition-colors">
|
||||
<Layers size={12} />
|
||||
Σειρά κατηγοριών
|
||||
{catOrder.length > 0 && (
|
||||
<span className="bg-primary-100 text-primary-700 rounded px-1 text-[10px] font-bold">{catOrder.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</ToggleRow>
|
||||
|
||||
<ToggleRow
|
||||
label="Checkboxes"
|
||||
desc="Προσθέτει [ ] πριν από κάθε αντικείμενο."
|
||||
value={checkboxes} onChange={setCheckboxes}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* KDS Behavior */}
|
||||
<Section icon={Zap} label="Συμπεριφορά KDS">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<ToggleRow
|
||||
label="Παράκαμψη Pending & Prep"
|
||||
desc="Αντικείμενα ξεκινούν απευθείας σε READY. Χρήσιμο για ποτά ή μπύρες."
|
||||
value={bypassPending} onChange={handleBypassPending}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Παράκαμψη KDS Status"
|
||||
desc="Αντικείμενα πηγαίνουν κατευθείαν σε SERVED. Ενεργοποιεί αυτόματα και την Παράκαμψη Pending."
|
||||
value={bypassKds} onChange={handleBypassKds} warning
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Αυτόματο Ready → Served"
|
||||
desc="Όταν ο σεφ επισημαίνει READY στο KDS, αυτόματα αναβαθμίζεται και σε SERVED."
|
||||
value={autoReadyServed} onChange={setAutoReadyServed}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end pt-3 border-t border-gray-100">
|
||||
<button type="button" onClick={onCancel} className="btn btn-secondary">
|
||||
Ακύρωση
|
||||
</button>
|
||||
<button type="submit" disabled={!valid || saving} className="btn btn-primary flex items-center gap-2">
|
||||
<Check size={15} />
|
||||
{saving ? 'Αποθήκευση…' : 'Αποθήκευση'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{catModal && (
|
||||
<CategoryOrderModal
|
||||
categories={categories} order={catOrder}
|
||||
onSave={setCatOrder} onClose={() => setCatModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zone Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Badge({ children, variant = 'default' }) {
|
||||
const cls = {
|
||||
default: 'bg-gray-100 text-gray-600',
|
||||
primary: 'bg-primary-100 text-primary-700',
|
||||
green: 'bg-green-100 text-green-700',
|
||||
amber: 'bg-amber-100 text-amber-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
}[variant] || 'bg-gray-100 text-gray-600'
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold ${cls}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ZoneCard({ zone, printers, onEdit, onDelete }) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const printerMap = Object.fromEntries(printers.map(p => [p.id, p]))
|
||||
const masterPrinter = printerMap[zone.master_printer_id]
|
||||
const secondaryPrinters = (zone.secondary_printer_ids || []).map(id => printerMap[id]).filter(Boolean)
|
||||
|
||||
const printLabel = {
|
||||
none: { text: 'Χωρίς εκτύπωση', variant: 'default' },
|
||||
master: { text: 'Κύριος μόνο', variant: 'primary' },
|
||||
all: { text: 'Όλοι', variant: 'primary' },
|
||||
}[zone.auto_print] || { text: '—', variant: 'default' }
|
||||
|
||||
const sortLabel = {
|
||||
order_time: 'Σειρά παρ.',
|
||||
item_count: 'Κατά πλήθος',
|
||||
alpha: 'Αλφαβητικά',
|
||||
}[zone.sort_items_by] || zone.sort_items_by
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden hover:shadow-md hover:border-gray-300 transition-all group">
|
||||
<div className="px-4 pt-4 pb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-gray-900 text-sm leading-tight">{zone.name}</h3>
|
||||
{zone.bypass_kds && <Badge variant="red"><Zap size={10} />Bypass KDS</Badge>}
|
||||
{!zone.bypass_kds && zone.bypass_pending && <Badge variant="amber"><SkipForward size={10} />Bypass Pending</Badge>}
|
||||
{zone.auto_ready_to_served && <Badge variant="green"><Check size={10} />Auto Served</Badge>}
|
||||
</div>
|
||||
{zone.notification_name && (
|
||||
<p className="text-xs text-primary-600 font-medium mt-0.5">{zone.notification_name}</p>
|
||||
)}
|
||||
{zone.description && <p className="text-xs text-gray-400 mt-0.5">{zone.description}</p>}
|
||||
</div>
|
||||
<div className="flex gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => onEdit(zone)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{!confirmDelete ? (
|
||||
<button onClick={() => setConfirmDelete(true)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1 items-center">
|
||||
<button onClick={() => setConfirmDelete(false)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:bg-gray-100 transition-colors">
|
||||
<X size={13} />
|
||||
</button>
|
||||
<button onClick={() => onDelete(zone.id)}
|
||||
className="px-2 py-1 rounded-lg text-xs font-bold bg-red-600 text-white hover:bg-red-700 transition-colors">
|
||||
Διαγραφή
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Κύριος</span>
|
||||
{masterPrinter ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 bg-primary-50 text-primary-700 rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
<Printer size={10} />{masterPrinter.name}
|
||||
</span>
|
||||
{zone.auto_print !== 'none' && (
|
||||
<span className="text-xs text-gray-400 font-mono">{zone.master_copies}×</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 italic">Χωρίς</span>
|
||||
)}
|
||||
</div>
|
||||
{secondaryPrinters.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Δευτ.</span>
|
||||
{secondaryPrinters.map(p => (
|
||||
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
<Printer size={10} />{p.name}
|
||||
</span>
|
||||
))}
|
||||
{zone.auto_print === 'all' && (
|
||||
<span className="text-xs text-gray-400 font-mono">{zone.secondary_copies}×</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-gray-100 bg-gray-50/60 flex flex-wrap gap-1.5 items-center">
|
||||
<Badge variant={zone.auto_print !== 'none' ? 'primary' : 'default'}>{printLabel.text}</Badge>
|
||||
<Badge>{sortLabel}</Badge>
|
||||
{zone.group_by_category && <Badge><Layers size={10} />Κατά κατηγορία</Badge>}
|
||||
{zone.print_checkboxes && <Badge><CheckSquare size={10} />Checkboxes</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PrepZonesConfigPage() {
|
||||
const qc = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
const { data: zones = [], isLoading: lZ } = useQuery({ queryKey: ['prep-zones'], queryFn: fetchZones })
|
||||
const { data: printers = [], isLoading: lP } = useQuery({ queryKey: ['printers'], queryFn: fetchPrinters })
|
||||
const { data: categories = [], isLoading: lC } = useQuery({ queryKey: ['categories'], queryFn: fetchCategories })
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['prep-zones'] })
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: body => client.post('/api/prep-zones', body).then(r => r.data),
|
||||
onSuccess: () => { invalidate(); setShowForm(false); toast.success('Ζώνη δημιουργήθηκε') },
|
||||
onError: () => toast.error('Σφάλμα κατά τη δημιουργία'),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, ...body }) => client.put(`/api/prep-zones/${id}`, body).then(r => r.data),
|
||||
onSuccess: () => { invalidate(); setEditing(null); toast.success('Ζώνη ενημερώθηκε') },
|
||||
onError: () => toast.error('Σφάλμα κατά την ενημέρωση'),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: id => client.delete(`/api/prep-zones/${id}`),
|
||||
onSuccess: () => { invalidate(); toast.success('Ζώνη διαγράφηκε') },
|
||||
onError: () => toast.error('Σφάλμα κατά τη διαγραφή'),
|
||||
})
|
||||
|
||||
if (lZ || lP || lC) {
|
||||
return (
|
||||
<div className="p-8 flex items-center gap-3 text-sm text-gray-400">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-primary-500 animate-spin" />
|
||||
Φόρτωση…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const flatCategories = categories.flatMap(c => [c, ...(c.subcategories || [])])
|
||||
const formOpen = showForm || !!editing
|
||||
|
||||
return (
|
||||
// h-full + overflow-y-auto makes this page scroll within the fixed AppLayout <main>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-orange-100 flex items-center justify-center shrink-0">
|
||||
<FlameKindling size={16} className="text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 leading-tight">Ζώνες Προετοιμασίας</h1>
|
||||
<p className="text-sm text-gray-500">Ζώνες, εκτυπωτές και συμπεριφορά KDS</p>
|
||||
</div>
|
||||
</div>
|
||||
{!formOpen && (
|
||||
<button onClick={() => setShowForm(true)} className="btn btn-primary flex items-center gap-2 shrink-0">
|
||||
<Plus size={15} />
|
||||
Νέα Ζώνη
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two-column layout when form is open */}
|
||||
{formOpen ? (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 items-start xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
{/* Form panel */}
|
||||
<div className={`bg-white rounded-2xl p-6 shadow-sm border ${showForm ? 'border-primary-200' : 'border-amber-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className={`w-1 h-5 rounded-full ${showForm ? 'bg-primary-500' : 'bg-amber-400'}`} />
|
||||
<h2 className="text-sm font-bold text-gray-800">
|
||||
{showForm ? 'Νέα Ζώνη Προετοιμασίας' : `Επεξεργασία: ${editing?.name}`}
|
||||
</h2>
|
||||
</div>
|
||||
<ZoneForm
|
||||
initial={editing}
|
||||
printers={printers}
|
||||
categories={flatCategories}
|
||||
onSave={showForm
|
||||
? (body) => create.mutate(body)
|
||||
: (body) => update.mutate({ id: editing.id, ...body })}
|
||||
onCancel={() => { setShowForm(false); setEditing(null) }}
|
||||
saving={create.isPending || update.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zone list sidebar */}
|
||||
<div className="space-y-3">
|
||||
{zones.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400 bg-white rounded-2xl border border-gray-200">
|
||||
<FlameKindling size={24} className="mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">Δεν υπάρχουν ζώνες ακόμα</p>
|
||||
</div>
|
||||
) : zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
printers={printers}
|
||||
onEdit={z => { setShowForm(false); setEditing(z) }}
|
||||
onDelete={id => remove.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Full-width zone grid when no form open */
|
||||
zones.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-100 flex items-center justify-center">
|
||||
<FlameKindling size={28} className="text-gray-300" />
|
||||
</div>
|
||||
<div className="font-semibold text-gray-600 mb-1">Δεν υπάρχουν ζώνες ακόμα</div>
|
||||
<p className="text-sm">Δημιουργήστε την πρώτη ζώνη για να αρχίσετε.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
printers={printers}
|
||||
onEdit={z => { setShowForm(false); setEditing(z) }}
|
||||
onDelete={id => remove.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
manager_dashboard/src/pages/Management/PricingPage.jsx
Normal file
43
manager_dashboard/src/pages/Management/PricingPage.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { Percent, Tag, Gift, Users } from 'lucide-react'
|
||||
import { TabGroup, TabCard } from '../../ui/Tabs'
|
||||
import PriceGroupsTab from './pricing/PriceGroupsTab'
|
||||
import ModifiersTab from './pricing/ModifiersTab'
|
||||
import DealsTab from './pricing/DealsTab'
|
||||
import DiscountSettingsTab from './pricing/DiscountSettingsTab'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'groups', label: 'Ομάδες Τιμών', icon: Tag },
|
||||
{ id: 'modifiers', label: 'Τροποποιητές Τιμής', icon: Percent },
|
||||
{ id: 'deals', label: 'Προσφορές', icon: Gift },
|
||||
{ id: 'discounts', label: 'Ρυθμίσεις Εκπτώσεων', icon: Users },
|
||||
]
|
||||
|
||||
export default function PricingPage() {
|
||||
const [tab, setTab] = useState('groups')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 p-6 gap-4">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-9 h-9 rounded-xl bg-sky-100 flex items-center justify-center shrink-0">
|
||||
<Percent className="w-5 h-5 text-sky-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold text-slate-900">Προσφορές & Τιμές</h1>
|
||||
<p className="text-[12px] text-slate-500">Δυναμικές τιμές, τροποποιητές, προσφορές και εκπτώσεις</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabCard className="flex-1">
|
||||
<TabGroup tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === 'groups' && <PriceGroupsTab />}
|
||||
{tab === 'modifiers' && <ModifiersTab />}
|
||||
{tab === 'deals' && <DealsTab />}
|
||||
{tab === 'discounts' && <DiscountSettingsTab />}
|
||||
</div>
|
||||
</TabCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../api/client'
|
||||
|
||||
@@ -76,7 +76,7 @@ function PriceInput({ value, onChange, placeholder, className = '', allowNegativ
|
||||
)
|
||||
}
|
||||
|
||||
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
|
||||
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel, showMultiple = false }) {
|
||||
if (!subChoices || subChoices.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-indigo-50/40 px-3 py-2 space-y-2">
|
||||
@@ -84,14 +84,28 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
|
||||
Υπο-επιλογές του «{parentLabel || '…'}»
|
||||
</p>
|
||||
{subChoices.map((sc, sci) => (
|
||||
<div key={sci} className="flex items-center gap-2 ml-4">
|
||||
<div key={sci} className="flex items-center gap-2 ml-4 flex-wrap">
|
||||
<ReorderBtns onUp={() => onMove(sci, -1)} onDown={() => onMove(sci, 1)}
|
||||
disableUp={sci === 0} disableDown={sci === subChoices.length - 1} />
|
||||
<DefaultBtn isDefault={sc.is_default} onClick={() => onToggleDefault(sci)} />
|
||||
<input className="input flex-1 text-sm" placeholder="π.χ. Καραμέλα"
|
||||
<input className="input flex-1 min-w-28 text-sm" placeholder="π.χ. Καραμέλα"
|
||||
value={sc.name} onChange={e => onChange(sci, 'name', e.target.value)} />
|
||||
<PriceInput value={sc.extra_cost} onChange={v => onChange(sci, 'extra_cost', v)}
|
||||
allowNegative className="w-28 text-sm" />
|
||||
{showMultiple && (
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer shrink-0 select-none">
|
||||
<input type="checkbox" checked={sc.allow_multiple ?? false}
|
||||
onChange={e => onChange(sci, 'allow_multiple', e.target.checked)}
|
||||
className="accent-primary-700 w-3.5 h-3.5" />
|
||||
Πολλαπλά
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: sc.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={sc.is_compact ?? false}
|
||||
onChange={e => onChange(sci, 'is_compact', e.target.checked)}
|
||||
className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
|
||||
Compact
|
||||
</label>
|
||||
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0">✕</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -102,6 +116,40 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
|
||||
)
|
||||
}
|
||||
|
||||
// ── Group helpers ─────────────────────────────────────────────────────────────
|
||||
function GroupHeader({ group, onRename, onDelete, collapsed, onToggle }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [val, setVal] = useState(group.name)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
|
||||
<button type="button" onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 px-3 py-2 rounded-lg bg-indigo-50 border border-indigo-200 text-indigo-700 font-semibold text-sm text-left hover:bg-indigo-100 transition-colors">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 180ms', flexShrink: 0 }}>
|
||||
<path d="M6 9L12 15L18 9" strokeLinecap="round"/>
|
||||
</svg>
|
||||
{editing ? (
|
||||
<input
|
||||
className="input flex-1 text-sm py-0 h-6 min-w-0"
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
onBlur={() => { onRename(val); setEditing(false) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{group.name || '(αχρησιμοποίητη ομάδα)'}</span>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setVal(group.name); setEditing(true) }}
|
||||
className="w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 flex items-center justify-center text-xs">✎</button>
|
||||
<button type="button" onClick={onDelete}
|
||||
className="w-7 h-7 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 flex items-center justify-center text-xs">✕</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Form builder ──────────────────────────────────────────────────────────────
|
||||
function buildFormFromProduct(product) {
|
||||
return {
|
||||
@@ -109,9 +157,10 @@ function buildFormFromProduct(product) {
|
||||
description: product.description || '',
|
||||
category_id: product.category_id ?? '',
|
||||
base_price: product.base_price ?? '',
|
||||
unit_type: product.unit_type ?? 'piece',
|
||||
is_available: product.is_available ?? true,
|
||||
lifecycle_status: product.lifecycle_status ?? 'active',
|
||||
printer_zone_id: product.printer_zone_id ?? '',
|
||||
prep_zone_ids: product.prep_zone_ids ?? [],
|
||||
quick_options: product.quick_options?.map(q => ({
|
||||
name: q.name, price: q.price ?? 0, allow_multiple: q.allow_multiple ?? false,
|
||||
sort_order: q.sort_order ?? 0, is_favorite: q.is_favorite ?? false,
|
||||
@@ -119,25 +168,32 @@ function buildFormFromProduct(product) {
|
||||
})) ?? [],
|
||||
options: product.options?.map(o => ({
|
||||
name: o.name, extra_cost: o.extra_cost ?? 0, allow_multiple: o.allow_multiple ?? false,
|
||||
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
|
||||
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
|
||||
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })) ?? [],
|
||||
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
|
||||
group_id: o.group_id ?? null,
|
||||
})) ?? [],
|
||||
ingredients: product.ingredients?.map(i => ({
|
||||
name: i.name, extra_cost: i.extra_cost ?? 0,
|
||||
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
|
||||
is_compact: i.is_compact ?? false, group_id: i.group_id ?? null,
|
||||
})) ?? [],
|
||||
preference_sets: product.preference_sets?.map(ps => ({
|
||||
name: ps.name,
|
||||
default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1,
|
||||
choices: ps.choices?.map(c => ({
|
||||
name: c.name, extra_cost: c.extra_cost ?? 0, disables_subset: c.disables_subset ?? false,
|
||||
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
|
||||
is_compact: c.is_compact ?? false,
|
||||
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, is_compact: s.is_compact ?? false })) ?? [],
|
||||
})) ?? [],
|
||||
group_id: ps.group_id ?? null,
|
||||
shared_subset: ps.shared_subset ? {
|
||||
name: ps.shared_subset.name,
|
||||
choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
|
||||
} : null,
|
||||
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
||||
allow_multi_select: ps.allow_multi_select ?? false,
|
||||
allow_choice_quantity: ps.allow_choice_quantity ?? false,
|
||||
})) ?? [],
|
||||
digital_visible: product.digital_visible ?? true,
|
||||
digital_available: product.digital_available ?? true,
|
||||
@@ -152,6 +208,18 @@ function buildFormFromProduct(product) {
|
||||
cost_breakdown: product.cost_breakdown?.length
|
||||
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
|
||||
: [],
|
||||
// Quick Add
|
||||
quick_add_enabled: product.quick_add_enabled ?? true,
|
||||
// Tags
|
||||
tags: product.tags ?? [],
|
||||
tagsInput: '',
|
||||
// Service items
|
||||
is_service_item: product.is_service_item ?? false,
|
||||
// Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: product.fiscal_name ?? '',
|
||||
fiscal_vat_group_id: product.fiscal_vat_group_id ?? null,
|
||||
// Modifier groups (folders) — keyed by _tempId in form, id from server
|
||||
modifier_groups: (product.modifier_groups || []).map(g => ({ _tempId: g.id, id: g.id, modifier_type: g.modifier_type, name: g.name, sort_order: g.sort_order })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +257,7 @@ function getItemTypeLabel(type) {
|
||||
}
|
||||
|
||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
|
||||
export default function ProductFormModal({ product, categories, printers, prepZones = [], onSave, onCopy, onClose }) {
|
||||
const [form, setForm] = useState(() => buildFormFromProduct(product))
|
||||
const [activeTab, setActiveTab] = useState('favorites')
|
||||
const [leftTab, setLeftTab] = useState('info')
|
||||
@@ -197,6 +265,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Fetch fiscal VAT groups from settings (needed for the Ταμειακή tab dropdown)
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const fiscalEnabled = settingsData?.['fiscal.enabled']?.value === 'true'
|
||||
const vatGroups = (() => {
|
||||
try { return JSON.parse(settingsData?.['fiscal.vat_groups']?.value || '[]') } catch { return [] }
|
||||
})()
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
@@ -251,14 +330,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
|
||||
|
||||
// Options
|
||||
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
|
||||
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, multi_select: false, is_compact: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
|
||||
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
|
||||
function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
|
||||
function moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) }
|
||||
|
||||
function addOptionSubChoice(oi) {
|
||||
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
|
||||
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
|
||||
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false, allow_multiple: false, is_compact: false }] }
|
||||
)}))
|
||||
}
|
||||
function removeOptionSubChoice(oi, sci) {
|
||||
@@ -287,14 +366,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
}
|
||||
|
||||
// Ingredients
|
||||
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) }
|
||||
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
|
||||
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
|
||||
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
|
||||
function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) }
|
||||
|
||||
// Preference sets
|
||||
function addPrefSet() {
|
||||
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
|
||||
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0, allow_multi_select: false, allow_choice_quantity: false }] }))
|
||||
setActiveTab(form.preference_sets.length)
|
||||
}
|
||||
function removePrefSet(si) {
|
||||
@@ -306,7 +385,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
}
|
||||
function addChoice(si) {
|
||||
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
|
||||
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, sub_choices: [] }] } : ps
|
||||
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, is_compact: false, sub_choices: [] }] } : ps
|
||||
)}))
|
||||
}
|
||||
function removeChoice(si, ci) {
|
||||
@@ -341,7 +420,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
function addSubChoice(si, ci) {
|
||||
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
|
||||
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
|
||||
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
|
||||
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false, is_compact: false }] }
|
||||
)}
|
||||
)}))
|
||||
}
|
||||
@@ -405,27 +484,60 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
)}))
|
||||
}
|
||||
|
||||
// Modifier groups
|
||||
const _nextTempId = useRef(-1)
|
||||
function addModifierGroup(modifier_type) {
|
||||
const _tempId = _nextTempId.current--
|
||||
setForm(f => ({ ...f, modifier_groups: [...f.modifier_groups, { _tempId, id: null, modifier_type, name: 'Νέα ομάδα', sort_order: f.modifier_groups.length }] }))
|
||||
}
|
||||
function renameModifierGroup(_tempId, name) {
|
||||
setForm(f => ({ ...f, modifier_groups: f.modifier_groups.map(g => g._tempId === _tempId ? { ...g, name } : g) }))
|
||||
}
|
||||
function deleteModifierGroup(_tempId) {
|
||||
setForm(f => {
|
||||
const next = { ...f, modifier_groups: f.modifier_groups.filter(g => g._tempId !== _tempId) }
|
||||
// Un-assign all items that belonged to this group
|
||||
next.options = f.options.map(o => o.group_id === _tempId ? { ...o, group_id: null } : o)
|
||||
next.ingredients = f.ingredients.map(i => i.group_id === _tempId ? { ...i, group_id: null } : i)
|
||||
next.preference_sets = f.preference_sets.map(ps => ps.group_id === _tempId ? { ...ps, group_id: null } : ps)
|
||||
return next
|
||||
})
|
||||
}
|
||||
function setItemGroup(listKey, idx, _tempId) {
|
||||
setForm(f => ({ ...f, [listKey]: f[listKey].map((item, i) => i === idx ? { ...item, group_id: _tempId } : item) }))
|
||||
}
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({})
|
||||
function toggleGroupCollapsed(_tempId) {
|
||||
setCollapsedGroups(s => ({ ...s, [_tempId]: !s[_tempId] }))
|
||||
}
|
||||
|
||||
function buildBody() {
|
||||
return {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
category_id: form.category_id ? Number(form.category_id) : null,
|
||||
base_price: parseFloat(form.base_price),
|
||||
base_price: form.base_price !== '' && form.base_price !== null && form.base_price !== undefined ? parseFloat(form.base_price) : 0,
|
||||
unit_type: form.unit_type || 'piece',
|
||||
is_available: form.is_available,
|
||||
lifecycle_status: form.lifecycle_status,
|
||||
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
|
||||
prep_zone_ids: form.prep_zone_ids,
|
||||
quick_options: form.quick_options.map((q, i) => ({
|
||||
name: q.name, price: parseFloat(q.price) || 0, allow_multiple: q.allow_multiple ?? false,
|
||||
sort_order: i, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false,
|
||||
})),
|
||||
modifier_groups: form.modifier_groups.map((g, gi) => ({ modifier_type: g.modifier_type, name: g.name, sort_order: gi })),
|
||||
options: form.options.map(o => ({
|
||||
name: o.name, extra_cost: parseFloat(o.extra_cost) || 0, allow_multiple: o.allow_multiple ?? false,
|
||||
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
|
||||
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
|
||||
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })),
|
||||
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
|
||||
group_id: o.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === o.group_id) : null,
|
||||
})),
|
||||
ingredients: form.ingredients.map(i => ({
|
||||
name: i.name, extra_cost: parseFloat(i.extra_cost) || 0,
|
||||
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
|
||||
is_compact: i.is_compact ?? false,
|
||||
group_id: i.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === i.group_id) : null,
|
||||
})),
|
||||
preference_sets: form.preference_sets.map(ps => ({
|
||||
name: ps.name,
|
||||
@@ -436,9 +548,13 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
} : null,
|
||||
choices: ps.choices.map(c => ({
|
||||
name: c.name, extra_cost: parseFloat(c.extra_cost) || 0, disables_subset: c.disables_subset ?? false,
|
||||
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
|
||||
is_compact: c.is_compact ?? false,
|
||||
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false, is_compact: s.is_compact ?? false })),
|
||||
})),
|
||||
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
|
||||
group_id: ps.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === ps.group_id) : null,
|
||||
allow_multi_select: ps.allow_multi_select ?? false,
|
||||
allow_choice_quantity: ps.allow_choice_quantity ?? false,
|
||||
})),
|
||||
digital_visible: form.digital_visible,
|
||||
digital_available: form.digital_available,
|
||||
@@ -452,6 +568,15 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
|
||||
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
|
||||
: null,
|
||||
// Quick Add
|
||||
quick_add_enabled: form.quick_add_enabled,
|
||||
// Tags
|
||||
tags: form.tags || [],
|
||||
// Service items
|
||||
is_service_item: form.is_service_item ?? false,
|
||||
// Fiscal printer (ΦΗΜ)
|
||||
fiscal_name: form.fiscal_name?.trim() || null,
|
||||
fiscal_vat_group_id: form.fiscal_vat_group_id ? parseInt(form.fiscal_vat_group_id, 10) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,8 +606,9 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
}
|
||||
|
||||
const isNew = !product.id
|
||||
const canSave = form.name.trim() && form.base_price
|
||||
const canSave = form.name.trim() && (form.is_service_item || (form.base_price !== '' && form.base_price !== null && form.base_price !== undefined))
|
||||
const favCount = buildFavoritesList(form).length
|
||||
const prefGroups = form.modifier_groups.filter(g => g.modifier_type === 'preference')
|
||||
const tabs = [
|
||||
{ key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
|
||||
{ key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
|
||||
@@ -490,6 +616,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
{ key: 'options', label: 'Έξτρα', count: form.options.length },
|
||||
...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })),
|
||||
{ key: '__add_pref__', label: '+ Προτίμηση', isAdd: true },
|
||||
{ key: '__add_pref_group__', label: '+ Ομάδα', isAddGroup: true },
|
||||
]
|
||||
const favList = buildFavoritesList(form)
|
||||
|
||||
@@ -508,14 +635,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
{/* Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* LEFT: tabbed product info — 30% width */}
|
||||
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: '25%' }}>
|
||||
{/* LEFT: tabbed product info — 25% normally, 100% for service items */}
|
||||
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: form.is_service_item ? '100%' : '25%' }}>
|
||||
{/* Left tab bar */}
|
||||
<div className="flex border-b border-gray-200 shrink-0 bg-white">
|
||||
{[
|
||||
{ key: 'info', label: 'Πληροφορίες' },
|
||||
{ key: 'digital', label: 'Digital Menu' },
|
||||
{ key: 'cost', label: 'Κόστος' },
|
||||
...(!form.is_service_item ? [
|
||||
{ key: 'digital', label: 'Digital Menu' },
|
||||
{ key: 'cost', label: 'Κόστος' },
|
||||
...(fiscalEnabled ? [{ key: 'fiscal', label: 'Ταμειακή' }] : []),
|
||||
] : []),
|
||||
].map(t => (
|
||||
<button key={t.key} onClick={() => setLeftTab(t.key)}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
@@ -533,12 +663,36 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
|
||||
{/* Tab 1: Main Info */}
|
||||
{leftTab === 'info' && (<>
|
||||
<div>
|
||||
<label className="label">Όνομα *</label>
|
||||
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
|
||||
</div>
|
||||
{/* Service Item toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField('is_service_item', !form.is_service_item)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors w-full ${
|
||||
form.is_service_item
|
||||
? 'bg-amber-50 border-amber-400 text-amber-800'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||
</svg>
|
||||
{form.is_service_item ? 'Service Item' : 'Κανονικό προϊόν'}
|
||||
</button>
|
||||
|
||||
{form.is_service_item && (
|
||||
<p className="text-xs text-amber-700 -mt-1 px-1">
|
||||
{form.base_price && parseFloat(form.base_price) > 0
|
||||
? 'Τυπώνεται σε ξεχωριστή ενότητα SERVICE. Καταγράφεται στα έσοδα.'
|
||||
: 'Τυπώνεται μία φορά — δεν αποθηκεύεται στην παραγγελία, δεν καταγράφεται στα έσοδα.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα *</label>
|
||||
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder={form.is_service_item ? 'π.χ. Σερβίτσιο' : 'π.χ. Espresso'} />
|
||||
</div>
|
||||
|
||||
{!form.is_service_item && <div>
|
||||
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
@@ -549,15 +703,68 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
style={{ lineHeight: 1.5, fontSize: 13 }}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{!form.is_service_item && <div>
|
||||
<label className="label">Tags <span className="text-gray-400 font-normal normal-case">(προαιρετικά)</span></label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-1.5">
|
||||
{(form.tags || []).map((tag, ti) => (
|
||||
<span key={ti} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-xs font-medium text-indigo-700">
|
||||
{tag}
|
||||
<button type="button" onClick={() => setField('tags', form.tags.filter((_, i) => i !== ti))}
|
||||
className="hover:text-indigo-900 leading-none">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="input text-sm"
|
||||
value={form.tagsInput || ''}
|
||||
onChange={e => setField('tagsInput', e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.key === 'Enter' || e.key === ',') && form.tagsInput?.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = form.tagsInput.trim().toLowerCase().replace(/,/g, '')
|
||||
if (tag && !(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
|
||||
setField('tagsInput', '')
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (form.tagsInput?.trim()) {
|
||||
const tag = form.tagsInput.trim().toLowerCase()
|
||||
if (!(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
|
||||
setField('tagsInput', '')
|
||||
}
|
||||
}}
|
||||
placeholder="π.χ. vegan, gluten-free — Enter ή κόμμα"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Πάτα Enter ή κόμμα για να προσθέσεις tag.</p>
|
||||
</div>}
|
||||
|
||||
{!form.is_service_item && <div>
|
||||
<label className="label">Μονάδα μέτρησης</label>
|
||||
<select className="input" value={form.unit_type} onChange={e => setField('unit_type', e.target.value)}>
|
||||
<option value="piece">Τεμάχιο (pc)</option>
|
||||
<option value="portion">Μερίδα (portion)</option>
|
||||
<option value="kg">Κιλό (kg)</option>
|
||||
<option value="liter">Λίτρο (L)</option>
|
||||
<option value="gram">Γραμμάριο (g)</option>
|
||||
<option value="ml">Χιλιοστόλιτρο (mL)</option>
|
||||
</select>
|
||||
{(form.unit_type === 'kg' || form.unit_type === 'liter' || form.unit_type === 'gram' || form.unit_type === 'ml') && (
|
||||
<p className="text-xs text-indigo-600 mt-1">Η ποσότητα θα εισάγεται ως δεκαδικός αριθμός στην εφαρμογή σερβιτόρων.</p>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
<div>
|
||||
<label className="label">Βασική Τιμή (€) *</label>
|
||||
<label className="label">{form.is_service_item ? 'Τιμή (€) — προαιρετική' : 'Βασική Τιμή (€) *'}</label>
|
||||
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Κατηγορία</label>
|
||||
<label className="label">
|
||||
Κατηγορία
|
||||
{form.is_service_item && <span className="text-gray-400 font-normal normal-case ml-1">(προαιρετική — εμφανίζεται και εκεί)</span>}
|
||||
</label>
|
||||
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
||||
<option value="">— Χωρίς κατηγορία —</option>
|
||||
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
|
||||
@@ -571,23 +778,56 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Ζώνη εκτυπωτή</label>
|
||||
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
|
||||
<option value="">— Χωρίς εκτυπωτή —</option>
|
||||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<label className="label">Ζώνη Προετοιμασίας</label>
|
||||
<div className="flex flex-col gap-1.5 mt-1">
|
||||
{prepZones.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">Δεν υπάρχουν ζώνες. <a href="/management/prep-zones" className="underline text-primary-600">Δημιουργήστε μια ζώνη</a>.</p>
|
||||
) : (
|
||||
prepZones.map(z => {
|
||||
const selected = form.prep_zone_ids.includes(z.id)
|
||||
return (
|
||||
<label key={z.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border cursor-pointer transition-colors select-none text-sm ${selected ? 'border-primary-300 bg-primary-50 text-primary-700' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
const ids = form.prep_zone_ids.includes(z.id)
|
||||
? form.prep_zone_ids.filter(id => id !== z.id)
|
||||
: [...form.prep_zone_ids, z.id]
|
||||
setField('prep_zone_ids', ids)
|
||||
}}
|
||||
className="w-3.5 h-3.5 accent-primary-600 shrink-0"
|
||||
/>
|
||||
<span className="font-medium">{z.name}</span>
|
||||
{z.description && <span className="text-xs text-gray-400 ml-1">— {z.description}</span>}
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => setField('is_available', !form.is_available)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||||
}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
||||
</button>
|
||||
{!form.is_service_item && <div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onClick={() => setField('is_available', !form.is_available)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||||
}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<button type="button" onClick={() => setField('quick_add_enabled', !form.quick_add_enabled)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
form.quick_add_enabled ? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||||
}`}>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${form.quick_add_enabled ? 'bg-blue-500' : 'bg-gray-400'}`} />
|
||||
{form.quick_add_enabled ? 'Quick Add: Ενεργό' : 'Quick Add: Ανενεργό'}
|
||||
</button>
|
||||
</div>}
|
||||
|
||||
{!form.is_service_item && <div>
|
||||
<label className="label">Εικόνα προϊόντος</label>
|
||||
{product.image_url && (
|
||||
<img src={product.image_url}
|
||||
@@ -599,7 +839,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
|
||||
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
</>)}
|
||||
|
||||
{/* Tab 2: Digital Menu */}
|
||||
@@ -761,13 +1001,69 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: Fiscal (ΦΗΜ) — only shown when fiscal.enabled = true */}
|
||||
{leftTab === 'fiscal' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-xs text-blue-800">
|
||||
Τα πεδία αυτά χρησιμοποιούνται κατά την αυτόματη αποστολή αποδείξεων στη ΦΗΜ.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Όνομα ΦΗΜ <span className="text-gray-400 font-normal normal-case">(προαιρετικό)</span></label>
|
||||
<input
|
||||
className="input font-mono uppercase"
|
||||
placeholder={form.name?.toUpperCase() || 'ΧΡΗΣΙΜΟΠΟΙΕΊ ΤΟ ΚΑΝΟΝΙΚΟ ΟΝΟΜΑ'}
|
||||
value={form.fiscal_name}
|
||||
maxLength={30}
|
||||
onChange={e => setField('fiscal_name', e.target.value.toUpperCase())}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Σύντομο όνομα για την απόδειξη. Αν αφεθεί κενό, χρησιμοποιείται το κανονικό όνομα.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Ομάδα ΦΠΑ ΦΗΜ *</label>
|
||||
{vatGroups.length === 0 ? (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Δεν έχουν οριστεί ομάδες ΦΠΑ. Πηγαίνετε στις <strong>Ρυθμίσεις → Ταμειακή</strong> για να τις ρυθμίσετε.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="input"
|
||||
value={form.fiscal_vat_group_id ?? ''}
|
||||
onChange={e => setField('fiscal_vat_group_id', e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
>
|
||||
<option value="">— Χωρίς ανάθεση —</option>
|
||||
{vatGroups.map(g => (
|
||||
<option key={g.machine_id} value={g.machine_id}>
|
||||
{g.machine_id} — {g.friendly_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{!form.fiscal_vat_group_id && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
Απαιτείται για πληρωμή με ΦΗΜ ενεργή.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: ingredient/option tabs — 70% */}
|
||||
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
|
||||
{/* RIGHT: ingredient/option tabs — 70% (hidden for service items) */}
|
||||
<div className="flex flex-col overflow-hidden" style={{ width: '75%', display: form.is_service_item ? 'none' : undefined }}>
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
|
||||
{tabs.map(tab => {
|
||||
if (tab.isAddGroup) return (
|
||||
<button key="__add_pref_group__" onClick={() => addModifierGroup('preference')}
|
||||
className="px-4 py-3 text-sm font-medium text-indigo-600 hover:bg-indigo-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
if (tab.isAdd) return (
|
||||
<button key="__add_pref__" onClick={addPrefSet}
|
||||
className="px-4 py-3 text-sm font-medium text-primary-600 hover:bg-primary-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
|
||||
@@ -849,63 +1145,142 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
{activeTab === 'ingredients' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
|
||||
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
|
||||
{activeTab === 'ingredients' && (() => {
|
||||
const ingGroups = form.modifier_groups.filter(g => g.modifier_type === 'ingredient')
|
||||
const ungrouped = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id == null)
|
||||
const renderIngRow = (ing, i) => (
|
||||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white flex-wrap ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
|
||||
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
|
||||
<input className="input flex-1 min-w-40" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
|
||||
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: ing.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={ing.is_compact ?? false} onChange={e => setIngredient(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
|
||||
Compact
|
||||
</label>
|
||||
{ingGroups.length > 0 && (
|
||||
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
|
||||
value={form.ingredients[i].group_id ?? ''}
|
||||
onChange={e => setItemGroup('ingredients', i, e.target.value === '' ? null : Number(e.target.value))}>
|
||||
<option value="">— χωρίς ομάδα —</option>
|
||||
{ingGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
|
||||
<div className="space-y-2">
|
||||
{form.ingredients.map((ing, i) => (
|
||||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
|
||||
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
|
||||
<input className="input flex-1" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
|
||||
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||||
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addModifierGroup('ingredient')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
|
||||
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
|
||||
<div className="space-y-2">
|
||||
{ungrouped.map(({ ing, i }) => renderIngRow(ing, i))}
|
||||
{ingGroups.map(g => {
|
||||
const members = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id === g._tempId)
|
||||
const collapsed = collapsedGroups[g._tempId]
|
||||
return (
|
||||
<div key={g._tempId}>
|
||||
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
|
||||
onRename={name => renameModifierGroup(g._tempId, name)}
|
||||
onDelete={() => deleteModifierGroup(g._tempId)} />
|
||||
{!collapsed && (
|
||||
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-2">
|
||||
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς υλικά — αλλάξτε ομάδα από κάθε υλικό.</p>}
|
||||
{members.map(({ ing, i }) => renderIngRow(ing, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Options/Extras */}
|
||||
{activeTab === 'options' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
|
||||
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
|
||||
{activeTab === 'options' && (() => {
|
||||
const optGroups = form.modifier_groups.filter(g => g.modifier_type === 'option')
|
||||
const ungrouped = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id == null)
|
||||
const renderOptRow = (opt, i) => (
|
||||
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
|
||||
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.options.length - 1} />
|
||||
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
|
||||
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
|
||||
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||||
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
|
||||
Πολλαπλά
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.multi_select ? '#0891b2' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={opt.multi_select ?? false} onChange={e => setOption(i, 'multi_select', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#0891b2' }} />
|
||||
Multi-select
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={opt.is_compact ?? false} onChange={e => setOption(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
|
||||
Compact
|
||||
</label>
|
||||
{optGroups.length > 0 && (
|
||||
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
|
||||
value={form.options[i].group_id ?? ''}
|
||||
onChange={e => setItemGroup('options', i, e.target.value === '' ? null : Number(e.target.value))}>
|
||||
<option value="">— χωρίς ομάδα —</option>
|
||||
{optGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
|
||||
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
|
||||
showMultiple={opt.multi_select}
|
||||
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
|
||||
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
|
||||
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
|
||||
onRemove={sci => removeOptionSubChoice(i, sci)}
|
||||
onAdd={() => addOptionSubChoice(i)} />
|
||||
</div>
|
||||
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
|
||||
<div className="space-y-3">
|
||||
{form.options.map((opt, i) => (
|
||||
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||||
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
|
||||
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
|
||||
disableUp={i === 0} disableDown={i === form.options.length - 1} />
|
||||
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
|
||||
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
|
||||
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||||
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
|
||||
Πολλαπλά
|
||||
</label>
|
||||
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
|
||||
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||||
</div>
|
||||
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
|
||||
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
|
||||
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
|
||||
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
|
||||
onRemove={sci => removeOptionSubChoice(i, sci)}
|
||||
onAdd={() => addOptionSubChoice(i)} />
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||||
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addModifierGroup('option')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
|
||||
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
|
||||
<div className="space-y-3">
|
||||
{ungrouped.map(({ opt, i }) => renderOptRow(opt, i))}
|
||||
{optGroups.map(g => {
|
||||
const members = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id === g._tempId)
|
||||
const collapsed = collapsedGroups[g._tempId]
|
||||
return (
|
||||
<div key={g._tempId}>
|
||||
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
|
||||
onRename={name => renameModifierGroup(g._tempId, name)}
|
||||
onDelete={() => deleteModifierGroup(g._tempId)} />
|
||||
{!collapsed && (
|
||||
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-3">
|
||||
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς extras — αλλάξτε ομάδα από κάθε extra.</p>}
|
||||
{members.map(({ opt, i }) => renderOptRow(opt, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Preference set tab */}
|
||||
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
|
||||
@@ -914,22 +1289,65 @@ export default function ProductFormModal({ product, categories, printers, onSave
|
||||
const hasSharedSubset = !!ps.shared_subset
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<input className="input flex-1 font-semibold text-base" placeholder="π.χ. Ζάχαρη" value={ps.name}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<input className="input flex-1 font-semibold text-base min-w-32" placeholder="π.χ. Ζάχαρη" value={ps.name}
|
||||
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
|
||||
{prefGroups.length > 0 && (
|
||||
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 140 }}
|
||||
value={ps.group_id ?? ''}
|
||||
onChange={e => setPrefSetField(si, 'group_id', e.target.value === '' ? null : Number(e.target.value))}>
|
||||
<option value="">— χωρίς ομάδα —</option>
|
||||
{prefGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<FavoriteBtn isFavorite={ps.is_favorite} onClick={() => toggleFavorite('pref', si)} />
|
||||
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή</button>
|
||||
</div>
|
||||
{/* Pref groups management (only show if any exist) */}
|
||||
{prefGroups.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-indigo-50/50 border border-indigo-100 rounded-xl space-y-1">
|
||||
<p className="text-xs font-semibold text-indigo-600 mb-2">Ομάδες προτιμήσεων</p>
|
||||
{prefGroups.map(g => (
|
||||
<GroupHeader key={g._tempId} group={g}
|
||||
collapsed={collapsedGroups[g._tempId]}
|
||||
onToggle={() => toggleGroupCollapsed(g._tempId)}
|
||||
onRename={name => renameModifierGroup(g._tempId, name)}
|
||||
onDelete={() => deleteModifierGroup(g._tempId)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-5 mb-3 flex-wrap">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" checked={ps.allow_multi_select ?? false}
|
||||
onChange={e => setPrefSetField(si, 'allow_multi_select', e.target.checked)}
|
||||
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
|
||||
<span className="text-sm font-medium" style={{ color: ps.allow_multi_select ? '#6366f1' : '#6b7280' }}>
|
||||
Πολλαπλή επιλογή
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" checked={ps.allow_choice_quantity ?? false}
|
||||
onChange={e => setPrefSetField(si, 'allow_choice_quantity', e.target.checked)}
|
||||
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
|
||||
<span className="text-sm font-medium" style={{ color: ps.allow_choice_quantity ? '#6366f1' : '#6b7280' }}>
|
||||
Ποσότητα ανά επιλογή
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-3">● = προεπιλογή · ⊘ = απενεργοποιεί κοινό υπο-σύνολο</p>
|
||||
<div className="space-y-3 mb-5">
|
||||
{ps.choices.map((ch, ci) => (
|
||||
<div key={ci} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-2 p-3 bg-white">
|
||||
<div className="flex items-center gap-2 p-3 bg-white flex-wrap">
|
||||
<ReorderBtns onUp={() => moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
|
||||
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
|
||||
<DefaultBtn isDefault={ps.default_choice_index === ci} onClick={() => toggleDefaultChoice(si, ci)} />
|
||||
<input className="input flex-1" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
|
||||
<input className="input flex-1 min-w-32" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
|
||||
<PriceInput value={ch.extra_cost} onChange={v => setChoice(si, ci, 'extra_cost', v)} allowNegative className="w-32" />
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: ch.is_compact ? '#7c3aed' : '#6b7280' }}>
|
||||
<input type="checkbox" checked={ch.is_compact ?? false} onChange={e => setChoice(si, ci, 'is_compact', e.target.checked)} className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
|
||||
Compact
|
||||
</label>
|
||||
{hasSharedSubset && (
|
||||
<button type="button" onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm ${ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'}`}>⊘</button>
|
||||
|
||||
9
manager_dashboard/src/pages/Management/ProductsPage.jsx
Normal file
9
manager_dashboard/src/pages/Management/ProductsPage.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ProductsTab from '../Management/ProductsTab'
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<ProductsTab />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,8 @@ function Icon({ name, size = 14, className = '' }) {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const EMPTY_PRODUCT = {
|
||||
name: '', category_id: '', base_price: '', is_available: true, lifecycle_status: 'active',
|
||||
printer_zone_id: '', description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
|
||||
description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
|
||||
is_service_item: false,
|
||||
}
|
||||
|
||||
function catColor(cat) { return cat?.color || '#94a3b8' }
|
||||
@@ -175,6 +176,15 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
|
||||
|
||||
{/* Tree */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 24px' }}>
|
||||
{/* Service items — permanent, always first */}
|
||||
<CatRow
|
||||
label="Service"
|
||||
count={products.filter(p => p.is_service_item && p.lifecycle_status !== 'archived').length}
|
||||
isSelected={selectedCat === '__service__'}
|
||||
onClick={() => onSelect('__service__')}
|
||||
dotColor="#f59e0b"
|
||||
isService
|
||||
/>
|
||||
{/* All */}
|
||||
<CatRow
|
||||
label="Όλα τα προϊόντα" count={totalActive}
|
||||
@@ -277,7 +287,7 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
|
||||
)
|
||||
}
|
||||
|
||||
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag }) {
|
||||
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag, isService }) {
|
||||
const bg = isSelected ? '#eff6ff' : 'transparent'
|
||||
const accent = isSelected ? '#3b82f6' : 'transparent'
|
||||
return (
|
||||
@@ -313,14 +323,20 @@ function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, is
|
||||
<span style={{ width: 15, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
{/* Color dot */}
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
|
||||
}} />
|
||||
{/* Color dot or service icon */}
|
||||
{isService ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: isService ? '#b45309' : '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -439,9 +455,9 @@ function CatFooterRow({ icon, label, count, isSelected, onClick }) {
|
||||
}
|
||||
|
||||
// ── Products Panel Header ─────────────────────────────────────────────────────
|
||||
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, zoneFilter, setZoneFilter, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, zones, onNew }) {
|
||||
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, onNew }) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const hasFilter = zoneFilter !== 'all' || priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
|
||||
const hasFilter = priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
|
||||
|
||||
function clearFilters() {
|
||||
setZoneFilter('all')
|
||||
@@ -526,10 +542,6 @@ function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, vi
|
||||
borderRadius: 10, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end',
|
||||
}}>
|
||||
<FilterGroup label="Ζώνη εκτύπωσης">
|
||||
<SegControl value={zoneFilter} onChange={setZoneFilter}
|
||||
options={[{ v: 'all', l: 'Όλες' }, ...zones.map(z => ({ v: z, l: z }))]} />
|
||||
</FilterGroup>
|
||||
<FilterGroup label="Τιμή €">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="number" placeholder="από" value={priceMin} onChange={e => setPriceMin(e.target.value)}
|
||||
@@ -636,7 +648,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
|
||||
}
|
||||
|
||||
// ── Bulk bar ──────────────────────────────────────────────────────────────────
|
||||
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
|
||||
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrepZone, onMoveToCategory }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -660,12 +672,12 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
|
||||
</button>
|
||||
))}
|
||||
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
|
||||
<button onClick={onSetPrinterZone}
|
||||
<button onClick={onSetPrepZone}
|
||||
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
className="hover:bg-white/10"
|
||||
>
|
||||
<Icon name="folder" size={13} />
|
||||
Ζώνη εκτύπωσης
|
||||
Ζώνη Προετοιμασίας
|
||||
</button>
|
||||
<button onClick={onMoveToCategory}
|
||||
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
@@ -679,38 +691,43 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bulk: Set Printer Zone modal ───────────────────────────────────────────────
|
||||
function BulkSetPrinterZoneModal({ count, printers, onClose, onConfirm }) {
|
||||
const zones = [...new Map(printers.map(p => [p.id, p])).values()]
|
||||
const [selectedId, setSelectedId] = useState(zones[0]?.id ?? '')
|
||||
// ── Bulk: Set Prep Zone modal ───────────────────────────────────────────────
|
||||
function BulkSetPrepZoneModal({ count, prepZones, onClose, onConfirm }) {
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const toggle = (id) => setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
|
||||
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 380, padding: 24 }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνη εκτύπωσης</h2>
|
||||
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 420, padding: 24 }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνες Προετοιμασίας</h2>
|
||||
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
|
||||
Θα οριστεί σε <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
|
||||
</p>
|
||||
{zones.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν εκτυπωτές.</p>
|
||||
{prepZones.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν ζώνες προετοιμασίας.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
||||
{zones.map(z => (
|
||||
<label key={z.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${selectedId === z.id ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: selectedId === z.id ? '#eff6ff' : '#fff' }}>
|
||||
<input type="radio" name="zone" value={z.id} checked={selectedId === z.id} onChange={() => setSelectedId(z.id)} style={{ display: 'none' }} />
|
||||
<span style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selectedId === z.id ? '#3b82f6' : '#d1d5db'}`, background: selectedId === z.id ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{selectedId === z.id && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff' }} />}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{prepZones.map(z => {
|
||||
const on = selectedIds.includes(z.id)
|
||||
return (
|
||||
<label key={z.id} onClick={() => toggle(z.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${on ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: on ? '#eff6ff' : '#fff' }}>
|
||||
<span style={{ width: 16, height: 16, borderRadius: 4, border: `2px solid ${on ? '#3b82f6' : '#d1d5db'}`, background: on ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{on && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2, display: 'block' }} />}
|
||||
</span>
|
||||
<div>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
|
||||
{z.description && <span style={{ fontSize: 12, color: '#6b7280', marginLeft: 8 }}>{z.description}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
|
||||
<button
|
||||
onClick={() => onConfirm(selectedId)}
|
||||
disabled={!selectedId}
|
||||
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
|
||||
onClick={() => onConfirm(selectedIds)}
|
||||
disabled={!selectedIds.length}
|
||||
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedIds.length ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedIds.length ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
Εφαρμογή
|
||||
</button>
|
||||
@@ -829,6 +846,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
||||
Αρχείο
|
||||
</span>
|
||||
)}
|
||||
{p.is_service_item && !isArchived && (
|
||||
<span style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', background: '#fef3c7', color: '#b45309', padding: '1px 6px', borderRadius: 4, flexShrink: 0 }}>
|
||||
Service
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#6b7280' }}>
|
||||
{displayCat && (
|
||||
@@ -837,11 +859,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
|
||||
{displayCat.name}
|
||||
</span>
|
||||
)}
|
||||
{p.printer_zone_name && (
|
||||
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
|
||||
{p.printer_zone_name}
|
||||
{p.prep_zone_names?.map(name => (
|
||||
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -997,11 +1019,13 @@ function ProductCard({ p, cat, parentCat, selected, onToggleSelect, onEdit, onAr
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: 15, color: '#111827' }}>
|
||||
€{parseFloat(p.base_price).toFixed(2)}
|
||||
</span>
|
||||
{p.printer_zone_name && (
|
||||
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
|
||||
{p.printer_zone_name}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{p.prep_zone_names?.map(name => (
|
||||
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1052,7 +1076,6 @@ export default function ProductsTab() {
|
||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [view, setView] = useState('list')
|
||||
const [zoneFilter, setZoneFilter] = useState('all')
|
||||
const [priceMin, setPriceMin] = useState('')
|
||||
const [priceMax, setPriceMax] = useState('')
|
||||
const [dataQualityFilter, setDataQualityFilter] = useState('all')
|
||||
@@ -1062,7 +1085,7 @@ export default function ProductsTab() {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
|
||||
// local override of group display order (array of group keys), reset when scope changes
|
||||
const [localGroupOrder, setLocalGroupOrder] = useState(null)
|
||||
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
|
||||
const [bulkPrepZoneModal, setBulkPrepZoneModal] = useState(false)
|
||||
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
|
||||
|
||||
// drag state for group headers
|
||||
@@ -1087,11 +1110,15 @@ export default function ProductsTab() {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const printers = statusData?.printers ?? []
|
||||
const zones = [...new Set(printers.map(p => p.name))]
|
||||
|
||||
const { data: prepZones = [] } = useQuery({
|
||||
queryKey: ['prep-zones'],
|
||||
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
|
||||
})
|
||||
|
||||
const products = allProducts.map(p => ({
|
||||
...p,
|
||||
printer_zone_name: p.printer_zone_id ? printers.find(pr => pr.id === p.printer_zone_id)?.name : null,
|
||||
prep_zone_names: (p.prep_zone_ids || []).map(id => prepZones.find(z => z.id === id)?.name).filter(Boolean),
|
||||
}))
|
||||
|
||||
const invalidate = () => {
|
||||
@@ -1191,8 +1218,10 @@ export default function ProductsTab() {
|
||||
|
||||
if (selectedCat === '__archived__') {
|
||||
list = list.filter(p => p.lifecycle_status === 'archived')
|
||||
} else if (selectedCat === '__service__') {
|
||||
list = list.filter(p => p.is_service_item && p.lifecycle_status !== 'archived')
|
||||
} else if (selectedCat === '__uncategorized__') {
|
||||
list = list.filter(p => !p.category_id && p.lifecycle_status !== 'archived')
|
||||
list = list.filter(p => !p.category_id && !p.is_service_item && p.lifecycle_status !== 'archived')
|
||||
} else if (selectedCat) {
|
||||
if (isParentScope) {
|
||||
const ids = new Set([selectedCat, ...subCats.map(s => s.id)])
|
||||
@@ -1202,6 +1231,8 @@ export default function ProductsTab() {
|
||||
}
|
||||
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
|
||||
} else {
|
||||
// "All products" — exclude service items (they have their own section)
|
||||
list = list.filter(p => !p.is_service_item)
|
||||
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
|
||||
}
|
||||
|
||||
@@ -1215,8 +1246,6 @@ export default function ProductsTab() {
|
||||
list = list.filter(p => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
if (zoneFilter !== 'all') list = list.filter(p => p.printer_zone_name === zoneFilter)
|
||||
|
||||
const pmin = priceMin === '' ? -Infinity : Number(priceMin)
|
||||
const pmax = priceMax === '' ? Infinity : Number(priceMax)
|
||||
list = list.filter(p => p.base_price >= pmin && p.base_price <= pmax)
|
||||
@@ -1289,12 +1318,12 @@ export default function ProductsTab() {
|
||||
setConfirmDelete(null); invalidate(); clearSelect()
|
||||
}
|
||||
|
||||
async function handleBulkSetPrinterZone(printerId) {
|
||||
async function handleBulkSetPrepZone(prepZoneIds) {
|
||||
const ids = [...selected]
|
||||
setBulkPrinterZoneModal(false)
|
||||
setBulkPrepZoneModal(false)
|
||||
try {
|
||||
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
|
||||
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
|
||||
await client.post('/api/products/bulk-prep-zones', { product_ids: ids, prep_zone_ids: prepZoneIds })
|
||||
toast.success(`Ζώνες προετοιμασίας ορίστηκαν σε ${ids.length} προϊόντα`)
|
||||
invalidate(); clearSelect()
|
||||
} catch { toast.error('Σφάλμα') }
|
||||
}
|
||||
@@ -1420,6 +1449,7 @@ export default function ProductsTab() {
|
||||
function getTitle() {
|
||||
if (selectedCat === null) return 'Όλα τα προϊόντα'
|
||||
if (selectedCat === '__archived__') return 'Αρχειοθετημένα'
|
||||
if (selectedCat === '__service__') return 'Service'
|
||||
if (selectedCat === '__uncategorized__') return 'Χωρίς κατηγορία'
|
||||
const cat = categories.find(c => c.id === selectedCat)
|
||||
if (!cat) return 'Προϊόντα'
|
||||
@@ -1456,15 +1486,17 @@ export default function ProductsTab() {
|
||||
totalCount={totalActive}
|
||||
search={search} setSearch={setSearch}
|
||||
view={view} setView={setView}
|
||||
zoneFilter={zoneFilter} setZoneFilter={setZoneFilter}
|
||||
priceMin={priceMin} setPriceMin={setPriceMin}
|
||||
priceMax={priceMax} setPriceMax={setPriceMax}
|
||||
dataQualityFilter={dataQualityFilter} setDataQualityFilter={setDataQualityFilter}
|
||||
showUnavailable={showUnavailable} setShowUnavailable={setShowUnavailable}
|
||||
showArchived={showArchived} setShowArchived={setShowArchived}
|
||||
zones={zones}
|
||||
onNew={() => {
|
||||
const SPECIAL = [null, '__uncategorized__', '__archived__']
|
||||
if (selectedCat === '__service__') {
|
||||
setEditProduct({ ...EMPTY_PRODUCT, is_service_item: true, base_price: 0 })
|
||||
return
|
||||
}
|
||||
const SPECIAL = [null, '__uncategorized__', '__archived__', '__service__']
|
||||
const presetCat = (!SPECIAL.includes(subCatFilter) && subCatFilter)
|
||||
|| (!SPECIAL.includes(selectedCat) && selectedCat)
|
||||
|| ''
|
||||
@@ -1491,7 +1523,7 @@ export default function ProductsTab() {
|
||||
onAvail={() => bulkAction('available')}
|
||||
onUnavail={() => bulkAction('unavailable')}
|
||||
onArchive={() => bulkAction('archive')}
|
||||
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
|
||||
onSetPrepZone={() => setBulkPrepZoneModal(true)}
|
||||
onMoveToCategory={() => setBulkMoveCatModal(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -1626,6 +1658,7 @@ export default function ProductsTab() {
|
||||
product={editProduct}
|
||||
categories={categories}
|
||||
printers={printers}
|
||||
prepZones={prepZones}
|
||||
onSave={b => saveProduct.mutate(b)}
|
||||
onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })}
|
||||
onClose={() => setEditProduct(null)}
|
||||
@@ -1649,13 +1682,13 @@ export default function ProductsTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk: set printer zone modal */}
|
||||
{bulkPrinterZoneModal && (
|
||||
<BulkSetPrinterZoneModal
|
||||
{/* Bulk: set prep zone modal */}
|
||||
{bulkPrepZoneModal && (
|
||||
<BulkSetPrepZoneModal
|
||||
count={selected.size}
|
||||
printers={printers}
|
||||
onClose={() => setBulkPrinterZoneModal(false)}
|
||||
onConfirm={handleBulkSetPrinterZone}
|
||||
prepZones={prepZones}
|
||||
onClose={() => setBulkPrepZoneModal(false)}
|
||||
onConfirm={handleBulkSetPrepZone}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
1
manager_dashboard/src/pages/Management/SchedulePage.jsx
Normal file
1
manager_dashboard/src/pages/Management/SchedulePage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../SchedulePage'
|
||||
9
manager_dashboard/src/pages/Management/StaffPage.jsx
Normal file
9
manager_dashboard/src/pages/Management/StaffPage.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import StaffTab from '../StaffTab'
|
||||
|
||||
export default function StaffPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<StaffTab />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import TablesConfigTab from '../TablesConfigTab'
|
||||
|
||||
export default function TablesConfigPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TablesConfigTab />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
687
manager_dashboard/src/pages/Management/pricing/DealsTab.jsx
Normal file
687
manager_dashboard/src/pages/Management/pricing/DealsTab.jsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Gift, Power, Pencil, Trash2, X, Info } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import { ConfirmModal } from '../../../ui/Modal'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
list: () => client.get('/api/pricing/deals').then(r => r.data),
|
||||
create: body => client.post('/api/pricing/deals', body).then(r => r.data),
|
||||
update: (id,b) => client.put(`/api/pricing/deals/${id}`, b).then(r => r.data),
|
||||
toggle: id => client.patch(`/api/pricing/deals/${id}/toggle`).then(r => r.data),
|
||||
remove: id => client.delete(`/api/pricing/deals/${id}`),
|
||||
modifiers: () => client.get('/api/pricing/modifiers').then(r => r.data),
|
||||
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
|
||||
categories:() => client.get('/api/products/categories').then(r => r.data),
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
|
||||
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
|
||||
]
|
||||
|
||||
const CONDITION_TYPES = [
|
||||
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα αντικειμένου' },
|
||||
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
|
||||
{ value: 'time_range', label: 'Ώρα ημέρας' },
|
||||
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
|
||||
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
|
||||
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
|
||||
{ value: 'price_group_active',label: 'Ομάδα τιμής ενεργή' },
|
||||
]
|
||||
|
||||
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
|
||||
const CHANNELS = ['pos','online','qr','takeaway']
|
||||
|
||||
const ACTION_TYPES = [
|
||||
{ value: 'apply_modifier', label: 'Εφαρμογή τροποποιητή' },
|
||||
{ value: 'set_price', label: 'Ορισμός τιμής' },
|
||||
{ value: 'add_amount', label: '± Ποσό €' },
|
||||
{ value: 'add_percent', label: '± Ποσοστό %' },
|
||||
{ value: 'free_item', label: 'Δωρεάν προϊόν' },
|
||||
{ value: 'free_choice', label: 'Επιλογή δωρεάν προϊόντος' },
|
||||
]
|
||||
|
||||
// ─── Reuse condition editor (simplified for deals) ────────────────────────────
|
||||
|
||||
function ConditionRow({ cond, onChange, onRemove, groups }) {
|
||||
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
|
||||
const setType = t => onChange({ condition_type: t, params: {} })
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={cond.condition_type}
|
||||
onChange={e => setType(e.target.value)}>
|
||||
{CONDITION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cond.condition_type === 'min_item_quantity' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
|
||||
<input type="number" min={1} className="input w-28 text-[12px]"
|
||||
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'min_order_value' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία (€)</label>
|
||||
<input type="number" min={0} step={0.5} className="input w-28 text-[12px]"
|
||||
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'time_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'day_of_week' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DAYS.map((d, i) => (
|
||||
<button type="button" key={i}
|
||||
onClick={() => { const days = cond.params.days ?? []; set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i]) }}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{d.slice(0, 3)}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'date_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'order_channel' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHANNELS.map(ch => (
|
||||
<button type="button" key={ch}
|
||||
onClick={() => { const channels = cond.params.channels ?? []; set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch]) }}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{ch}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cond.condition_type === 'price_group_active' && (
|
||||
<div>
|
||||
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
|
||||
onChange={e => set('price_group_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή ομάδας —</option>
|
||||
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared multi-picker (same logic as ModifiersTab) ─────────────────────────
|
||||
|
||||
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id' }) {
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{items.length === 0 && (
|
||||
<p className="text-[11px] text-slate-400 px-3 py-2">Δεν βρέθηκαν αποτελέσματα</p>
|
||||
)}
|
||||
{items.map(item => {
|
||||
const val = item[valueKey]
|
||||
const checked = selected.includes(val)
|
||||
return (
|
||||
<div key={val} onClick={() => onToggle(val)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
|
||||
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
|
||||
}`}>
|
||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
{item[labelKey]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagPicker({ selected, onToggle, onAdd, allTags }) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const handleAdd = () => {
|
||||
const tag = draft.trim()
|
||||
if (tag && !selected.includes(tag)) onAdd(tag)
|
||||
setDraft('')
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map(tag => {
|
||||
const active = selected.includes(tag)
|
||||
return (
|
||||
<button type="button" key={tag} onClick={() => onToggle(tag)}
|
||||
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
|
||||
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
|
||||
}`}>{tag}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
|
||||
{tag}
|
||||
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
|
||||
<button type="button" onClick={handleAdd}
|
||||
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Deal target row ──────────────────────────────────────────────────────────
|
||||
|
||||
function DealTargetRow({ target, onChange, onRemove, products, categories, allTags }) {
|
||||
const ids = target.target_ids ?? []
|
||||
const tags = target.target_tags ?? []
|
||||
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
|
||||
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
|
||||
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
|
||||
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={target.target_type}
|
||||
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
|
||||
<option value="any">Οποιοδήποτε προϊόν</option>
|
||||
<option value="item">Προϊόντα</option>
|
||||
<option value="category">Κατηγορίες</option>
|
||||
<option value="tag">Ετικέτες (tags)</option>
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{target.target_type === 'item' && (
|
||||
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'category' && (
|
||||
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'tag' && (
|
||||
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Deal form ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEAL_TABS = [
|
||||
{ key: 'info', label: 'Πληροφορίες' },
|
||||
{ key: 'trigger', label: 'Trigger & Συνθήκες' },
|
||||
{ key: 'action', label: 'Ενέργεια' },
|
||||
]
|
||||
|
||||
function DealForm({ initial, onSave, onCancel, saving, modifiers, groups, activeTab, onTabChange }) {
|
||||
const { data: products = [] } = useQuery({
|
||||
queryKey: ['products-all-pricing'],
|
||||
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
|
||||
})
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => client.get('/api/products/categories').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ['product-tags'],
|
||||
queryFn: () => client.get('/api/products/tags').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const normalizeTarget = t => ({
|
||||
...t,
|
||||
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
|
||||
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
|
||||
})
|
||||
|
||||
const blank = {
|
||||
name: '', description: '', color: COLORS[6], is_active: true, sort_order: 0,
|
||||
action_type: 'free_item',
|
||||
action_modifier_id: null, action_value: null,
|
||||
action_free_item_id: null,
|
||||
action_free_target_type: 'item', action_free_target_ids: [],
|
||||
action_free_quantity: 1,
|
||||
conditions: [], targets: [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
|
||||
}
|
||||
const [f, setF] = useState(initial ? {
|
||||
...blank, ...initial,
|
||||
conditions: initial.conditions ?? [],
|
||||
targets: (initial.targets ?? [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }]).map(normalizeTarget),
|
||||
action_free_target_ids: initial.action_free_target_ids ?? [],
|
||||
} : blank)
|
||||
|
||||
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
|
||||
|
||||
const addCond = () => set('conditions', [...f.conditions, { condition_type: 'min_item_quantity', params: { min: 1 } }])
|
||||
const updCond = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
|
||||
const remCond = i => set('conditions', f.conditions.filter((_, j) => j !== i))
|
||||
|
||||
const addTarget = () => set('targets', [...f.targets, { target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
|
||||
const updTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
|
||||
const remTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
|
||||
|
||||
const toggleFreeId = id => {
|
||||
const ids = f.action_free_target_ids ?? []
|
||||
set('action_free_target_ids', ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
|
||||
}
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
|
||||
onSave({
|
||||
...f,
|
||||
action_free_target_ids: f.action_free_target_ids?.length ? f.action_free_target_ids : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="deal-form" onSubmit={submit} className="space-y-5">
|
||||
|
||||
{/* ── Info tab ── */}
|
||||
{activeTab === 'info' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα προσφοράς</label>
|
||||
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="π.χ. 3+1 Μπύρες" autoFocus />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή</label>
|
||||
<input className="input w-full" value={f.description ?? ''}
|
||||
onChange={e => set('description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map(c => (
|
||||
<button type="button" key={c} onClick={() => set('color', c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<div onClick={() => set('is_active', !f.is_active)}
|
||||
className={`relative rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}
|
||||
style={{ width: 32, height: 18 }}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-3.5' : ''}`} />
|
||||
</div>
|
||||
<span className="text-[12px] text-slate-700">Ενεργή</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Trigger & Conditions tab ── */}
|
||||
{activeTab === 'trigger' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Trigger</span>
|
||||
<span className="text-[11px] text-slate-400 ml-1.5">Αγορά αυτών</span>
|
||||
</div>
|
||||
<button type="button" onClick={addTarget}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Ποια προϊόντα πρέπει να υπάρχουν στην παραγγελία για να ενεργοποιηθεί η προσφορά;
|
||||
</p>
|
||||
{f.targets.map((t, i) => (
|
||||
<DealTargetRow key={i} target={t} onChange={v => updTarget(i, v)} onRemove={() => remTarget(i)}
|
||||
products={products} categories={categories} allTags={allTags} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Επιπλέον Συνθήκες</span>
|
||||
<span className="text-[11px] text-slate-400 ml-1.5">(προαιρετικά)</span>
|
||||
</div>
|
||||
<button type="button" onClick={addCond}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
{f.conditions.length === 0 && (
|
||||
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
|
||||
Χωρίς επιπλέον συνθήκες — αρκεί μόνο το trigger.
|
||||
</p>
|
||||
)}
|
||||
{f.conditions.map((c, i) => (
|
||||
<ConditionRow key={i} cond={c} onChange={v => updCond(i, v)} onRemove={() => remCond(i)} groups={groups} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Action tab ── */}
|
||||
{activeTab === 'action' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{ACTION_TYPES.map(({ value, label }) => (
|
||||
<button type="button" key={value} onClick={() => set('action_type', value)}
|
||||
className={`py-2 rounded-lg border text-[12px] font-medium transition-colors text-center ${
|
||||
f.action_type === value ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{f.action_type === 'apply_modifier' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Τροποποιητής (χωρίς συνθήκες)</label>
|
||||
<select className="input w-full text-[12px]" value={f.action_modifier_id ?? ''}
|
||||
onChange={e => set('action_modifier_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{(modifiers ?? []).filter(m => !m.conditions?.length).map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-slate-400 mt-1">Εμφανίζονται μόνο τροποποιητές χωρίς συνθήκες (χειροκίνητοι).</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(f.action_type === 'set_price' || f.action_type === 'add_amount' || f.action_type === 'add_percent') && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">
|
||||
{f.action_type === 'set_price' ? 'Νέα τιμή (€)' :
|
||||
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
|
||||
'Ποσοστό (%, αρνητικό = έκπτωση)'}
|
||||
</label>
|
||||
<input type="number" step={0.1} className="input w-36 text-[12px]"
|
||||
value={f.action_value ?? 0} onChange={e => set('action_value', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.action_type === 'free_item' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Δωρεάν προϊόν</label>
|
||||
<select className="input w-full text-[12px]" value={f.action_free_item_id ?? ''}
|
||||
onChange={e => set('action_free_item_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα</label>
|
||||
<input type="number" min={1} className="input w-20 text-[12px]"
|
||||
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.action_type === 'free_choice' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
{[['item','Προϊόντα'],['category','Κατηγορία'],['tag','Ετικέτα']].map(([v,l]) => (
|
||||
<button type="button" key={v} onClick={() => { set('action_free_target_type', v); set('action_free_target_ids', []) }}
|
||||
className={`px-3 py-1 rounded text-[12px] font-medium border transition-colors ${
|
||||
f.action_free_target_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
{f.action_free_target_type === 'item' && (
|
||||
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{products.map(p => (
|
||||
<label key={p.id} className="flex items-center gap-2 cursor-pointer py-0.5">
|
||||
<input type="checkbox" className="rounded"
|
||||
checked={(f.action_free_target_ids ?? []).includes(p.id)}
|
||||
onChange={() => toggleFreeId(p.id)} />
|
||||
<span className="text-[12px] text-slate-700">{p.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.action_free_target_type === 'category' && (
|
||||
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
|
||||
{categories.map(c => (
|
||||
<label key={c.id} className="flex items-center gap-2 cursor-pointer py-0.5">
|
||||
<input type="checkbox" className="rounded"
|
||||
checked={(f.action_free_target_ids ?? []).includes(c.id)}
|
||||
onChange={() => toggleFreeId(c.id)} />
|
||||
<span className="text-[12px] text-slate-700">{c.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.action_free_target_type === 'tag' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Ετικέτες (μία ανά γραμμή)</label>
|
||||
<textarea className="input w-full text-[12px] font-mono" rows={3}
|
||||
value={(f.action_free_target_ids ?? []).join('\n')}
|
||||
onChange={e => set('action_free_target_ids', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα δωρεάν</label>
|
||||
<input type="number" min={1} className="input w-20 text-[12px]"
|
||||
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Deal card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function DealCard({ deal, onEdit, onDelete }) {
|
||||
const qc = useQueryClient()
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.toggle(deal.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-deals'] }),
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const actionLabel = ACTION_TYPES.find(a => a.value === deal.action_type)?.label ?? deal.action_type
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm p-4 flex items-start gap-3 transition-opacity ${deal.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
|
||||
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: deal.color ?? '#22c55e' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14px] font-semibold text-slate-800">{deal.name}</span>
|
||||
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
deal.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>{deal.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
|
||||
<span className="text-[11px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{actionLabel}</span>
|
||||
</div>
|
||||
{deal.description && <p className="text-[12px] text-slate-500 mt-0.5">{deal.description}</p>}
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
{deal.conditions?.length ?? 0} συνθήκες · {deal.targets?.length ?? 0} targets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => toggle.mutate()}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
deal.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
|
||||
}`}>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onEdit(deal)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(deal)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DealsTab() {
|
||||
const qc = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
const [formTab, setFormTab] = useState('info')
|
||||
|
||||
const { data: deals = [], isLoading } = useQuery({ queryKey: ['pricing-deals'], queryFn: api.list })
|
||||
const { data: modifiers = [] } = useQuery({ queryKey: ['pricing-modifiers'], queryFn: api.modifiers })
|
||||
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: () => client.get('/api/pricing/groups').then(r => r.data) })
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: api.create,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setShowForm(false); toast.success('Δημιουργήθηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }) => api.update(id, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.remove,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setDeleting(null); toast.success('Διαγράφηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const formProps = { modifiers, groups }
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Προσφορές</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Αυτόματες δράσεις που ενεργοποιούνται όταν ο σερβιτόρος προσθέτει συγκεκριμένα προϊόντα στην παραγγελία.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />Νέα Προσφορά
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 bg-sky-50 border border-sky-200 rounded-lg px-4 py-3">
|
||||
<Info className="w-4 h-4 text-sky-500 shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] text-sky-700">
|
||||
Όταν μια προσφορά ενεργοποιηθεί, ο σερβιτόρος λαμβάνει prompt στην εφαρμογή και επιλέγει αν θα την εφαρμόσει.
|
||||
Τίποτα δεν εφαρμόζεται αυτόματα χωρίς επιβεβαίωση.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : deals.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Gift className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-[13px]">Δεν υπάρχουν προσφορές</p>
|
||||
<p className="text-[12px] mt-1">π.χ. «Αγόρασε 3 μπύρες, πάρε 1 δωρεάν»</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{deals.map(d => (
|
||||
<DealCard key={d.id} deal={d} onEdit={d => { setEditing(d); setFormTab('info') }} onDelete={setDeleting} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<Modal title="Νέα Προσφορά" onClose={() => { setShowForm(false); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="deal-form" variant="primary" disabled={create.isPending}>
|
||||
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<DealForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
|
||||
saving={create.isPending} {...formProps} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<Modal title="Επεξεργασία Προσφοράς" onClose={() => { setEditing(null); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="deal-form" variant="primary" disabled={update.isPending}>
|
||||
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<DealForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
|
||||
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
|
||||
{...formProps} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<ConfirmModal title="Διαγραφή Προσφοράς"
|
||||
message={`Θέλετε να διαγράψετε την προσφορά "${deleting.name}";`}
|
||||
confirmLabel="Διαγραφή"
|
||||
onConfirm={() => remove.mutate(deleting.id)}
|
||||
onCancel={() => setDeleting(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, ShieldCheck, Save } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
globalGet: () => client.get('/api/pricing/discount-settings/global').then(r => r.data),
|
||||
globalPut: body => client.put('/api/pricing/discount-settings/global', body).then(r => r.data),
|
||||
waiterGet: id => client.get(`/api/pricing/discount-settings/${id}`).then(r => r.data),
|
||||
waiterPut: (id,b) => client.put(`/api/pricing/discount-settings/${id}`, b).then(r => r.data),
|
||||
staff: () => client.get('/api/waiters/').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Toggle helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ value, onChange }) {
|
||||
return (
|
||||
<div onClick={() => onChange(!value)}
|
||||
className={`relative rounded-full cursor-pointer transition-colors shrink-0 ${value ? 'bg-sky-500' : 'bg-slate-200'}`}
|
||||
style={{ width: 36, height: 20 }}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Numeric field ────────────────────────────────────────────────────────────
|
||||
|
||||
function NumField({ label, hint, value, onChange, step = 1, min = 0, unit = '' }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[12px] font-medium text-slate-600">{label}</label>
|
||||
{hint && <p className="text-[11px] text-slate-400">{hint}</p>}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number" min={min} step={step}
|
||||
className="input w-28 text-[12px]"
|
||||
placeholder="—"
|
||||
value={value ?? ''}
|
||||
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
/>
|
||||
{unit && <span className="text-[12px] text-slate-500">{unit}</span>}
|
||||
{value !== null && value !== undefined && (
|
||||
<button type="button" onClick={() => onChange(null)}
|
||||
className="text-[11px] text-slate-400 hover:text-slate-600 underline ml-1">
|
||||
Αφαίρεση
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Global settings panel ────────────────────────────────────────────────────
|
||||
|
||||
function GlobalPanel() {
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['discount-global'], queryFn: api.globalGet })
|
||||
|
||||
const blankGlobal = {
|
||||
enabled: false,
|
||||
max_total_value_workday: null,
|
||||
max_total_value_shift: null,
|
||||
max_items_per_shift: null,
|
||||
}
|
||||
const [f, setF] = useState(null)
|
||||
const current = f ?? (data ? { ...blankGlobal, ...data } : blankGlobal)
|
||||
const set = (k, v) => setF(p => ({ ...(p ?? current), [k]: v }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.globalPut(current),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-global'] }); setF(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" /></div>
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-sky-100 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-4 h-4 text-sky-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">Καθολικές Ρυθμίσεις</h3>
|
||||
<p className="text-[11px] text-slate-500">Εφαρμόζονται σε όλους τους σερβιτόρους ανεξαρτήτως ατομικών ρυθμίσεων</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master enable */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-slate-700">Εκπτώσεις Ενεργές</span>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Αν είναι ανενεργό, κανένας σερβιτόρος δεν μπορεί να εφαρμόσει εκπτώσεις</p>
|
||||
</div>
|
||||
<Toggle value={current.enabled ?? false} onChange={v => set('enabled', v)} />
|
||||
</div>
|
||||
|
||||
{/* Limits */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<NumField label="Μέγιστο ανά εργάσιμη ημέρα" hint="Ολόκληρο το κατάστημα"
|
||||
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
|
||||
<NumField label="Μέγιστο ανά βάρδια" hint="Όλες οι βάρδιες μαζί"
|
||||
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
|
||||
<NumField label="Μέγιστα αντικείμενα/βάρδια" hint="Σύνολο αντικειμένων με έκπτωση"
|
||||
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Per-waiter settings panel ────────────────────────────────────────────────
|
||||
|
||||
const WAITER_BLANK = {
|
||||
can_apply_discounts: false,
|
||||
max_discount_percent: null,
|
||||
max_discount_amount: null,
|
||||
max_total_value_shift: null,
|
||||
max_total_value_workday: null,
|
||||
max_items_per_shift: null,
|
||||
max_items_per_workday: null,
|
||||
max_items_per_order: null,
|
||||
}
|
||||
|
||||
function WaiterPanel({ waiters }) {
|
||||
const qc = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState(waiters[0]?.id ?? null)
|
||||
const [dirty, setDirty] = useState(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['discount-waiter', selectedId],
|
||||
queryFn: () => api.waiterGet(selectedId),
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const current = dirty ?? (data ? { ...WAITER_BLANK, ...data } : WAITER_BLANK)
|
||||
const set = (k, v) => setDirty(p => ({ ...(p ?? current), [k]: v }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.waiterPut(selectedId, current),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-waiter', selectedId] }); setDirty(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const selectedWaiter = waiters.find(w => w.id === selectedId)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<Users className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">Ανά Σερβιτόρο</h3>
|
||||
<p className="text-[11px] text-slate-500">Ατομικά όρια — ισχύουν παράλληλα με τα καθολικά</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter picker */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{waiters.map(w => (
|
||||
<button key={w.id} type="button"
|
||||
onClick={() => { setSelectedId(w.id); setDirty(null) }}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium border transition-colors ${
|
||||
w.id === selectedId ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
{w.nickname || w.full_name || w.username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!selectedId ? (
|
||||
<p className="text-[12px] text-slate-400 text-center py-8">Επιλέξτε σερβιτόρο</p>
|
||||
) : isLoading ? (
|
||||
<div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin" /></div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Can apply toggle */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-slate-700">
|
||||
{selectedWaiter?.nickname || selectedWaiter?.full_name || selectedWaiter?.username} — Δικαίωμα εκπτώσεων
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Απενεργοποίηση κλειδώνει τον σερβιτόρο εντελώς</p>
|
||||
</div>
|
||||
<Toggle value={current.can_apply_discounts} onChange={v => set('can_apply_discounts', v)} />
|
||||
</div>
|
||||
|
||||
{/* Per-discount limits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Ανά Έκπτωση</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<NumField label="Μέγιστο ποσοστό %" hint="Μέγιστη έκπτωση σε % ανά εφαρμογή"
|
||||
value={current.max_discount_percent} onChange={v => set('max_discount_percent', v)} step={5} unit="%" />
|
||||
<NumField label="Μέγιστο ποσό €" hint="Μέγιστο € που αφαιρείται ανά εφαρμογή"
|
||||
value={current.max_discount_amount} onChange={v => set('max_discount_amount', v)} step={0.5} unit="€" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope limits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Συνολικά Όρια</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<NumField label="Αξία/Βάρδια" hint="Συνολική αξία εκπτώσεων ανά βάρδια"
|
||||
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
|
||||
<NumField label="Αξία/Εργάσιμη ημέρα" hint="Συνολική αξία εκπτώσεων ανά ημέρα"
|
||||
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
|
||||
<NumField label="Αντικείμενα/Βάρδια"
|
||||
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
|
||||
<NumField label="Αντικείμενα/Εργάσιμη ημέρα"
|
||||
value={current.max_items_per_workday} onChange={v => set('max_items_per_workday', v)} unit="τεμ." />
|
||||
<NumField label="Αντικείμενα/Παραγγελία" hint="Μέγιστος αριθμός αντικειμένων με έκπτωση ανά παραγγελία"
|
||||
value={current.max_items_per_order} onChange={v => set('max_items_per_order', v)} unit="τεμ." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscountSettingsTab() {
|
||||
const { data: staff = [] } = useQuery({ queryKey: ['waiters'], queryFn: api.staff })
|
||||
const waiters = staff.filter(u => u.is_active !== false)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Ρυθμίσεις Εκπτώσεων</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Ελέγξτε ποιοι σερβιτόροι μπορούν να εφαρμόζουν εκπτώσεις και ποια είναι τα όρια ανά βάρδια ή εργάσιμη ημέρα.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlobalPanel />
|
||||
|
||||
{waiters.length > 0
|
||||
? <WaiterPanel waiters={waiters} />
|
||||
: (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-[13px]">Δεν βρέθηκαν ενεργοί σερβιτόροι</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
810
manager_dashboard/src/pages/Management/pricing/ModifiersTab.jsx
Normal file
810
manager_dashboard/src/pages/Management/pricing/ModifiersTab.jsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus, Percent, Power, Pencil, Trash2, Star, Layers,
|
||||
GripVertical, ChevronDown, ChevronUp, X, Info,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import { ConfirmModal } from '../../../ui/Modal'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
list: (params) => client.get('/api/pricing/modifiers', { params }).then(r => r.data),
|
||||
create: body => client.post('/api/pricing/modifiers', body).then(r => r.data),
|
||||
update: (id, b) => client.put(`/api/pricing/modifiers/${id}`, b).then(r => r.data),
|
||||
toggle: id => client.patch(`/api/pricing/modifiers/${id}/toggle`).then(r => r.data),
|
||||
reorder: items => client.patch('/api/pricing/modifiers/reorder', { items }),
|
||||
remove: id => client.delete(`/api/pricing/modifiers/${id}`),
|
||||
groups: () => client.get('/api/pricing/groups').then(r => r.data),
|
||||
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
|
||||
categories: () => client.get('/api/products/categories').then(r => r.data),
|
||||
prepZones: () => client.get('/api/prep-zones').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
|
||||
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
|
||||
]
|
||||
|
||||
const CONDITION_TYPES = [
|
||||
{ value: 'time_range', label: 'Ώρα ημέρας (εύρος)' },
|
||||
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
|
||||
{ value: 'specific_date', label: 'Συγκεκριμένη ημερομηνία' },
|
||||
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
|
||||
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα προϊόντος' },
|
||||
{ value: 'min_category_qty', label: 'Ελάχιστη ποσότητα κατηγορίας' },
|
||||
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
|
||||
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
|
||||
{ value: 'price_group_active', label: 'Ομάδα τιμής ενεργή' },
|
||||
{ value: 'user_tier', label: 'Επίπεδο πελάτη (placeholder)' },
|
||||
{ value: 'low_stock', label: 'Χαμηλό απόθεμα (placeholder)' },
|
||||
]
|
||||
|
||||
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
|
||||
const CHANNELS = ['pos','online','qr','takeaway']
|
||||
|
||||
const ROUND_OPTIONS = [
|
||||
{ value: '', label: 'Χωρίς στρογγυλοποίηση' },
|
||||
{ value: '0.05', label: 'Στο κοντινότερο €0,05' },
|
||||
{ value: '0.10', label: 'Στο κοντινότερο €0,10' },
|
||||
{ value: '0.20', label: 'Στο κοντινότερο €0,20' },
|
||||
{ value: '0.50', label: 'Στο κοντινότερο €0,50' },
|
||||
{ value: 'x.99', label: 'Σε x,99 (π.χ. 4,99)' },
|
||||
{ value: 'x.00', label: 'Σε x,00 (στρογγυλό)' },
|
||||
]
|
||||
|
||||
// ─── Condition editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function ConditionEditor({ cond, onChange, onRemove, groups, categories, prepZones }) {
|
||||
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
|
||||
const setType = t => onChange({ condition_type: t, params: {} })
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={cond.condition_type}
|
||||
onChange={e => setType(e.target.value)}>
|
||||
{CONDITION_TYPES.map(ct => (
|
||||
<option key={ct.value} value={ct.value}>{ct.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Params per type */}
|
||||
{cond.condition_type === 'time_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'date_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'specific_date' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ημερομηνίες (μία ανά γραμμή)</label>
|
||||
<textarea className="input w-full text-[12px] font-mono" rows={3}
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={(cond.params.dates ?? []).join('\n')}
|
||||
onChange={e => set('dates', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'day_of_week' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DAYS.map((d, i) => (
|
||||
<button type="button" key={i}
|
||||
onClick={() => {
|
||||
const days = cond.params.days ?? []
|
||||
set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i])
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{d.slice(0, 3)}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_item_quantity' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
|
||||
<input type="number" min={1} className="input w-32 text-[12px]"
|
||||
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_category_qty' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Κατηγορία</label>
|
||||
<select className="input w-full text-[12px]" value={cond.params.category_id ?? ''}
|
||||
onChange={e => set('category_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{(categories ?? []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχ.</label>
|
||||
<input type="number" min={1} className="input w-full text-[12px]"
|
||||
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_order_value' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία παραγγελίας (€)</label>
|
||||
<input type="number" min={0} step={0.5} className="input w-32 text-[12px]"
|
||||
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'order_channel' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHANNELS.map(ch => (
|
||||
<button type="button" key={ch}
|
||||
onClick={() => {
|
||||
const channels = cond.params.channels ?? []
|
||||
set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch])
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{ch}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'price_group_active' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ομάδα τιμής</label>
|
||||
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
|
||||
onChange={e => set('price_group_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(cond.condition_type === 'user_tier' || cond.condition_type === 'low_stock') && (
|
||||
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1 flex items-center gap-1.5">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
Placeholder — θα ενεργοποιηθεί σε μελλοντική έκδοση
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared multi-picker ──────────────────────────────────────────────────────
|
||||
|
||||
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id', placeholder = 'Επιλέξτε...' }) {
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{items.length === 0 && (
|
||||
<p className="text-[11px] text-slate-400 px-3 py-2">{placeholder}</p>
|
||||
)}
|
||||
{items.map(item => {
|
||||
const val = item[valueKey]
|
||||
const checked = selected.includes(val)
|
||||
return (
|
||||
<div key={val} onClick={() => onToggle(val)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
|
||||
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
|
||||
}`}>
|
||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
{item[labelKey]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagPicker({ selected, onToggle, onAdd, allTags }) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const handleAdd = () => {
|
||||
const tag = draft.trim()
|
||||
if (tag && !selected.includes(tag)) onAdd(tag)
|
||||
setDraft('')
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Quick-select existing tags */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map(tag => {
|
||||
const active = selected.includes(tag)
|
||||
return (
|
||||
<button type="button" key={tag} onClick={() => onToggle(tag)}
|
||||
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
|
||||
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
|
||||
}`}>{tag}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
|
||||
{tag}
|
||||
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Manual entry */}
|
||||
<div className="flex gap-2">
|
||||
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
|
||||
<button type="button" onClick={handleAdd}
|
||||
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Target editor ────────────────────────────────────────────────────────────
|
||||
|
||||
function TargetEditor({ target, onChange, onRemove, products, categories, prepZones, allTags }) {
|
||||
const ids = target.target_ids ?? []
|
||||
const tags = target.target_tags ?? []
|
||||
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
|
||||
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
|
||||
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
|
||||
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={target.target_type}
|
||||
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
|
||||
<option value="all">Όλα τα προϊόντα</option>
|
||||
<option value="item">Προϊόντα</option>
|
||||
<option value="category">Κατηγορίες</option>
|
||||
<option value="prep_zone">Ζώνες ετοιμασίας</option>
|
||||
<option value="tag">Ετικέτες (tags)</option>
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{target.target_type === 'item' && (
|
||||
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'category' && (
|
||||
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'prep_zone' && (
|
||||
<MultiPicker items={prepZones} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'tag' && (
|
||||
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modifier form ────────────────────────────────────────────────────────────
|
||||
|
||||
const MODIFIER_TABS = [
|
||||
{ key: 'info', label: 'Πληροφορίες' },
|
||||
{ key: 'target', label: 'Στόχος' },
|
||||
{ key: 'conditions', label: 'Συνθήκες' },
|
||||
{ key: 'price', label: 'Τιμή' },
|
||||
]
|
||||
|
||||
function ModifierForm({ initial, onSave, onCancel, saving, groups, activeTab, onTabChange }) {
|
||||
const { data: products = [] } = useQuery({
|
||||
queryKey: ['products-all-pricing'],
|
||||
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
|
||||
})
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => client.get('/api/products/categories').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: prepZones = [] } = useQuery({
|
||||
queryKey: ['prep-zones'],
|
||||
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ['product-tags'],
|
||||
queryFn: () => client.get('/api/products/tags').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const normalizeTarget = t => ({
|
||||
...t,
|
||||
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
|
||||
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
|
||||
})
|
||||
|
||||
const blank = {
|
||||
name: '', description: '', color: COLORS[0], is_active: true, is_favorite: false,
|
||||
allow_stack: false, sort_order: 0, scope: 'global', item_id: null,
|
||||
action_type: 'add_percent', action_value: -10, round_to: '',
|
||||
conditions: [], targets: [{ target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
|
||||
}
|
||||
const [f, setF] = useState(initial ? {
|
||||
...initial,
|
||||
action_value: initial.action_value ?? 0,
|
||||
round_to: initial.round_to ?? '',
|
||||
conditions: initial.conditions ?? [],
|
||||
targets: (initial.targets ?? []).map(normalizeTarget),
|
||||
} : blank)
|
||||
|
||||
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
|
||||
|
||||
const addCondition = () => set('conditions', [...f.conditions, { condition_type: 'time_range', params: {} }])
|
||||
const updateCondition = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
|
||||
const removeCondition = i => set('conditions', f.conditions.filter((_, j) => j !== i))
|
||||
|
||||
const addTarget = () => set('targets', [...f.targets, { target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
|
||||
const updateTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
|
||||
const removeTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
|
||||
if (f.scope === 'item' && !f.item_id) return toast.error('Επιλέξτε προϊόν για item-scope')
|
||||
onSave({ ...f, round_to: f.round_to || null })
|
||||
}
|
||||
|
||||
const actionLabel = f.action_type === 'set' ? 'Νέα τιμή (€)' :
|
||||
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
|
||||
'Ποσοστό (%, αρνητικό = έκπτωση)'
|
||||
|
||||
return (
|
||||
<form id="modifier-form" onSubmit={submit} className="space-y-5">
|
||||
|
||||
{/* ── Info tab ── */}
|
||||
{activeTab === 'info' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
|
||||
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="π.χ. Happy Hour -10%" autoFocus />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
|
||||
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map(c => (
|
||||
<button type="button" key={c} onClick={() => set('color', c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[
|
||||
{ key: 'is_active', label: 'Ενεργός' },
|
||||
{ key: 'is_favorite', label: 'Αγαπημένος (dashboard)' },
|
||||
{ key: 'allow_stack', label: 'Επιτρέπει συσσώρευση' },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<div onClick={() => set(key, !f[key])}
|
||||
className={`relative rounded-full transition-colors ${f[key] ? 'bg-sky-500' : 'bg-slate-200'}`}
|
||||
style={{ height: '18px', width: '32px' }}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f[key] ? 'translate-x-3.5' : ''}`} />
|
||||
</div>
|
||||
<span className="text-[12px] text-slate-700">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Target tab ── */}
|
||||
{activeTab === 'target' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Εμβέλεια</label>
|
||||
<div className="flex gap-2">
|
||||
{[['global','Καθολικός (με στόχους)'],['item','Συγκεκριμένο προϊόν']].map(([v, l]) => (
|
||||
<button type="button" key={v} onClick={() => set('scope', v)}
|
||||
className={`flex-1 py-2 rounded-lg border text-[12px] font-medium transition-colors ${
|
||||
f.scope === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
{f.scope === 'item' && (
|
||||
<select className="input w-full mt-2 text-[13px]" value={f.item_id ?? ''}
|
||||
onChange={e => set('item_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή προϊόντος —</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{f.scope === 'global' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">
|
||||
Στόχοι <span className="font-normal text-slate-400 normal-case ml-1">(OR — αρκεί ένας)</span>
|
||||
</span>
|
||||
<button type="button" onClick={addTarget}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
{f.targets.length === 0 && (
|
||||
<p className="text-[12px] text-amber-600 bg-amber-50 rounded-lg px-3 py-2.5">
|
||||
Χωρίς στόχο — ο τροποποιητής δεν θα εφαρμοστεί σε κανένα προϊόν.
|
||||
</p>
|
||||
)}
|
||||
{f.targets.map((t, i) => (
|
||||
<TargetEditor key={i} target={t}
|
||||
onChange={v => updateTarget(i, v)}
|
||||
onRemove={() => removeTarget(i)}
|
||||
products={products} categories={categories} prepZones={prepZones} allTags={allTags} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Conditions tab ── */}
|
||||
{activeTab === 'conditions' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Συνθήκες</span>
|
||||
<span className="text-[11px] text-slate-400 ml-1.5">AND — όλες πρέπει να ισχύουν</span>
|
||||
</div>
|
||||
<button type="button" onClick={addCondition}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
{f.conditions.length === 0 && (
|
||||
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
|
||||
Χωρίς συνθήκες — ο τροποποιητής εφαρμόζεται χειροκίνητα μέσω ενεργοποίησης.
|
||||
</p>
|
||||
)}
|
||||
{f.conditions.map((c, i) => (
|
||||
<ConditionEditor key={i} cond={c}
|
||||
onChange={v => updateCondition(i, v)}
|
||||
onRemove={() => removeCondition(i)}
|
||||
groups={groups} categories={categories} prepZones={prepZones} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Price Modifier tab ── */}
|
||||
{activeTab === 'price' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{[['set','Ορισμός τιμής'],['add_amount','± Ποσό €'],['add_percent','± Ποσοστό %']].map(([v,l]) => (
|
||||
<button type="button" key={v} onClick={() => set('action_type', v)}
|
||||
className={`flex-1 py-1.5 rounded-lg border text-[12px] font-medium transition-colors ${
|
||||
f.action_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">{actionLabel}</label>
|
||||
<input type="number" step={f.action_type === 'set' ? 0.01 : f.action_type === 'add_amount' ? 0.1 : 1}
|
||||
className="input w-full" value={f.action_value}
|
||||
onChange={e => set('action_value', Number(e.target.value))} />
|
||||
</div>
|
||||
{f.action_type === 'add_percent' && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Στρογγυλοποίηση (προαιρ.)</label>
|
||||
<select className="input w-full text-[12px]" value={f.round_to ?? ''}
|
||||
onChange={e => set('round_to', e.target.value)}>
|
||||
{ROUND_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Το σύστημα στρογγυλοποιεί πάντα την τελική τιμή στο κοντινότερο €0,10 ανεξάρτητα από την παραπάνω επιλογή.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modifier card ────────────────────────────────────────────────────────────
|
||||
|
||||
function ModifierCard({ modifier, onEdit, onDelete, dragHandleProps }) {
|
||||
const qc = useQueryClient()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.toggle(modifier.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }),
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const actionText = modifier.action_type === 'set'
|
||||
? `= €${modifier.action_value}`
|
||||
: modifier.action_type === 'add_amount'
|
||||
? `${modifier.action_value >= 0 ? '+' : ''}€${modifier.action_value}`
|
||||
: `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}%`
|
||||
|
||||
const condCount = modifier.conditions?.length ?? 0
|
||||
const targetSummary = modifier.scope === 'item'
|
||||
? 'item-scope'
|
||||
: modifier.targets?.length === 1 && modifier.targets[0]?.target_type === 'all'
|
||||
? 'Όλα τα προϊόντα'
|
||||
: `${modifier.targets?.length ?? 0} στόχοι`
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm transition-opacity ${modifier.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
{/* Drag handle */}
|
||||
<div {...dragHandleProps} className="cursor-grab text-slate-300 hover:text-slate-400 shrink-0">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Color bar */}
|
||||
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: modifier.color ?? '#64748b' }} />
|
||||
|
||||
{/* Name + badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-semibold text-slate-800 truncate">{modifier.name}</span>
|
||||
{modifier.is_favorite && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
|
||||
{modifier.allow_stack && <Layers className="w-3 h-3 text-sky-400 shrink-0" title="Stackable" />}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
|
||||
modifier.action_value < 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-sky-100 text-sky-700'
|
||||
}`}>{actionText}</span>
|
||||
<span className="text-[10px] text-slate-400">{condCount === 0 ? 'χειροκίνητος' : `${condCount} συνθ.`} · {targetSummary}</span>
|
||||
</div>
|
||||
{modifier.description && <p className="text-[11px] text-slate-400 truncate mt-0.5">{modifier.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 transition-colors">
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => toggle.mutate()}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
modifier.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
|
||||
}`} title={modifier.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onEdit(modifier)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(modifier)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-100 px-4 py-3 text-[12px] text-slate-500 space-y-1.5">
|
||||
{modifier.conditions?.length === 0 && <p className="italic">Χωρίς αυτόματες συνθήκες — μόνο χειροκίνητη ενεργοποίηση</p>}
|
||||
{modifier.conditions?.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-sky-400 shrink-0" />
|
||||
<span>{CONDITION_TYPES.find(x => x.value === c.condition_type)?.label ?? c.condition_type}</span>
|
||||
<span className="text-slate-400 font-mono text-[11px]">{JSON.stringify(c.params)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ModifiersTab() {
|
||||
const qc = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
const [formTab, setFormTab] = useState('info')
|
||||
const [scopeFilter, setScopeFilter] = useState('all')
|
||||
const [items, setItems] = useState(null)
|
||||
const [dragging, setDragging] = useState(null)
|
||||
|
||||
const { data: rawModifiers = [], isLoading } = useQuery({
|
||||
queryKey: ['pricing-modifiers'],
|
||||
queryFn: () => api.list(),
|
||||
})
|
||||
const modifiers = items ?? rawModifiers
|
||||
|
||||
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.groups, staleTime: 30_000 })
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: api.create,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setShowForm(false); toast.success('Δημιουργήθηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }) => api.update(id, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setEditing(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.remove,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setDeleting(null); toast.success('Διαγράφηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const reorder = useMutation({
|
||||
mutationFn: api.reorder,
|
||||
onError: () => { setItems(null); toast.error('Σφάλμα αναδιάταξης') },
|
||||
})
|
||||
|
||||
// Simple drag-to-reorder (no extra library — HTML5 DnD)
|
||||
function handleDragStart(e, id) {
|
||||
setDragging(id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
function handleDragOver(e, id) {
|
||||
e.preventDefault()
|
||||
if (dragging == null || dragging === id) return
|
||||
const from = modifiers.findIndex(m => m.id === dragging)
|
||||
const to = modifiers.findIndex(m => m.id === id)
|
||||
if (from === -1 || to === -1) return
|
||||
const reordered = [...modifiers]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
setItems(reordered)
|
||||
}
|
||||
function handleDrop() {
|
||||
setDragging(null)
|
||||
const payload = (items ?? modifiers).map((m, i) => ({ id: m.id, sort_order: i }))
|
||||
reorder.mutate(payload)
|
||||
}
|
||||
|
||||
const filtered = scopeFilter === 'all' ? modifiers : modifiers.filter(m => m.scope === scopeFilter)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Τροποποιητές Τιμής</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Κανόνες που αλλάζουν τιμές αυτόματα ή χειροκίνητα. Η σειρά καθορίζει προτεραιότητα.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />Νέος Τροποποιητής
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex gap-2">
|
||||
{[['all','Όλοι'],['global','Καθολικοί'],['item','Item-scope']].map(([v,l]) => (
|
||||
<button key={v} onClick={() => setScopeFilter(v)}
|
||||
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||
scopeFilter === v ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Percent className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-[13px]">Δεν υπάρχουν τροποποιητές</p>
|
||||
<p className="text-[12px] mt-1">Δημιουργήστε έναν για Happy Hour, εποχιακές τιμές κ.λπ.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2" onDragOver={e => e.preventDefault()} onDrop={handleDrop}>
|
||||
{filtered.map(m => (
|
||||
<div key={m.id} draggable
|
||||
onDragStart={e => handleDragStart(e, m.id)}
|
||||
onDragOver={e => handleDragOver(e, m.id)}
|
||||
className={dragging === m.id ? 'opacity-50' : ''}>
|
||||
<ModifierCard modifier={m} onEdit={m => { setEditing(m); setFormTab('info') }} onDelete={setDeleting}
|
||||
dragHandleProps={{
|
||||
onMouseDown: () => {},
|
||||
title: 'Σύρετε για αναδιάταξη',
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create */}
|
||||
{showForm && (
|
||||
<Modal title="Νέος Τροποποιητής Τιμής" onClose={() => { setShowForm(false); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="modifier-form" variant="primary" disabled={create.isPending}>
|
||||
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<ModifierForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
|
||||
saving={create.isPending} groups={groups} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{editing && (
|
||||
<Modal title="Επεξεργασία Τροποποιητή" onClose={() => { setEditing(null); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="modifier-form" variant="primary" disabled={update.isPending}>
|
||||
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<ModifierForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
|
||||
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
|
||||
groups={groups} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{deleting && (
|
||||
<ConfirmModal title="Διαγραφή Τροποποιητή"
|
||||
message={`Θέλετε να διαγράψετε τον τροποποιητή "${deleting.name}";`}
|
||||
confirmLabel="Διαγραφή"
|
||||
onConfirm={() => remove.mutate(deleting.id)}
|
||||
onCancel={() => setDeleting(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Tag, Power, Pencil, Trash2, Clock } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import { ConfirmModal } from '../../../ui/Modal'
|
||||
|
||||
const DAYS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυρ']
|
||||
|
||||
const api = {
|
||||
list: () => client.get('/api/pricing/groups').then(r => r.data),
|
||||
create: body => client.post('/api/pricing/groups', body).then(r => r.data),
|
||||
update: (id,b) => client.put(`/api/pricing/groups/${id}`, b).then(r => r.data),
|
||||
toggle: id => client.patch(`/api/pricing/groups/${id}/toggle`).then(r => r.data),
|
||||
remove: id => client.delete(`/api/pricing/groups/${id}`),
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
|
||||
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
|
||||
]
|
||||
|
||||
function GroupForm({ initial, onSave, onCancel, saving }) {
|
||||
const blank = { name: '', description: '', color: COLORS[0], is_active: false,
|
||||
auto_enable_time: '', auto_disable_time: '', auto_days: [] }
|
||||
const [f, setF] = useState(initial ? {
|
||||
...initial,
|
||||
auto_days: initial.auto_days ?? [],
|
||||
} : blank)
|
||||
|
||||
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
|
||||
const toggleDay = d => set('auto_days', f.auto_days.includes(d)
|
||||
? f.auto_days.filter(x => x !== d)
|
||||
: [...f.auto_days, d])
|
||||
|
||||
const hasSchedule = f.auto_enable_time || f.auto_disable_time || f.auto_days.length > 0
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
|
||||
onSave({
|
||||
...f,
|
||||
auto_days: f.auto_days.length ? f.auto_days : null,
|
||||
auto_enable_time: f.auto_enable_time || null,
|
||||
auto_disable_time: f.auto_disable_time || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
|
||||
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="π.χ. Happy Hour" autoFocus />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
|
||||
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)}
|
||||
placeholder="Σύντομη περιγραφή..." />
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-2">Χρώμα</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map(c => (
|
||||
<button type="button" key={c} onClick={() => set('color', c)}
|
||||
className={`w-7 h-7 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-2 ring-slate-400 scale-110' : 'hover:scale-105'}`}
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
<input type="color" value={f.color ?? '#6366f1'} onChange={e => set('color', e.target.value)}
|
||||
className="w-7 h-7 rounded-full border-0 cursor-pointer p-0.5 bg-transparent" title="Προσαρμοσμένο χρώμα" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active toggle */}
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<div onClick={() => set('is_active', !f.is_active)}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-slate-700">Ενεργή τώρα</span>
|
||||
</label>
|
||||
|
||||
{/* Auto-schedule */}
|
||||
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Αυτόματο Χρονοδιάγραμμα</span>
|
||||
<span className="text-[11px] text-slate-400">(προαιρετικό)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Ενεργοποίηση</label>
|
||||
<input type="time" className="input w-full text-[13px]"
|
||||
value={f.auto_enable_time ?? ''} onChange={e => set('auto_enable_time', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-1">Απενεργοποίηση</label>
|
||||
<input type="time" className="input w-full text-[13px]"
|
||||
value={f.auto_disable_time ?? ''} onChange={e => set('auto_disable_time', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-1.5">Ημέρες</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{DAYS.map((d, i) => (
|
||||
<button type="button" key={i} onClick={() => toggleDay(i)}
|
||||
className={`px-2.5 py-1 rounded text-[12px] font-medium transition-colors ${
|
||||
f.auto_days.includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSchedule && (!f.auto_enable_time || !f.auto_disable_time || !f.auto_days.length) && (
|
||||
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1">
|
||||
Για πλήρες χρονοδιάγραμμα ορίστε ώρα ενεργοποίησης, ώρα απενεργοποίησης και τουλάχιστον μία ημέρα.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>Ακύρωση</Button>
|
||||
<Button type="submit" variant="primary" disabled={saving}>
|
||||
{saving ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupCard({ group, onEdit, onDelete }) {
|
||||
const qc = useQueryClient()
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.toggle(group.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-groups'] }),
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex items-start gap-3">
|
||||
{/* Color dot */}
|
||||
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: group.color ?? '#6366f1' }} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14px] font-semibold text-slate-800 truncate">{group.name}</span>
|
||||
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
group.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>{group.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
|
||||
</div>
|
||||
{group.description && <p className="text-[12px] text-slate-500 mt-0.5 truncate">{group.description}</p>}
|
||||
{group.auto_enable_time && group.auto_disable_time && (
|
||||
<p className="text-[11px] text-slate-400 mt-1 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{group.auto_enable_time}–{group.auto_disable_time}
|
||||
{group.auto_days?.length > 0 && (
|
||||
<span className="ml-1">{group.auto_days.map(d => DAYS[d]).join(', ')}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Quick toggle */}
|
||||
<button onClick={() => toggle.mutate()}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
group.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
|
||||
}`} title={group.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onEdit(group)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(group)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PriceGroupsTab() {
|
||||
const qc = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.list })
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: api.create,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setShowForm(false); toast.success('Ομάδα δημιουργήθηκε') },
|
||||
onError: () => toast.error('Σφάλμα δημιουργίας'),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }) => api.update(id, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.remove,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setDeleting(null); toast.success('Διαγράφηκε') },
|
||||
onError: () => toast.error('Σφάλμα διαγραφής'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Ομάδες Τιμών</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Ονομαστές ομάδες που ενεργοποιούνται χειροκίνητα ή αυτόματα. Χρησιμοποιούνται ως συνθήκη σε τροποποιητές τιμής.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />Νέα Ομάδα
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Tag className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-[13px]">Δεν υπάρχουν ομάδες τιμών</p>
|
||||
<p className="text-[12px] mt-1">Δημιουργήστε μια ομάδα για Happy Hour, Εποχή κ.λπ.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map(g => (
|
||||
<GroupCard key={g.id} group={g}
|
||||
onEdit={setEditing}
|
||||
onDelete={setDeleting} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showForm && (
|
||||
<Modal title="Νέα Ομάδα Τιμών" onClose={() => setShowForm(false)} maxWidth="max-w-md">
|
||||
<GroupForm onSave={body => create.mutate(body)} onCancel={() => setShowForm(false)} saving={create.isPending} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editing && (
|
||||
<Modal title="Επεξεργασία Ομάδας" onClose={() => setEditing(null)} maxWidth="max-w-md">
|
||||
<GroupForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
|
||||
onCancel={() => setEditing(null)} saving={update.isPending} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleting && (
|
||||
<ConfirmModal
|
||||
title="Διαγραφή Ομάδας"
|
||||
message={`Θέλετε σίγουρα να διαγράψετε την ομάδα "${deleting.name}"; Τυχόν τροποποιητές που τη χρησιμοποιούν δεν θα επηρεαστούν αλλά η συνθήκη δεν θα εκπληρώνεται πλέον.`}
|
||||
confirmLabel="Διαγραφή"
|
||||
onConfirm={() => remove.mutate(deleting.id)}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
854
manager_dashboard/src/pages/ManagerOrderDrawer.jsx
Normal file
854
manager_dashboard/src/pages/ManagerOrderDrawer.jsx
Normal file
@@ -0,0 +1,854 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DECIMAL_UNIT_CONFIG = {
|
||||
kg: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'kg' },
|
||||
liter: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'L' },
|
||||
gram: { step: 10, min: 10, presets: [50, 100, 150, 200, 250, 300, 400, 500], offsets: [10, 50, 100, 200], label: 'g' },
|
||||
ml: { step: 5, min: 5, presets: [25, 50, 100, 150, 200, 250, 300, 500], offsets: [25, 50, 100, 200], label: 'mL' },
|
||||
}
|
||||
|
||||
function fmtDecimalQty(qty, cfg) {
|
||||
if (!cfg) return String(qty)
|
||||
if (cfg.label === 'kg' || cfg.label === 'L') {
|
||||
const n = Number(qty)
|
||||
return n % 1 === 0 ? n.toFixed(1) : (Math.round(n * 100) / 100).toFixed(2).replace(/0$/, '')
|
||||
}
|
||||
return String(qty)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
|
||||
const PRICE_DELTAS = [-5, -3, -2, -1, -0.5, -0.2, +0.2, +0.5, +1, +2, +3, +5]
|
||||
|
||||
// ── Primitive UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CheckCircle({ selected }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
|
||||
background: selected ? '#f59e0b' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 120ms ease',
|
||||
}}>
|
||||
{selected && <svg width="12" height="12" 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: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
|
||||
background: 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{selected && <div style={{ width: 9, height: 9, borderRadius: '50%', background: '#f59e0b' }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ selected, onClick, children, right, left }) {
|
||||
return (
|
||||
<div onClick={onClick} style={{
|
||||
padding: '10px 12px',
|
||||
background: selected ? 'rgba(245,158,11,0.08)' : '#f8fafc',
|
||||
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : '#e2e8f0'}`,
|
||||
borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
minHeight: 50,
|
||||
}}>
|
||||
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stepper({ value, onChange, min = 0, max = 99 }) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', height: 36, borderRadius: 18, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
|
||||
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? '#94a3b8' : '#1e293b' }}>−</button>
|
||||
<div style={{ minWidth: 26, textAlign: 'center', fontSize: 14, fontWeight: 700, color: '#1e293b' }}>{value}</div>
|
||||
<button onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max}
|
||||
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? '#94a3b8' : '#1e293b' }}>+</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Option rows ───────────────────────────────────────────────────────────────
|
||||
|
||||
function QuickOptionRow({ opt, quickState, setQuickState }) {
|
||||
const qty = quickState[opt.id] || 0
|
||||
const selected = qty > 0
|
||||
return (
|
||||
<Row selected={selected} onClick={() => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))}
|
||||
left={<CheckCircle selected={selected} />}
|
||||
right={opt.allow_multiple ? (
|
||||
<div 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: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="12" height="12" 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: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
|
||||
{opt.price > 0 && <div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>+{opt.price.toFixed(2)} €</div>}
|
||||
</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: 4 }} onClick={e => e.stopPropagation()}>
|
||||
{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: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="12" height="12" 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: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>
|
||||
{(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: '5px 0 2px 14px', paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 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: 13, color: '#1e293b' }}>{sub.name}</div>
|
||||
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} €</div>}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: 30, padding: '0 12px', borderRadius: 15,
|
||||
background: removed ? '#ef4444' : '#f1f5f9',
|
||||
border: `1px solid ${removed ? '#ef4444' : '#e2e8f0'}`,
|
||||
color: removed ? '#fff' : '#475569',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
}}>
|
||||
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: removed ? '#94a3b8' : '#1e293b', textDecoration: removed ? 'line-through' : 'none' }}>{ing.name}</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
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 8px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: complete ? '#1e293b' : '#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: 6 }}>
|
||||
{ps.choices.map(ch => {
|
||||
const isSel = selChoice?.id === ch.id
|
||||
const hasSubs = ch.sub_choices?.length > 0
|
||||
return (
|
||||
<div key={ch.id}>
|
||||
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
|
||||
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: '#64748b' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{ch.name}</div>
|
||||
</Row>
|
||||
{isSel && hasSubs && (
|
||||
<div style={{ margin: '5px 0 2px 14px', paddingLeft: 12, borderLeft: `2px solid ${subChoices[ch.id] == null ? '#ef4444' : 'rgba(245,158,11,0.35)'}`, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{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: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showShared && (
|
||||
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', 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: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} €</div> : null}>
|
||||
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab content ───────────────────────────────────────────────────────────────
|
||||
|
||||
function QuickTab({ product, quickState, setQuickState }) {
|
||||
const quickOptions = product.quick_options || []
|
||||
if (quickOptions.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 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} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
|
||||
const options = product.options || []
|
||||
if (options.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map(opt => (
|
||||
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IngredientsTab({ product, removedState, setRemovedState }) {
|
||||
const ingredients = product.ingredients || []
|
||||
if (ingredients.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ padding: '8px 12px', background: '#f1f5f9', borderRadius: 8, fontSize: 12, color: '#64748b', marginBottom: 2 }}>
|
||||
Κάντε κλικ για να αφαιρέσετε υλικό.
|
||||
</div>
|
||||
{ingredients.map(ing => (
|
||||
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
|
||||
const preferenceSets = product.preference_sets || []
|
||||
if (preferenceSets.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{preferenceSets.map(ps => (
|
||||
<PrefSetBlock key={ps.id} ps={ps}
|
||||
prefs={prefs} setPrefs={setPrefs}
|
||||
subChoices={subChoices} setSubChoices={setSubChoices}
|
||||
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuantityTab({ unitCfg, qty, setQty }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [qtyInput, setQtyInput] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
function clamp(v) { return Math.max(unitCfg.min, Math.round(v * 1000) / 1000) }
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', height: 48, borderRadius: 24, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}>
|
||||
<button onClick={() => setQty(q => clamp(q - unitCfg.step))}
|
||||
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>−</button>
|
||||
{editing ? (
|
||||
<input ref={inputRef} type="number" inputMode="decimal" value={qtyInput}
|
||||
onChange={e => setQtyInput(e.target.value)}
|
||||
onBlur={() => { const v = parseFloat(qtyInput); if (!isNaN(v)) setQty(clamp(v)); setEditing(false) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
|
||||
autoFocus
|
||||
style={{ width: 70, height: 48, border: 'none', background: 'transparent', textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div onClick={() => { setQtyInput(String(qty)); setEditing(true) }}
|
||||
style={{ minWidth: 70, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', cursor: 'text', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
{fmtDecimalQty(qty, unitCfg)}
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.65 }}>{unitCfg.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setQty(q => clamp(q + unitCfg.step))}
|
||||
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΕΠΙΛΟΓΗ</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
|
||||
{unitCfg.presets.map(v => (
|
||||
<button key={v} onClick={() => setQty(v)} style={{
|
||||
padding: '8px 4px', borderRadius: 10, border: 'none',
|
||||
background: qty === v ? '#f59e0b' : '#f1f5f9',
|
||||
color: qty === v ? '#fff' : '#1e293b',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}>
|
||||
{fmtDecimalQty(v, unitCfg)}{unitCfg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΠΡΟΣΘΗΚΗ</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 6 }}>
|
||||
{unitCfg.offsets.map(off => (
|
||||
<button key={`+${off}`} onClick={() => setQty(q => clamp(q + off))} style={{
|
||||
padding: '8px 4px', borderRadius: 10, border: 'none',
|
||||
background: 'rgba(34,197,94,0.1)', color: '#16a34a',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}>+{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
|
||||
))}
|
||||
{unitCfg.offsets.map(off => (
|
||||
<button key={`-${off}`} onClick={() => setQty(q => clamp(q - off))} style={{
|
||||
padding: '8px 4px', borderRadius: 10, border: 'none',
|
||||
background: 'rgba(239,68,68,0.08)', color: '#dc2626',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}>−{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceAdjustTab({ basePrice, priceAdj, setPriceAdj }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [rawInput, setRawInput] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
const effectivePrice = basePrice + priceAdj
|
||||
|
||||
function applyDelta(d) {
|
||||
setPriceAdj(a => {
|
||||
const next = Math.round((a + d) * 100) / 100
|
||||
return Math.max(-(basePrice - 0.01), next)
|
||||
})
|
||||
}
|
||||
|
||||
function commitInput() {
|
||||
const v = parseFloat(rawInput.replace(',', '.'))
|
||||
if (!isNaN(v) && v >= 0.01) setPriceAdj(Math.round((v - basePrice) * 100) / 100)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ padding: '8px 12px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, fontSize: 12, color: '#d97706' }}>
|
||||
Αλλαγή τιμής μόνο για αυτό το αντικείμενο.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 }}>
|
||||
<button onClick={() => applyDelta(-0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}>−</button>
|
||||
{editing ? (
|
||||
<input ref={inputRef} type="number" inputMode="decimal" value={rawInput}
|
||||
onChange={e => setRawInput(e.target.value)}
|
||||
onBlur={commitInput}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
|
||||
style={{ width: 100, height: 48, border: '2px solid #f59e0b', borderRadius: 12, background: 'white', textAlign: 'center', fontSize: 20, fontWeight: 800, color: '#f59e0b', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<button onClick={() => { setRawInput(effectivePrice.toFixed(2)); setEditing(true) }} style={{
|
||||
minWidth: 100, height: 48, borderRadius: 12,
|
||||
border: `2px solid ${priceAdj !== 0 ? '#f59e0b' : '#e2e8f0'}`,
|
||||
background: priceAdj !== 0 ? 'rgba(245,158,11,0.08)' : '#f8fafc',
|
||||
color: priceAdj !== 0 ? '#f59e0b' : '#1e293b',
|
||||
fontSize: 20, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{effectivePrice.toFixed(2)} €
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => applyDelta(+0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}>+</button>
|
||||
</div>
|
||||
{priceAdj !== 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>Βάση: {basePrice.toFixed(2)} €</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: priceAdj > 0 ? '#16a34a' : '#dc2626' }}>{priceAdj > 0 ? '+' : ''}{priceAdj.toFixed(2)} €</span>
|
||||
<button onClick={() => setPriceAdj(0)} style={{ padding: '2px 8px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f1f5f9', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>Επαναφορά</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||
{PRICE_DELTAS.map(d => (
|
||||
<button key={d} onClick={() => applyDelta(d)} style={{
|
||||
padding: '9px 4px', borderRadius: 10, border: 'none',
|
||||
background: d < 0 ? 'rgba(239,68,68,0.08)' : 'rgba(34,197,94,0.08)',
|
||||
color: d < 0 ? '#dc2626' : '#16a34a',
|
||||
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||
}}>
|
||||
{d > 0 ? '+' : ''}{d.toFixed(2 - (Number.isInteger(d) ? 2 : 0))} €
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesTab({ note, setNote }) {
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
|
||||
rows={4}
|
||||
style={{ width: '100%', padding: 12, fontSize: 14, fontFamily: 'inherit', color: '#1e293b', background: 'white', border: '1px solid #e2e8f0', borderRadius: 10, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 14, marginBottom: 6 }}>Γρήγορες σημειώσεις</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{QUICK_NOTES.map(q => (
|
||||
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
|
||||
style={{ height: 32, padding: '0 12px', borderRadius: 16, background: '#f1f5f9', border: '1px solid #e2e8f0', color: '#475569', fontSize: 12, fontWeight: 500, cursor: 'pointer' }}>
|
||||
+ {q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Courses tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CoursesTab({ courses, courseId, setCourseId }) {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setCourseId(null)}
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
|
||||
background: courseId === null ? '#2563eb' : '#f8fafc',
|
||||
color: courseId === null ? '#fff' : '#1e293b',
|
||||
border: `2px solid ${courseId === null ? '#2563eb' : '#e2e8f0'}`,
|
||||
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
No course (fire immediately)
|
||||
</button>
|
||||
{courses.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setCourseId(courseId === c.id ? null : c.id)}
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
|
||||
background: courseId === c.id ? c.color : '#f8fafc',
|
||||
color: courseId === c.id ? '#fff' : '#1e293b',
|
||||
border: `2px solid ${courseId === c.id ? c.color : '#e2e8f0'}`,
|
||||
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
background: courseId === c.id ? 'rgba(255,255,255,0.5)' : c.color,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ManagerOrderDrawer({ product, onClose, onAdd, initialState, courses }) {
|
||||
const preferenceSets = product?.preference_sets || []
|
||||
const quickOptions = product?.quick_options || []
|
||||
const options = product?.options || []
|
||||
const ingredients = product?.ingredients || []
|
||||
|
||||
const unitCfg = product ? (DECIMAL_UNIT_CONFIG[product.unit_type] ?? null) : null
|
||||
const isDecimal = !!unitCfg
|
||||
|
||||
const hasTabs = {
|
||||
quick: quickOptions.length > 0,
|
||||
extras: options.length > 0,
|
||||
ingredients: ingredients.length > 0,
|
||||
prefs: preferenceSets.length > 0,
|
||||
quantity: isDecimal,
|
||||
}
|
||||
|
||||
const firstTab = hasTabs.quick ? 'quick'
|
||||
: hasTabs.extras ? 'extras'
|
||||
: hasTabs.ingredients ? 'ingredients'
|
||||
: hasTabs.prefs ? 'prefs'
|
||||
: hasTabs.quantity ? 'quantity'
|
||||
: 'notes'
|
||||
|
||||
const [activeTab, setActiveTab] = useState(firstTab)
|
||||
const [qty, setQty] = useState(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 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 [priceAdj, setPriceAdj] = useState(0)
|
||||
const [addAttempted, setAddAttempted] = useState(false)
|
||||
const [courseId, setCourseId] = useState(initialState?.courseId ?? null)
|
||||
|
||||
// Reset when product changes
|
||||
useEffect(() => {
|
||||
if (!product) return
|
||||
const base = buildInitialState(product)
|
||||
if (initialState) {
|
||||
setQty(initialState.qty ?? (isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 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 ?? '')
|
||||
setPriceAdj(initialState.priceAdj ?? 0)
|
||||
setCourseId(initialState.courseId ?? null)
|
||||
setActiveTab(initialState.activeTab ?? firstTab)
|
||||
} else {
|
||||
setQty(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
|
||||
setQuickState({})
|
||||
setExtrasState({})
|
||||
setRemovedState({})
|
||||
setPrefs(base.prefs)
|
||||
setSubChoices(base.subChoices)
|
||||
setSharedSubs(base.sharedSubs)
|
||||
setNote('')
|
||||
setPriceAdj(0)
|
||||
setCourseId(null)
|
||||
setActiveTab(firstTab)
|
||||
}
|
||||
setExpandedExtra(null)
|
||||
setAddAttempted(false)
|
||||
}, [product?.id])
|
||||
|
||||
// Derived total price
|
||||
const totalPrice = (() => {
|
||||
if (!product) return 0
|
||||
let price = product.base_price
|
||||
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
|
||||
price += (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||
})
|
||||
quickOptions.forEach(opt => { price += (quickState[opt.id] || 0) * (opt.price ?? 0) })
|
||||
options.forEach(opt => {
|
||||
const sel = extrasState[opt.id]
|
||||
if (!sel) return
|
||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||
price += ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
|
||||
})
|
||||
return (price + priceAdj) * qty
|
||||
})()
|
||||
|
||||
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
|
||||
|
||||
function handleAdd() {
|
||||
if (!canAdd) {
|
||||
setAddAttempted(true)
|
||||
if (!allPrefsOk && 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
|
||||
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,
|
||||
unit_price: product.base_price,
|
||||
selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
|
||||
removed_ingredients: removedNames,
|
||||
notes: note,
|
||||
price_adjustment: priceAdj !== 0 ? priceAdj : undefined,
|
||||
course_id: courseId ?? undefined,
|
||||
_drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note, priceAdj, courseId },
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
hasTabs.quick && { id: 'quick', label: 'Quick' },
|
||||
hasTabs.extras && { id: 'extras', label: 'Extras' },
|
||||
hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
|
||||
hasTabs.prefs && { id: 'prefs', label: 'Προτιμήσεις' },
|
||||
hasTabs.quantity && { id: 'quantity', label: `Ποσότητα (${unitCfg?.label})` },
|
||||
(courses?.length > 0) && { id: 'courses', label: 'Courses' },
|
||||
{ id: 'notes', label: 'Σημείωση' },
|
||||
{ id: 'price_adjust', label: '€ Τιμή' },
|
||||
].filter(Boolean)
|
||||
|
||||
if (!product) return null
|
||||
|
||||
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', height: '100%',
|
||||
background: 'white', borderLeft: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px 16px 12px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
|
||||
background: '#f1f5f9', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{product.image_url
|
||||
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <span style={{ fontSize: 18, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', lineHeight: 1.2, marginBottom: 3 }}>{product.name}</div>
|
||||
<div style={{ fontSize: 13, color: priceAdj !== 0 ? '#f59e0b' : '#64748b' }}>
|
||||
{priceAdj !== 0
|
||||
? <><span style={{ textDecoration: 'line-through', opacity: 0.5, marginRight: 4 }}>{product.base_price.toFixed(2)}</span>→ {(product.base_price + priceAdj).toFixed(2)} €</>
|
||||
: <>{product.base_price.toFixed(2)} €</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0',
|
||||
background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', flexShrink: 0, color: '#64748b',
|
||||
}}>
|
||||
<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>
|
||||
|
||||
{/* Tabs bar */}
|
||||
{tabs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 2, overflowX: 'auto', scrollbarWidth: 'none' }}>
|
||||
{tabs.map(t => {
|
||||
const active = activeTab === t.id
|
||||
const isAlert = t.id === 'prefs' && !allPrefsOk && addAttempted
|
||||
return (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
|
||||
padding: '6px 10px',
|
||||
background: 'none', border: 'none',
|
||||
borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
|
||||
color: isAlert ? '#ef4444' : active ? '#f59e0b' : '#64748b',
|
||||
fontSize: 12, fontWeight: active ? 700 : 500,
|
||||
fontFamily: 'inherit', cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 100ms, border-color 100ms',
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 14px 10px' }}>
|
||||
{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 === 'quantity' && isDecimal && <QuantityTab unitCfg={unitCfg} qty={qty} setQty={setQty} />}
|
||||
{activeTab === 'courses' && courses?.length > 0 && <CoursesTab courses={courses} courseId={courseId} setCourseId={setCourseId} />}
|
||||
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
|
||||
{activeTab === 'price_adjust' && <PriceAdjustTab basePrice={product.base_price} priceAdj={priceAdj} setPriceAdj={setPriceAdj} />}
|
||||
</div>
|
||||
|
||||
{/* Footer: qty stepper + ADD button */}
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
background: '#f8fafc',
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0,
|
||||
}}>
|
||||
{isDecimal ? (
|
||||
<div onClick={() => setActiveTab('quantity')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0, cursor: 'pointer', padding: '0 14px', gap: 4 }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{fmtDecimalQty(qty, unitCfg)}</span>
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>{unitCfg.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<button onClick={() => setQty(q => Math.max(1, q - 1))} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? '#94a3b8' : '#1e293b' }}>−</button>
|
||||
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{qty}</div>
|
||||
<button onClick={() => setQty(q => q + 1)} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleAdd} style={{
|
||||
flex: 1, height: 44, borderRadius: 22,
|
||||
background: canAdd ? '#f59e0b' : '#e2e8f0',
|
||||
border: 'none', color: canAdd ? '#fff' : '#94a3b8',
|
||||
fontSize: 14, fontWeight: 700, fontFamily: 'inherit',
|
||||
cursor: canAdd ? 'pointer' : 'not-allowed',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 18px',
|
||||
transition: 'background 150ms ease',
|
||||
}}>
|
||||
<span>ΠΡΟΣΘΗΚΗ</span>
|
||||
<span>{totalPrice.toFixed(2)} €</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -446,7 +446,6 @@ export default function NotesPage() {
|
||||
display: 'flex', alignItems: 'baseline', gap: 16, flexShrink: 0,
|
||||
background: 'white',
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Σημειώσεις & Εργασίες</h1>
|
||||
<span style={{ fontSize: 13, color: '#9ca3af' }}>
|
||||
{notes.length} σημειώσεις · {todos.filter(t => !t.is_done).length} εκκρεμείς εργασίες
|
||||
</span>
|
||||
|
||||
@@ -19,7 +19,7 @@ function timeAgo(iso) {
|
||||
}
|
||||
|
||||
function calcTotals(items = []) {
|
||||
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
|
||||
return items.reduce((sum, it) => sum + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * (it.quantity ?? 1), 0)
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
@@ -106,7 +106,7 @@ function OrderCard({ order, onAction }) {
|
||||
{order.items.map((it, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<span>{it.quantity} × {it.product?.name ?? `#${it.product_id}`}</span>
|
||||
<span className="text-gray-500">€{(it.unit_price * it.quantity).toFixed(2)}</span>
|
||||
<span className="text-gray-500">€{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -310,25 +310,8 @@ export default function OnlineOrdersPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Page header */}
|
||||
<div className="px-6 py-5 border-b border-gray-100 shrink-0 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-gray-800">Online Παραγγελίες</h1>
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
disabled={loading}
|
||||
className="ml-auto btn btn-secondary text-sm px-3 min-h-0 h-8"
|
||||
>
|
||||
{loading ? '…' : '↻ Ανανέωση'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 shrink-0 bg-white px-4">
|
||||
{/* Tabs + refresh on same row */}
|
||||
<div className="flex-shrink-0 flex items-stretch border-b border-slate-200 px-6 pt-2">
|
||||
{TABS.map(tab => {
|
||||
const count = tab.key === 'incoming' ? incoming.length
|
||||
: tab.key === 'active' ? active.length
|
||||
@@ -338,25 +321,34 @@ export default function OnlineOrdersPage() {
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? 'border-primary-600 text-primary-700 bg-primary-50/50'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-[13.5px] font-medium transition-colors whitespace-nowrap ${
|
||||
isActive ? 'text-slate-900' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
|
||||
<span className={`text-[11px] px-1.5 py-0.5 rounded-full font-mono font-semibold ${
|
||||
tab.key === 'incoming'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
|
||||
: isActive ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{isActive && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-sky-500" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center pb-2">
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg bg-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '…' : '↻ Ανανέωση'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import { Percent } from 'lucide-react'
|
||||
import Badge from '../ui/Badge'
|
||||
import { ConfirmModal } from '../ui/Modal'
|
||||
import PaymentMethodModal from '../ui/PaymentMethodModal'
|
||||
import DesktopOrderingTab from './DesktopOrderingTab'
|
||||
|
||||
function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
|
||||
@@ -37,8 +39,9 @@ function PrintOrderModal({ onClose, onPrint, printers }) {
|
||||
)
|
||||
}
|
||||
|
||||
function effPrice(item) { return (item.unit_price ?? 0) + (item.price_adjustment ?? 0) }
|
||||
function itemTotal(item) {
|
||||
return (item.unit_price * item.quantity).toFixed(2)
|
||||
return (effPrice(item) * item.quantity).toFixed(2)
|
||||
}
|
||||
|
||||
const PAYMENT_METHOD_STYLES = {
|
||||
@@ -147,6 +150,98 @@ function AuditTab({ order, waiterMap }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Price Events Tab ────────────────────────────────────────────────────────
|
||||
|
||||
const PRICE_EVENT_LABELS = {
|
||||
modifier_applied: 'Τροποποιητής',
|
||||
deal_triggered: 'Προσφορά',
|
||||
deal_offer_shown: 'Προσφορά (εμφανίστηκε)',
|
||||
deal_offer_accepted: 'Προσφορά (αποδεκτή)',
|
||||
deal_offer_dismissed:'Προσφορά (απορρίφθηκε)',
|
||||
waiter_discount: 'Έκπτωση σερβιτόρου',
|
||||
free_item_added: 'Δωρεάν αντικείμενο',
|
||||
}
|
||||
|
||||
const PRICE_EVENT_COLORS = {
|
||||
modifier_applied: { bg: '#eff6ff', color: '#1d4ed8', border: '#93c5fd' },
|
||||
deal_triggered: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
|
||||
deal_offer_shown: { bg: '#fefce8', color: '#a16207', border: '#fde68a' },
|
||||
deal_offer_accepted: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
|
||||
deal_offer_dismissed:{ bg: '#fef2f2', color: '#b91c1c', border: '#fca5a5' },
|
||||
waiter_discount: { bg: '#fdf4ff', color: '#7e22ce', border: '#d8b4fe' },
|
||||
free_item_added: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
|
||||
}
|
||||
|
||||
function PriceEventsTab({ orderId }) {
|
||||
const { data: events = [], isLoading } = useQuery({
|
||||
queryKey: ['price-events', orderId],
|
||||
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) return (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (events.length === 0) return (
|
||||
<div className="py-16 text-center text-gray-400">
|
||||
<Percent className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">Δεν υπάρχουν συμβάντα τιμολόγησης για αυτή την παραγγελία.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{events.map(ev => {
|
||||
const label = PRICE_EVENT_LABELS[ev.event_type] ?? ev.event_type
|
||||
const style = PRICE_EVENT_COLORS[ev.event_type] ?? { bg: '#f8fafc', color: '#334155', border: '#cbd5e1' }
|
||||
const hasDelta = ev.price_before != null && ev.price_after != null
|
||||
return (
|
||||
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: style.bg, color: style.color, border: `1px solid ${style.border}` }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 items-baseline">
|
||||
{ev.modifier_name && <span className="font-medium text-gray-800">{ev.modifier_name}</span>}
|
||||
{ev.deal_name && <span className="font-medium text-gray-800">{ev.deal_name}</span>}
|
||||
{ev.applied_by_username && (
|
||||
<span className="text-gray-500 text-xs">από {ev.applied_by_username}</span>
|
||||
)}
|
||||
{hasDelta && (
|
||||
<span className={`text-xs font-semibold ${ev.delta_amount < 0 ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount?.toFixed(2)}€
|
||||
<span className="font-normal text-gray-400 ml-1">
|
||||
({ev.price_before?.toFixed(2)} → {ev.price_after?.toFixed(2)})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ev.waiter_note && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 italic">"{ev.waiter_note}"</p>
|
||||
)}
|
||||
{ev.conditions_snapshot && Object.keys(ev.conditions_snapshot).length > 0 && (
|
||||
<details className="mt-1">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">Συνθήκες</summary>
|
||||
<pre className="text-[10px] text-gray-500 bg-gray-50 rounded p-2 mt-1 overflow-x-auto">
|
||||
{JSON.stringify(ev.conditions_snapshot, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="text-xs text-gray-400">{formatDate(ev.applied_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
|
||||
const { orderId: paramOrderId } = useParams()
|
||||
const orderId = propOrderId ?? paramOrderId
|
||||
@@ -178,6 +273,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: rawPriceEvents = [] } = useQuery({
|
||||
queryKey: ['price-events', orderId],
|
||||
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
|
||||
enabled: !!orderId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const priceEventsMap = rawPriceEvents.reduce((acc, ev) => {
|
||||
if (!acc[ev.order_item_id]) acc[ev.order_item_id] = []
|
||||
acc[ev.order_item_id].push(ev)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const printOrder = useMutation({
|
||||
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
|
||||
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
|
||||
@@ -272,10 +379,52 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
const activeItems = order.items.filter(i => i.status === 'active')
|
||||
const total = order.items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
|
||||
|
||||
const isOpen = ['open', 'partially_paid'].includes(order.status)
|
||||
|
||||
// The ordering tab needs full height and no outer padding
|
||||
if (tab === 'order') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Compact header strip */}
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||
{!propOrderId && (
|
||||
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm">← Πίσω</button>
|
||||
)}
|
||||
<div style={{ fontSize: 14, color: '#64748b' }}>
|
||||
Τραπέζι {order.table_name || order.table_id}
|
||||
</div>
|
||||
<Badge status={order.status} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>€{total.toFixed(2)}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
{/* Tab strip inline */}
|
||||
<div style={{ display: 'flex', gap: 1, borderBottom: 'none' }}>
|
||||
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό']].map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
style={{
|
||||
padding: '6px 14px', fontSize: 13, fontWeight: tab === key ? 700 : 500,
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
borderBottom: `2px solid ${tab === key ? '#4f46e5' : 'transparent'}`,
|
||||
color: tab === key ? '#4f46e5' : '#64748b',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Full-height ordering terminal */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<DesktopOrderingTab
|
||||
orderId={orderId}
|
||||
tableId={order.table_id}
|
||||
orderStatus={order.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-6">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
@@ -286,8 +435,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
{/* Header */}
|
||||
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-gray-500">
|
||||
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
|
||||
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
|
||||
</p>
|
||||
@@ -367,7 +515,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
@@ -423,12 +571,45 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
const isClosedItem = item.status === 'closed'
|
||||
const badgeStatus = isClosedItem ? 'closed_item' : item.status
|
||||
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
|
||||
const isDealItem = !!item.deal_id
|
||||
const itemPriceEvents = (priceEventsMap[item.id] ?? []).filter(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
|
||||
const EVENT_COLORS_ODP = { modifier_applied: '#2563eb', waiter_discount: '#7c3aed', free_item_added: '#16a34a' }
|
||||
return (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
|
||||
<p className="font-medium text-gray-800 text-sm" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{item.product?.name ?? `#${item.product_id}`}
|
||||
{item.course_id != null && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: '#6366f1', color: '#fff',
|
||||
fontSize: 10, fontWeight: 700,
|
||||
}} title="Course">
|
||||
{item.course_id}
|
||||
</span>
|
||||
)}
|
||||
{isDealItem && <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 5px', borderRadius: 4, background: 'rgba(167,139,250,0.12)', color: '#7c3aed', border: '1px solid rgba(167,139,250,0.3)' }}>ΔΩΡΕΑΝ</span>}
|
||||
</p>
|
||||
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
|
||||
<p className="text-xs text-gray-500">x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ</p>
|
||||
<p className="text-xs" style={{ color: hasPriceAdj ? '#3b82f6' : '#6b7280' }}>
|
||||
x{item.quantity} · €{effPrice(item).toFixed(2)}/τμχ
|
||||
{hasPriceAdj && (
|
||||
<span className="ml-1 line-through text-gray-400">€{(item.unit_price ?? 0).toFixed(2)}</span>
|
||||
)}
|
||||
</p>
|
||||
{itemPriceEvents.map((ev, ei) => {
|
||||
const evColor = EVENT_COLORS_ODP[ev.event_type] || '#8a9099'
|
||||
const evLabel = ev.modifier_name || ev.deal_name || (ev.event_type === 'waiter_discount' ? 'Έκπτωση' : ev.event_type === 'free_item_added' ? 'Δωρεάν' : ev.event_type)
|
||||
return (
|
||||
<p key={ei} className="text-xs" style={{ color: evColor, display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<span style={{ opacity: 0.6 }}>↳</span>
|
||||
<span>{evLabel}</span>
|
||||
{ev.delta_amount != null && <span style={{ fontWeight: 600 }}>{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount.toFixed(2)} €</span>}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
{item.paid_by && (
|
||||
<p className="text-xs text-green-600 mt-0.5">
|
||||
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
|
||||
@@ -445,7 +626,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm font-semibold text-gray-700 w-14 text-right">€{itemTotal(item)}</span>
|
||||
<span className="text-sm font-semibold w-14 text-right" style={{ color: hasPriceAdj ? '#3b82f6' : '#374151' }}>€{itemTotal(item)}</span>
|
||||
{isOpen && !readOnly && item.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
@@ -516,6 +697,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{tab === 'pricing' && (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<PriceEventsTab orderId={orderId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'audit' && (
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<AuditTab order={order} waiterMap={waiterMap} />
|
||||
|
||||
96
manager_dashboard/src/pages/PhonePage.jsx
Normal file
96
manager_dashboard/src/pages/PhonePage.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Phone, PhoneIncoming, ChevronRight, Wifi, WifiOff } from 'lucide-react'
|
||||
import usePhoneStore from '../store/phoneStore'
|
||||
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function PhonePage() {
|
||||
const { log, setActiveCall } = usePhoneStore()
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, fontFamily: "'DM Sans','Segoe UI',sans-serif", maxWidth: 680, margin: '0 auto' }}>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: 'linear-gradient(135deg,#064e3b,#065f46)',
|
||||
border: '1px solid rgba(52,211,153,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Phone size={18} color="#34d399" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 16, fontWeight: 700, color: '#0f172a', margin: 0, letterSpacing: '-0.01em' }}>
|
||||
Εισερχόμενες Κλήσεις
|
||||
</h1>
|
||||
<p style={{ fontSize: 12, color: '#94a3b8', margin: '2px 0 0' }}>Grandstream UCM · Caller ID</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 16px', borderBottom: '1px solid #f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Πρόσφατες Κλήσεις
|
||||
</span>
|
||||
{log.length > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#cbd5e1' }}>{log.length} κλήσεις</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.length === 0 ? (
|
||||
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#cbd5e1', fontSize: 13 }}>
|
||||
Δεν έχουν ληφθεί κλήσεις ακόμα.
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{log.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
onClick={() => setActiveCall(entry)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 16px',
|
||||
borderBottom: i < log.length - 1 ? '1px solid #f8fafc' : 'none',
|
||||
cursor: 'pointer', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: 10, flexShrink: 0,
|
||||
background: entry.customer ? '#fef3c7' : '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{entry.customer
|
||||
? <span style={{ fontSize: 14, fontWeight: 700, color: '#d97706' }}>
|
||||
{entry.customer.name?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
: <PhoneIncoming size={14} color="#94a3b8" />
|
||||
}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b' }}>
|
||||
{entry.customer ? entry.customer.name : entry.caller || '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1 }}>
|
||||
{entry.customer ? entry.caller : 'Άγνωστος'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 11, color: '#cbd5e1', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatTime(entry.at)}
|
||||
</span>
|
||||
<ChevronRight size={12} color="#cbd5e1" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ function PayersModal({ order, waiterMap, onClose }) {
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||||
{paymentGroups.map((g, i) => {
|
||||
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
|
||||
const groupTotal = g.items.reduce((s, it) => s + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity, 0)
|
||||
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
@@ -257,7 +257,7 @@ function PayersModal({ order, waiterMap, onClose }) {
|
||||
{g.items.map(it => (
|
||||
<li key={it.id} className="flex justify-between text-sm text-gray-600">
|
||||
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
|
||||
<span>€{(it.unit_price * it.quantity).toFixed(2)}</span>
|
||||
<span>€{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -281,14 +281,15 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
|
||||
if (!order) return null
|
||||
|
||||
const activeItems = order.items.filter(i => i.status !== 'cancelled')
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const effPrice = i => (i.unit_price ?? 0) + (i.price_adjustment ?? 0)
|
||||
const total = activeItems.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
|
||||
|
||||
// Per-waiter subtotals for paid items
|
||||
const waiterTotals = {}
|
||||
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
|
||||
const wid = item.paid_by
|
||||
if (!waiterTotals[wid]) waiterTotals[wid] = 0
|
||||
waiterTotals[wid] += item.unit_price * item.quantity
|
||||
waiterTotals[wid] += effPrice(item) * item.quantity
|
||||
}
|
||||
const waiterTotalEntries = Object.entries(waiterTotals)
|
||||
|
||||
@@ -327,8 +328,8 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
|
||||
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
|
||||
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
|
||||
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700">€{item.unit_price.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{(item.unit_price * item.quantity).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700">€{effPrice(item).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">€{(effPrice(item) * item.quantity).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5"><Badge status={item.status} /></td>
|
||||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
|
||||
@@ -836,7 +837,7 @@ function HistoryTab({ filters, setFilters }) {
|
||||
{visibleOrders.map(o => {
|
||||
const total = o.items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
|
||||
// Opener / closer
|
||||
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import PrintFontsTab from './tabs/PrintFontsTab'
|
||||
import SecurityTab from './tabs/SecurityTab'
|
||||
import DevelopmentTab from './tabs/DevelopmentTab'
|
||||
import Phase2FeaturesTab from './tabs/Phase2FeaturesTab'
|
||||
import FiscalTab from './tabs/FiscalTab'
|
||||
import { TabGroup } from '../../ui/Tabs'
|
||||
|
||||
const TABS = [
|
||||
@@ -14,6 +15,7 @@ const TABS = [
|
||||
{ id: 'operation', label: 'Λειτουργία' },
|
||||
{ id: 'colours', label: 'Εμφάνιση' },
|
||||
{ id: 'print-fonts', label: 'Εκτύπωση' },
|
||||
{ id: 'fiscal', label: 'Ταμειακή' },
|
||||
{ id: 'features', label: 'Λειτουργίες' },
|
||||
{ id: 'development', label: 'Developer' },
|
||||
]
|
||||
@@ -30,6 +32,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'operation' && <OperationTab />}
|
||||
{activeTab === 'colours' && <ColoursTab />}
|
||||
{activeTab === 'print-fonts' && <PrintFontsTab />}
|
||||
{activeTab === 'fiscal' && <FiscalTab />}
|
||||
{activeTab === 'features' && <Phase2FeaturesTab />}
|
||||
{activeTab === 'development' && <DevelopmentTab />}
|
||||
</div>
|
||||
|
||||
@@ -437,7 +437,7 @@ export default function AppInfoTab() {
|
||||
|
||||
<DataTransferSection />
|
||||
|
||||
{user?.role === 'sysadmin' && (
|
||||
{user?.role === 'superadmin' && (
|
||||
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
|
||||
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
|
||||
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>
|
||||
|
||||
@@ -6,18 +6,29 @@ import toast from 'react-hot-toast'
|
||||
// ─── Colour slot metadata ────────────────────────────────────────────────────
|
||||
|
||||
const SLOTS = [
|
||||
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
|
||||
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
|
||||
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
|
||||
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
|
||||
{ key: 'cardBg', key2: 'cardBg2', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
|
||||
{ key: 'badgeBg', key2: 'badgeBg2', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
|
||||
{ key: 'nameText', key2: 'nameText2', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
|
||||
{ key: 'badgeText', key2: 'badgeText2', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
|
||||
]
|
||||
|
||||
function useFlashToggle(active, ms = 700) {
|
||||
const [frame, setFrame] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!active) { setFrame(false); return }
|
||||
const id = setInterval(() => setFrame(f => !f), ms)
|
||||
return () => clearInterval(id)
|
||||
}, [active, ms])
|
||||
return frame
|
||||
}
|
||||
|
||||
const STATUSES = [
|
||||
{ key: 'free', label: 'Ελεύθερο' },
|
||||
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
|
||||
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
|
||||
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
|
||||
{ key: 'paid', label: 'Πληρωμένο' },
|
||||
{ key: 'kds_ready', label: 'Έτοιμο για Παράδοση (KDS)', hasFlash: true },
|
||||
]
|
||||
|
||||
const STATUS_LABELS_MOCK = {
|
||||
@@ -26,14 +37,19 @@ const STATUS_LABELS_MOCK = {
|
||||
mine: 'ΔΙΚΟ ΜΟΥ',
|
||||
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
|
||||
paid: 'ΠΛΗΡΩΜΕΝΟ',
|
||||
kds_ready: 'READY',
|
||||
}
|
||||
|
||||
// Quick-suggest palettes per slot type
|
||||
const QUICK_SWATCHES = {
|
||||
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
|
||||
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
|
||||
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
|
||||
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
|
||||
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
|
||||
cardBg2: ['rgba(0,0,0,0)', '#ffffff', '#dde5ef', '#243044', '#FF8F60', '#e8610a', '#22c55e', '#3b82f6'],
|
||||
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
|
||||
badgeBg2: ['rgba(0,0,0,0)', 'rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', '#ffffff', '#000000'],
|
||||
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
|
||||
nameText2: ['rgba(0,0,0,0)', '#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0'],
|
||||
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
|
||||
badgeText2: ['rgba(0,0,0,0)', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0', '#22c55e', '#ffffff'],
|
||||
}
|
||||
|
||||
// ─── Color picker modal ──────────────────────────────────────────────────────
|
||||
@@ -106,7 +122,9 @@ function ColourPickerModal({ value, onClose, onChange, slot }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
|
||||
{SLOTS.find(s => s.key === slot)?.label || (SLOTS.find(s => s.key2 === slot)?.label + ' (χρώμα 2)')}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
@@ -259,34 +277,43 @@ function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
|
||||
|
||||
// ─── Mini mock table card (for preview) ──────────────────────────────────────
|
||||
|
||||
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
|
||||
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ', flashFrame = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
|
||||
width: '100%', height: 90, borderRadius: 12,
|
||||
background: flashFrame ? (cfg.cardBg2 ?? cfg.cardBg) : cfg.cardBg,
|
||||
position: 'relative', flexShrink: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
|
||||
overflow: 'hidden',
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
{/* Table name + group */}
|
||||
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<span style={{
|
||||
fontSize: 17, fontWeight: 800, color: cfg.nameText,
|
||||
fontSize: 17, fontWeight: 800,
|
||||
color: flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText,
|
||||
lineHeight: 1, letterSpacing: -0.5,
|
||||
transition: 'color 0.15s',
|
||||
}}>{mockName}</span>
|
||||
<span style={{
|
||||
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
|
||||
color: cfg.nameText + '80',
|
||||
color: (flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText) + '80',
|
||||
textTransform: 'uppercase',
|
||||
}}>{groupName}</span>
|
||||
</div>
|
||||
{/* Status badge — tight equal padding on all sides */}
|
||||
{/* Status badge */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 7, left: 7,
|
||||
background: cfg.badgeBg,
|
||||
background: flashFrame ? (cfg.badgeBg2 ?? cfg.badgeBg) : cfg.badgeBg,
|
||||
borderRadius: 4, padding: '2px 5px',
|
||||
lineHeight: 1,
|
||||
lineHeight: 1, transition: 'background 0.15s',
|
||||
}}>
|
||||
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
<span style={{
|
||||
fontSize: 7, fontWeight: 700,
|
||||
color: flashFrame ? (cfg.badgeText2 ?? cfg.badgeText) : cfg.badgeText,
|
||||
whiteSpace: 'nowrap', lineHeight: 1,
|
||||
transition: 'color 0.15s',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -336,30 +363,82 @@ function PreviewPanel({ colours, mode }) {
|
||||
|
||||
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
|
||||
|
||||
function StatusBlock({ mode, status, label, colours, onOpen }) {
|
||||
function StatusBlock({ mode, status, label, hasFlash, colours, onOpen, onSetFlash }) {
|
||||
const cfg = colours[mode][status]
|
||||
const flashFrame = useFlashToggle(hasFlash && !!cfg.flash)
|
||||
return (
|
||||
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ width: 88, flexShrink: 0 }}>
|
||||
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
|
||||
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" flashFrame={flashFrame} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
|
||||
{hasFlash && (
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
onClick={() => onSetFlash(mode, !cfg.flash)}
|
||||
style={{
|
||||
width: 36, height: 20, borderRadius: 10,
|
||||
background: cfg.flash ? '#22c55e' : '#d1d5db',
|
||||
position: 'relative', cursor: 'pointer', transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, left: cfg.flash ? 18 : 2,
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
|
||||
Αναλαμπή (Flashing)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
|
||||
{SLOTS.map(slot => (
|
||||
<ColourSlotRow
|
||||
key={slot.key}
|
||||
mode={mode}
|
||||
status={status}
|
||||
slotKey={slot.key}
|
||||
label={slot.label}
|
||||
value={cfg[slot.key]}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
<div key={slot.key}>
|
||||
<ColourSlotRow
|
||||
mode={mode}
|
||||
status={status}
|
||||
slotKey={slot.key}
|
||||
label={slot.label}
|
||||
value={cfg[slot.key]}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
{hasFlash && cfg.flash && slot.key2 && (
|
||||
<div style={{ paddingLeft: 20, marginTop: -4, marginBottom: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 3, background: cfg[slot.key2] || 'rgba(0,0,0,0)', border: '1px solid #e5e7eb', flexShrink: 0 }} />
|
||||
<button
|
||||
onClick={() => onOpen(mode, status, slot.key2, cfg[slot.key2] || cfg[slot.key])}
|
||||
style={{
|
||||
width: 36, height: 22, borderRadius: 6,
|
||||
background: cfg[slot.key2] || 'rgba(0,0,0,0)',
|
||||
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
backgroundImage: !cfg[slot.key2] || cfg[slot.key2] === 'rgba(0,0,0,0)'
|
||||
? 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)'
|
||||
: 'none',
|
||||
backgroundSize: '6px 6px',
|
||||
backgroundPosition: '0 0,0 3px,3px -3px,-3px 0',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6b7280' }}>{slot.label} (χρώμα 2)</div>
|
||||
<div style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'monospace' }}>
|
||||
{cfg[slot.key2] || 'διάφανο'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +447,7 @@ function StatusBlock({ mode, status, label, colours, onOpen }) {
|
||||
|
||||
// ─── Mode section (light or dark) ────────────────────────────────────────────
|
||||
|
||||
function ModeSection({ mode, colours, onOpen }) {
|
||||
function ModeSection({ mode, colours, onOpen, onSetFlash }) {
|
||||
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
|
||||
return (
|
||||
<div>
|
||||
@@ -380,8 +459,10 @@ function ModeSection({ mode, colours, onOpen }) {
|
||||
mode={mode}
|
||||
status={s.key}
|
||||
label={s.label}
|
||||
hasFlash={s.hasFlash}
|
||||
colours={colours}
|
||||
onOpen={onOpen}
|
||||
onSetFlash={onSetFlash}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -402,7 +483,17 @@ export default function ColoursTab() {
|
||||
client.get('/api/settings/').then(r => {
|
||||
const raw = r.data?.['ui.table_colours']?.value
|
||||
if (raw) {
|
||||
try { setColours(JSON.parse(raw)) } catch {}
|
||||
try {
|
||||
const loaded = JSON.parse(raw)
|
||||
// Deep-merge with defaults so any missing statuses/slots are backfilled
|
||||
const merged = { light: {}, dark: {} }
|
||||
for (const mode of ['light', 'dark']) {
|
||||
for (const [status, defaults] of Object.entries(DEFAULT_COLOURS[mode])) {
|
||||
merged[mode][status] = { ...defaults, ...(loaded[mode]?.[status] ?? {}) }
|
||||
}
|
||||
}
|
||||
setColours(merged)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
@@ -432,6 +523,20 @@ export default function ColoursTab() {
|
||||
})
|
||||
}
|
||||
|
||||
function setFlash(mode, value) {
|
||||
setColours(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
[mode]: {
|
||||
...prev[mode],
|
||||
kds_ready: { ...prev[mode].kds_ready, flash: value },
|
||||
},
|
||||
}
|
||||
saveToBackend(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function openModal(mode, status, slot, value) {
|
||||
setModal({ mode, status, slot, value })
|
||||
}
|
||||
@@ -461,8 +566,8 @@ export default function ColoursTab() {
|
||||
|
||||
{/* Light + Dark mode settings */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
|
||||
<ModeSection mode="light" colours={colours} onOpen={openModal} />
|
||||
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
|
||||
<ModeSection mode="light" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
|
||||
<ModeSection mode="dark" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
383
manager_dashboard/src/pages/Settings/tabs/FiscalTab.jsx
Normal file
383
manager_dashboard/src/pages/Settings/tabs/FiscalTab.jsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange, disabled = false }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 999, border: 'none',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
background: checked ? '#16a34a' : '#d1d5db',
|
||||
position: 'relative', transition: 'background 150ms', flexShrink: 0,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: checked ? 23 : 3,
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 mt-6 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingRow({ label, hint, children }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-6 py-3 border-b border-slate-100 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700">{label}</p>
|
||||
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FiscalTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const val = (key) => settings?.[key]?.value ?? ''
|
||||
|
||||
// Local state for folder paths (to show test result without saving first)
|
||||
const [outFolder, setOutFolder] = useState('')
|
||||
const [inFolder, setInFolder] = useState('')
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
// VAT groups state (parsed from JSON setting)
|
||||
const [vatGroups, setVatGroups] = useState([])
|
||||
// End message lines
|
||||
const [endLines, setEndLines] = useState(['', '', '', '', ''])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return
|
||||
setOutFolder(val('fiscal.out_folder'))
|
||||
setInFolder(val('fiscal.in_folder'))
|
||||
try {
|
||||
const groups = JSON.parse(val('fiscal.vat_groups') || '[]')
|
||||
setVatGroups(Array.isArray(groups) ? groups : [])
|
||||
} catch { setVatGroups([]) }
|
||||
try {
|
||||
const lines = JSON.parse(val('fiscal.end_message') || '[]')
|
||||
const padded = [...lines, '', '', '', '', ''].slice(0, 5)
|
||||
setEndLines(padded)
|
||||
} catch { setEndLines(['', '', '', '', '']) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings])
|
||||
|
||||
const saveSetting = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pos-settings'] }),
|
||||
})
|
||||
|
||||
function save(key, value) {
|
||||
saveSetting.mutate({ key, value }, {
|
||||
onError: () => toast.error(`Αποτυχία αποθήκευσης ${key}`),
|
||||
})
|
||||
}
|
||||
|
||||
function saveAndToast(key, value, label) {
|
||||
saveSetting.mutate({ key, value }, {
|
||||
onSuccess: () => toast.success(`${label} αποθηκεύτηκε`),
|
||||
onError: () => toast.error('Αποτυχία αποθήκευσης'),
|
||||
})
|
||||
}
|
||||
|
||||
async function testFolders() {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await client.post('/api/fiscal/test-folders', {
|
||||
out_folder: outFolder,
|
||||
in_folder: inFolder,
|
||||
})
|
||||
setTestResult(res.data)
|
||||
} catch (e) {
|
||||
setTestResult({ ok: false, out_folder_error: String(e), in_folder_error: null })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function saveFolders() {
|
||||
save('fiscal.out_folder', outFolder)
|
||||
save('fiscal.in_folder', inFolder)
|
||||
toast.success('Φάκελοι αποθηκεύτηκαν')
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
// VAT group helpers
|
||||
function addVatGroup() {
|
||||
setVatGroups(prev => [...prev, { machine_id: '', friendly_name: '' }])
|
||||
}
|
||||
function removeVatGroup(i) {
|
||||
setVatGroups(prev => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
function updateVatGroup(i, field, value) {
|
||||
setVatGroups(prev => prev.map((g, idx) => idx === i ? { ...g, [field]: value } : g))
|
||||
}
|
||||
function saveVatGroups() {
|
||||
const cleaned = vatGroups
|
||||
.filter(g => String(g.machine_id).trim() && g.friendly_name.trim())
|
||||
.map(g => ({ machine_id: parseInt(g.machine_id, 10), friendly_name: g.friendly_name.trim() }))
|
||||
saveAndToast('fiscal.vat_groups', JSON.stringify(cleaned), 'Ομάδες ΦΠΑ')
|
||||
}
|
||||
|
||||
// End message helpers
|
||||
function updateEndLine(i, value) {
|
||||
setEndLines(prev => prev.map((l, idx) => idx === i ? value : l))
|
||||
}
|
||||
function saveEndMessage() {
|
||||
const cleaned = endLines.map(l => l.trim())
|
||||
saveAndToast('fiscal.end_message', JSON.stringify(cleaned), 'Μήνυμα τέλους')
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-400 py-8 text-center">Φόρτωση…</div>
|
||||
|
||||
const fiscalEnabled = val('fiscal.enabled') === 'true'
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-1">
|
||||
|
||||
{/* Master switch */}
|
||||
<div className="card px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Φορολογική Εκτύπωση (ΦΗΜ)</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Ενεργοποιεί αυτόματη αποστολή αποδείξεων στη Φορολογική Μηχανή κατά την πληρωμή
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={fiscalEnabled}
|
||||
onChange={v => save('fiscal.enabled', v ? 'true' : 'false')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fiscalEnabled && (
|
||||
<div className="mt-3 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-800">
|
||||
Η ΦΗΜ είναι <strong>ενεργή</strong>. Κάθε πληρωμή στο PWA θα αποστέλλει αυτόματα απόδειξη.
|
||||
Βεβαιωθείτε ότι οι φάκελοι και οι ομάδες ΦΠΑ είναι σωστά ρυθμισμένα.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Driver type */}
|
||||
<div className="card px-5 divide-y divide-slate-100">
|
||||
<SectionTitle>Τύπος Προγράμματος Οδήγησης</SectionTitle>
|
||||
<SettingRow
|
||||
label="Τύπος ΦΗΜ"
|
||||
hint="Επί του παρόντος υποστηρίζεται μόνο TXT File (dTEC100extra)"
|
||||
>
|
||||
<select
|
||||
value={val('fiscal.type')}
|
||||
onChange={e => save('fiscal.type', e.target.value)}
|
||||
className="input text-sm h-9 pr-8"
|
||||
>
|
||||
<option value="txt_file">TXT File (dTEC100extra)</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Folder paths */}
|
||||
<div className="card px-5 py-4 space-y-3">
|
||||
<SectionTitle>Φάκελοι Επικοινωνίας</SectionTitle>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
Οι φάκελοι πρέπει να είναι προσβάσιμοι μέσα από το Docker container (mounted volume ή UNC path).
|
||||
<br />
|
||||
<span className="font-medium">OUT folder</span> = εκεί γράφει το Xenia (ο driver τα διαβάζει ως IN).
|
||||
<br />
|
||||
<span className="font-medium">IN folder</span> = εκεί γράφει ο driver τις απαντήσεις (το Xenia τις διαβάζει).
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 block mb-1">OUT Folder (Xenia → ΦΗΜ driver)</label>
|
||||
<input
|
||||
className="input w-full text-sm font-mono"
|
||||
placeholder="/mnt/fiscal/in"
|
||||
value={outFolder}
|
||||
onChange={e => setOutFolder(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 block mb-1">IN Folder (ΦΗΜ driver → Xenia)</label>
|
||||
<input
|
||||
className="input w-full text-sm font-mono"
|
||||
placeholder="/mnt/fiscal/out"
|
||||
value={inFolder}
|
||||
onChange={e => setInFolder(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={testFolders}
|
||||
disabled={testing || !outFolder || !inFolder}
|
||||
className="btn btn-secondary text-sm px-4"
|
||||
>
|
||||
{testing ? 'Έλεγχος…' : 'Έλεγχος σύνδεσης'}
|
||||
</button>
|
||||
<button
|
||||
onClick={saveFolders}
|
||||
disabled={!outFolder || !inFolder}
|
||||
className="btn btn-primary text-sm px-4"
|
||||
>
|
||||
Αποθήκευση
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`rounded-lg border px-3 py-2 text-xs font-mono ${
|
||||
testResult.ok
|
||||
? 'bg-green-50 border-green-200 text-green-800'
|
||||
: 'bg-red-50 border-red-200 text-red-800'
|
||||
}`}>
|
||||
{testResult.ok ? (
|
||||
<span>✓ Και οι δύο φάκελοι είναι προσβάσιμοι</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{testResult.out_folder_error && <div>OUT: {testResult.out_folder_error}</div>}
|
||||
{testResult.in_folder_error && <div>IN: {testResult.in_folder_error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clerk / EFTPOS IDs */}
|
||||
<div className="card px-5 divide-y divide-slate-100">
|
||||
<SectionTitle>Ρυθμίσεις Εντολών</SectionTitle>
|
||||
<SettingRow
|
||||
label="Clerk ID"
|
||||
hint="Κωδικός ταμία για εντολές CR / CD"
|
||||
>
|
||||
<input
|
||||
type="number" min="1"
|
||||
className="input w-20 text-sm text-center"
|
||||
value={val('fiscal.clerk_id')}
|
||||
onChange={e => save('fiscal.clerk_id', e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="EFTPOS ID"
|
||||
hint="Αριθμός τερματικού EFTPOS για εντολή CD (πληρωμή με κάρτα)"
|
||||
>
|
||||
<input
|
||||
type="number" min="1"
|
||||
className="input w-20 text-sm text-center"
|
||||
value={val('fiscal.eftpos_id')}
|
||||
onChange={e => save('fiscal.eftpos_id', e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* End message */}
|
||||
<div className="card px-5 py-4 space-y-3">
|
||||
<SectionTitle>Μήνυμα Τέλους Απόδειξης (FM)</SectionTitle>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
Μέχρι 5 γραμμές κειμένου που εκτυπώνονται στο τέλος κάθε απόδειξης.
|
||||
Αφήστε κενές τις γραμμές που δεν θέλετε να χρησιμοποιήσετε.
|
||||
</p>
|
||||
{endLines.map((line, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400 w-4 text-right shrink-0">{i + 1}</span>
|
||||
<input
|
||||
className="input flex-1 text-sm font-mono uppercase"
|
||||
placeholder={`Γραμμή ${i + 1}…`}
|
||||
value={line}
|
||||
maxLength={40}
|
||||
onChange={e => updateEndLine(i, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={saveEndMessage} className="btn btn-primary text-sm px-4">
|
||||
Αποθήκευση μηνύματος
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* VAT Groups */}
|
||||
<div className="card px-5 py-4 space-y-3">
|
||||
<SectionTitle>Ομάδες ΦΠΑ ΦΗΜ</SectionTitle>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
Ορίστε τις ομάδες ΦΠΑ όπως είναι προγραμματισμένες στη ΦΗΜ.
|
||||
Το <strong>ID ΦΗΜ</strong> είναι ο αριθμός που χρησιμοποιεί η μηχανή (π.χ. 1, 2, 3…).
|
||||
Θα χρησιμοποιηθούν κατά την ανάθεση σε προϊόντα.
|
||||
</p>
|
||||
|
||||
{vatGroups.length === 0 && (
|
||||
<p className="text-xs text-slate-400 italic">Δεν έχουν οριστεί ομάδες ΦΠΑ ακόμα.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{vatGroups.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 font-medium">ID ΦΗΜ</span>
|
||||
<input
|
||||
type="number" min="1"
|
||||
className="input w-16 text-sm text-center"
|
||||
placeholder="1"
|
||||
value={g.machine_id}
|
||||
onChange={e => updateVatGroup(i, 'machine_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 gap-0.5">
|
||||
<span className="text-[10px] text-slate-400 font-medium">Φιλικό όνομα</span>
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder="π.χ. Νερό, Καφές, Αλκοόλ…"
|
||||
value={g.friendly_name}
|
||||
onChange={e => updateVatGroup(i, 'friendly_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeVatGroup(i)}
|
||||
className="btn btn-danger text-xs px-2 py-1.5 min-h-0 h-8 mt-4 shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={addVatGroup} className="btn btn-secondary text-sm px-3">
|
||||
+ Προσθήκη ομάδας
|
||||
</button>
|
||||
{vatGroups.length > 0 && (
|
||||
<button onClick={saveVatGroups} className="btn btn-primary text-sm px-4">
|
||||
Αποθήκευση ομάδων
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
@@ -63,11 +63,14 @@ function ShiftSettingsSection() {
|
||||
function toggle(key, current) {
|
||||
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
|
||||
}
|
||||
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
|
||||
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
|
||||
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
|
||||
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
|
||||
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
|
||||
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
|
||||
const hideRevenue = settings?.['shifts.hide_revenue_from_waiters']?.value ?? 'false'
|
||||
const revertAllowed = settings?.['payments.waiter_revert_allowed']?.value ?? 'false'
|
||||
const priceAdjAllowed = settings?.['orders.waiter_price_adjust_allowed']?.value ?? 'false'
|
||||
return (
|
||||
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
|
||||
<SectionCard title="Ρυθμίσεις Προσωπικού" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<>
|
||||
@@ -83,6 +86,320 @@ function ShiftSettingsSection() {
|
||||
>
|
||||
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
label="Απόκρυψη Εισπράξεων από Σερβιτόρους"
|
||||
description="Όταν είναι ΕΝΕΡΓΌ, οι σερβιτόροι δεν βλέπουν το ποσό είσπραξης και το ποσό παράδοσης στην Επισκόπηση Βάρδιας — αποτρέπει παραποίηση των ταμειακών."
|
||||
>
|
||||
<Toggle checked={hideRevenue === 'true'} onChange={() => toggle('shifts.hide_revenue_from_waiters', hideRevenue)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
label="Αναίρεση Πληρωμής από Σερβιτόρους"
|
||||
description="Επιτρέπει στους σερβιτόρους να αναιρούν μεμονωμένες πληρωμένες χρεώσεις από την καρτέλα ΠΛΗΡΩΜΕΝΑ, επαναφέροντάς τες σε μη πληρωμένες. Χρήσιμο για διόρθωση λαθών χωρίς παρέμβαση διαχειριστή."
|
||||
>
|
||||
<Toggle checked={revertAllowed === 'true'} onChange={() => toggle('payments.waiter_revert_allowed', revertAllowed)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
label="Προσαρμογή Τιμής από Σερβιτόρους"
|
||||
description="Επιτρέπει στους σερβιτόρους να αλλάζουν την τιμή ενός αντικειμένου κατά την παραγγελία ή μετά. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να τροποποιήσει τιμή — ανεξάρτητα από τις ατομικές ρυθμίσεις."
|
||||
>
|
||||
<Toggle checked={priceAdjAllowed === 'true'} onChange={() => toggle('orders.waiter_price_adjust_allowed', priceAdjAllowed)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentSettingsSection() {
|
||||
const qc = useQueryClient()
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
function toggle(key, current) {
|
||||
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
|
||||
}
|
||||
const cardEnabled = settings?.['payments.card_enabled']?.value ?? 'false'
|
||||
return (
|
||||
<SectionCard title="Ρυθμίσεις Πληρωμών" description="Μέθοδοι πληρωμής που είναι διαθέσιμες στους σερβιτόρους">
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<OptionRow
|
||||
label="Πληρωμή με Κάρτα"
|
||||
description="Επιτρέπει στους σερβιτόρους να καταχωρούν πληρωμές με κάρτα. Το σύστημα δεν επικοινωνεί με POS μηχάνημα — η επιλογή καταγράφεται μόνο για αναφορά."
|
||||
>
|
||||
<Toggle checked={cardEnabled === 'true'} onChange={() => toggle('payments.card_enabled', cardEnabled)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
const COURSE_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#06b6d4','#64748b','#10b981']
|
||||
|
||||
function CoursesSection({ settings, updateMut }) {
|
||||
const coursesEnabled = settings?.['orders.courses_enabled']?.value === 'true'
|
||||
const [courses, setCourses] = useState(() => {
|
||||
try { return JSON.parse(settings?.['orders.courses']?.value || '[]') }
|
||||
catch { return [] }
|
||||
})
|
||||
|
||||
// Sync courses from settings when settings change (e.g. after save)
|
||||
const settingsCoursesStr = settings?.['orders.courses']?.value
|
||||
useEffect(() => {
|
||||
try { setCourses(JSON.parse(settingsCoursesStr || '[]')) }
|
||||
catch { setCourses([]) }
|
||||
}, [settingsCoursesStr])
|
||||
|
||||
const dragItem = useRef(null)
|
||||
const dragOverItem = useRef(null)
|
||||
|
||||
function saveCourses(next) {
|
||||
setCourses(next)
|
||||
updateMut.mutate({ key: 'orders.courses', value: JSON.stringify(next) })
|
||||
}
|
||||
|
||||
function addCourse() {
|
||||
const newId = Math.max(...courses.map(c => c.id), 0) + 1
|
||||
const colorIdx = courses.length % COURSE_COLORS.length
|
||||
saveCourses([...courses, { id: newId, name: `Course ${newId}`, color: COURSE_COLORS[colorIdx] }])
|
||||
}
|
||||
|
||||
function deleteCourse(id) {
|
||||
saveCourses(courses.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
function updateName(id, name) {
|
||||
saveCourses(courses.map(c => c.id === id ? { ...c, name } : c))
|
||||
}
|
||||
|
||||
function cycleColor(id) {
|
||||
saveCourses(courses.map(c => {
|
||||
if (c.id !== id) return c
|
||||
const idx = COURSE_COLORS.indexOf(c.color)
|
||||
const next = COURSE_COLORS[(idx + 1) % COURSE_COLORS.length]
|
||||
return { ...c, color: next }
|
||||
}))
|
||||
}
|
||||
|
||||
function handleDragStart(e, idx) {
|
||||
dragItem.current = idx
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function handleDragOver(e, idx) {
|
||||
e.preventDefault()
|
||||
dragOverItem.current = idx
|
||||
}
|
||||
|
||||
function handleDrop() {
|
||||
if (dragItem.current === null || dragOverItem.current === null) return
|
||||
if (dragItem.current === dragOverItem.current) return
|
||||
const next = [...courses]
|
||||
const dragged = next.splice(dragItem.current, 1)[0]
|
||||
next.splice(dragOverItem.current, 0, dragged)
|
||||
dragItem.current = null
|
||||
dragOverItem.current = null
|
||||
saveCourses(next)
|
||||
}
|
||||
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{coursesEnabled && (
|
||||
<div>
|
||||
{courses.map((course, idx) => (
|
||||
<div
|
||||
key={course.id}
|
||||
draggable
|
||||
onDragStart={e => handleDragStart(e, idx)}
|
||||
onDragOver={e => handleDragOver(e, idx)}
|
||||
onDrop={handleDrop}
|
||||
style={{ ...rowStyle, cursor: 'grab' }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<span style={{ color: '#9ca3af', fontSize: 14, flexShrink: 0, cursor: 'grab' }}>⠿</span>
|
||||
{/* Color swatch */}
|
||||
<button
|
||||
onClick={() => cycleColor(course.id)}
|
||||
title="Click to change color"
|
||||
style={{
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: course.color,
|
||||
border: '2px solid rgba(0,0,0,0.12)',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Name input */}
|
||||
<input
|
||||
value={course.name}
|
||||
onChange={e => setCourses(cs => cs.map(c => c.id === course.id ? { ...c, name: e.target.value } : c))}
|
||||
onBlur={e => updateName(course.id, e.target.value)}
|
||||
style={{
|
||||
flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6',
|
||||
padding: '0 10px', fontSize: 13, fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => deleteCourse(course.id)}
|
||||
style={{
|
||||
height: 28, padding: '0 10px', borderRadius: 6,
|
||||
border: '1px solid #fee2e2', background: '#fff5f5',
|
||||
fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0,
|
||||
}}
|
||||
>Διαγραφή</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: '10px 20px' }}>
|
||||
<button
|
||||
onClick={addCourse}
|
||||
style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 8,
|
||||
border: '1px solid #dfe2e6', background: 'white',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
|
||||
}}
|
||||
>+ Add course</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
|
||||
|
||||
function QuickNotesSection({ settings, updateMut }) {
|
||||
const raw = settings?.['orders.quick_notes']?.value
|
||||
const [notes, setNotes] = useState(() => {
|
||||
try { return JSON.parse(raw || 'null') ?? DEFAULT_QUICK_NOTES }
|
||||
catch { return DEFAULT_QUICK_NOTES }
|
||||
})
|
||||
const [newNote, setNewNote] = useState('')
|
||||
|
||||
const settingsRaw = settings?.['orders.quick_notes']?.value
|
||||
useEffect(() => {
|
||||
try { setNotes(JSON.parse(settingsRaw || 'null') ?? DEFAULT_QUICK_NOTES) }
|
||||
catch { setNotes(DEFAULT_QUICK_NOTES) }
|
||||
}, [settingsRaw])
|
||||
|
||||
function saveNotes(next) {
|
||||
setNotes(next)
|
||||
updateMut.mutate({ key: 'orders.quick_notes', value: JSON.stringify(next) })
|
||||
}
|
||||
|
||||
function updateNote(idx, val) {
|
||||
const next = notes.map((n, i) => i === idx ? val : n)
|
||||
setNotes(next)
|
||||
}
|
||||
|
||||
function commitNote(idx) {
|
||||
saveNotes(notes.map((n, i) => i === idx ? n.trim() : n).filter(n => n.length > 0))
|
||||
}
|
||||
|
||||
function deleteNote(idx) {
|
||||
saveNotes(notes.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function addNote() {
|
||||
const trimmed = newNote.trim()
|
||||
if (!trimmed) return
|
||||
setNewNote('')
|
||||
saveNotes([...notes, trimmed])
|
||||
}
|
||||
|
||||
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '12px 20px 4px', fontSize: 12, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Quick Note Options
|
||||
</div>
|
||||
{notes.map((note, idx) => (
|
||||
<div key={idx} style={rowStyle}>
|
||||
<input
|
||||
value={note}
|
||||
onChange={e => updateNote(idx, e.target.value)}
|
||||
onBlur={() => commitNote(idx)}
|
||||
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => deleteNote(idx)}
|
||||
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0 }}
|
||||
>Διαγραφή</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, padding: '10px 20px' }}>
|
||||
<input
|
||||
value={newNote}
|
||||
onChange={e => setNewNote(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addNote()}
|
||||
placeholder="Νέα γρήγορη σημείωση…"
|
||||
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button
|
||||
onClick={addNote}
|
||||
style={{ height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
|
||||
>+ Προσθήκη</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableOrderSettingsSection() {
|
||||
const qc = useQueryClient()
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['pos-settings'],
|
||||
queryFn: () => client.get('/api/settings/').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
function toggle(key, current) {
|
||||
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
|
||||
}
|
||||
const autoClose = settings?.['orders.auto_close_on_full_payment']?.value ?? 'false'
|
||||
const bypassKds = settings?.['orders.bypass_kds_serve']?.value ?? 'false'
|
||||
return (
|
||||
<SectionCard title="Ρυθμίσεις Τραπεζιών και Παραγγελιών" description="Συμπεριφορά τραπεζιών και παραγγελιών">
|
||||
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση…</p>}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<OptionRow
|
||||
label="Αυτόματο Κλείσιμο Τραπεζιού μετά την πλήρη πληρωμή"
|
||||
description="Το τραπέζι κλείνει αυτόματα όταν πληρωθούν όλα τα αντικείμενα"
|
||||
>
|
||||
<Toggle checked={autoClose === 'true'} onChange={() => toggle('orders.auto_close_on_full_payment', autoClose)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
label="Παράκαμψη KDS και Κατάστασης Σερβιρίσματος"
|
||||
description="Οι νέες παραγγελίες σημειώνονται άμεσα ως σερβιρισμένες — δεν απαιτείται επιβεβαίωση από σερβιτόρο"
|
||||
>
|
||||
<Toggle checked={bypassKds === 'true'} onChange={() => toggle('orders.bypass_kds_serve', bypassKds)} disabled={updateMut.isPending} />
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
label="Courses"
|
||||
description="Enable course-based ordering — assign items to courses (Starter, Main, Dessert, etc.) for sequenced firing"
|
||||
>
|
||||
<Toggle
|
||||
checked={settings?.['orders.courses_enabled']?.value === 'true'}
|
||||
onChange={() => toggle('orders.courses_enabled', settings?.['orders.courses_enabled']?.value ?? 'false')}
|
||||
disabled={updateMut.isPending}
|
||||
/>
|
||||
</OptionRow>
|
||||
<CoursesSection settings={settings} updateMut={updateMut} />
|
||||
<QuickNotesSection settings={settings} updateMut={updateMut} />
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -268,21 +585,21 @@ function QuickTemplatesSection() {
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ['quick-templates'],
|
||||
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
|
||||
queryFn: () => client.get('/api/notifications/templates').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body) => client.post('/api/messages/templates', body),
|
||||
mutationFn: (body) => client.post('/api/notifications/templates', body),
|
||||
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setShowNew(false); setNewBody('') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, body }) => client.put(`/api/messages/templates/${id}`, { body }),
|
||||
mutationFn: ({ id, body }) => client.put(`/api/notifications/templates/${id}`, { body }),
|
||||
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
|
||||
onError: () => toast.error('Σφάλμα αποθήκευσης'),
|
||||
})
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
|
||||
mutationFn: (id) => client.delete(`/api/notifications/templates/${id}`),
|
||||
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
@@ -334,10 +651,66 @@ function QuickTemplatesSection() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Auto schedule settings ───────────────────────────────────────────────────
|
||||
|
||||
const SCHED_STORAGE_KEY = 'xenia_schedule_settings_v1'
|
||||
const SCHED_DEFAULTS = {
|
||||
autoStartWorkday: false,
|
||||
autoCloseWorkday: false,
|
||||
autoCloseShiftsOnWorkday: false,
|
||||
autoStartStaffShift: false,
|
||||
autoCloseStaffShift: false,
|
||||
}
|
||||
|
||||
function loadSchedSettings() {
|
||||
try { return { ...SCHED_DEFAULTS, ...JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}') } }
|
||||
catch { return { ...SCHED_DEFAULTS } }
|
||||
}
|
||||
|
||||
function AutoScheduleSection() {
|
||||
const [s, setS] = useState(loadSchedSettings)
|
||||
|
||||
function toggle(key) {
|
||||
setS(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] }
|
||||
const stored = JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}')
|
||||
localStorage.setItem(SCHED_STORAGE_KEY, JSON.stringify({ ...stored, ...next }))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Αυτόματο Πρόγραμμα Καταστήματος" description="Ρυθμίσεις αυτόματης έναρξης/κλεισίματος εργάσιμης και βαρδιών">
|
||||
<OptionRow label="Αυτόματη Έναρξη Εργάσιμης" description="Η εργάσιμη ξεκινά αυτόματα σύμφωνα με το πρόγραμμα">
|
||||
<Toggle checked={s.autoStartWorkday} onChange={() => toggle('autoStartWorkday')} />
|
||||
</OptionRow>
|
||||
<OptionRow label="Αυτόματο Κλείσιμο Εργάσιμης" description="Η εργάσιμη κλείνει αυτόματα σύμφωνα με το πρόγραμμα">
|
||||
<Toggle checked={s.autoCloseWorkday} onChange={() => toggle('autoCloseWorkday')} />
|
||||
</OptionRow>
|
||||
{s.autoCloseWorkday && (
|
||||
<div className="pl-8">
|
||||
<OptionRow label="Κλείσιμο βαρδιών προσωπικού" description="Κλείνουν αυτόματα αν δεν υπάρχουν ανοιχτές παραγγελίες">
|
||||
<Toggle checked={s.autoCloseShiftsOnWorkday} onChange={() => toggle('autoCloseShiftsOnWorkday')} />
|
||||
</OptionRow>
|
||||
</div>
|
||||
)}
|
||||
<OptionRow label="Αυτόματη Έναρξη Βάρδιας Προσωπικού" description="Οι βάρδιες ξεκινούν αυτόματα σύμφωνα με το πρόγραμμα">
|
||||
<Toggle checked={s.autoStartStaffShift} onChange={() => toggle('autoStartStaffShift')} />
|
||||
</OptionRow>
|
||||
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας Προσωπικού" description="Οι βάρδιες κλείνουν αυτόματα σύμφωνα με το πρόγραμμα">
|
||||
<Toggle checked={s.autoCloseStaffShift} onChange={() => toggle('autoCloseStaffShift')} />
|
||||
</OptionRow>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ShiftSettingsSection />
|
||||
<PaymentSettingsSection />
|
||||
<TableOrderSettingsSection />
|
||||
<AutoScheduleSection />
|
||||
<FlagDefsSection />
|
||||
<QuickTemplatesSection />
|
||||
</div>
|
||||
|
||||
@@ -637,7 +637,15 @@ function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }
|
||||
// ── Printers section ───────────────────────────────────────────────────────
|
||||
|
||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true, duplicates: 0 }
|
||||
|
||||
const PRINTER_PROFILES = [
|
||||
{ label: 'Jolimark TP850UE', codepage_n: 29 },
|
||||
{ label: 'S4MAS Giant 100', codepage_n: 29 },
|
||||
{ label: 'NETUM NT8330L', codepage_n: 24 },
|
||||
]
|
||||
const codepageToProfile = (n) => PRINTER_PROFILES.find(p => p.codepage_n === n) ?? PRINTER_PROFILES[0]
|
||||
|
||||
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, codepage_n: 29, is_active: true }
|
||||
|
||||
function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||
const [form, setForm] = useState(initial ?? EMPTY_FORM)
|
||||
@@ -663,10 +671,16 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
|
||||
type="number" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
|
||||
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
|
||||
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 180px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΜΟΝΤΕΛΟ ΕΚΤΥΠΩΤΗ</label>
|
||||
<select
|
||||
value={form.codepage_n}
|
||||
onChange={e => set('codepage_n', parseInt(e.target.value))}
|
||||
style={inputStyle}
|
||||
>
|
||||
{PRINTER_PROFILES.map(p => (
|
||||
<option key={p.label} value={p.codepage_n}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
|
||||
@@ -674,11 +688,6 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
|
||||
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
|
||||
type="number" min={20} max={80} style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΑΝΤΙΓΡΑΦΑ</label>
|
||||
<input value={form.duplicates ?? 0} onChange={e => set('duplicates', Math.max(0, Math.min(9, parseInt(e.target.value) || 0)))}
|
||||
type="number" min={0} max={9} style={inputStyle} title="0 = μία εκτύπωση, 1 = δύο κ.ο.κ." />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
|
||||
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
|
||||
style={btnPrimary}>Αποθήκευση</button>
|
||||
@@ -742,13 +751,8 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
|
||||
{printer.ip_address}:{printer.port}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.protocol}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {codepageToProfile(printer.codepage_n ?? 29).label}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}>— {printer.line_width ?? 48} χαρ.</span>
|
||||
{(printer.duplicates ?? 0) > 0 && (
|
||||
<span style={{ fontSize: 11, color: '#f59e0b', fontWeight: 700, marginLeft: 6 }}>
|
||||
— x{(printer.duplicates ?? 0) + 1} αντ.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Shield, Lock, LogOut, User, KeyRound } from 'lucide-react'
|
||||
import { Shield, Lock, LogOut, User, KeyRound, ShieldCheck, Copy, RefreshCw, X } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { PANEL_CLASS } from '../../../ui/tokens'
|
||||
import Button from '../../../ui/Button'
|
||||
@@ -114,6 +115,143 @@ function TimeSelect({ value, onChange, disabled }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Recovery Codes section ───────────────────────────────────────────────────
|
||||
|
||||
function RecoveryCodesSection() {
|
||||
const [revealedCodes, setRevealedCodes] = useState(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const { data: statusData, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['recovery-code-status'],
|
||||
queryFn: () => client.get('/api/recovery/status').then(r => r.data),
|
||||
})
|
||||
|
||||
const generateMut = useMutation({
|
||||
mutationFn: () => client.post('/api/recovery/generate').then(r => r.data),
|
||||
onSuccess: (data) => {
|
||||
setRevealedCodes(data.codes)
|
||||
setConfirming(false)
|
||||
refetchStatus()
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα δημιουργίας κωδικών'),
|
||||
})
|
||||
|
||||
const unused = statusData?.unused_count ?? null
|
||||
|
||||
function copyAll() {
|
||||
if (!revealedCodes) return
|
||||
navigator.clipboard.writeText(revealedCodes.join('\n'))
|
||||
toast.success('Οι κωδικοί αντιγράφηκαν!')
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Κωδικοί Ανάκτησης"
|
||||
description="Μονόχρηστοι κωδικοί έκτακτης ανάγκης. Κάθε κωδικός χρησιμοποιείται μία φορά και καίγεται."
|
||||
>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700">Διαθέσιμοι κωδικοί:</span>
|
||||
{unused === null ? (
|
||||
<span className="text-xs text-gray-400">Φόρτωση…</span>
|
||||
) : (
|
||||
<span className={`text-sm font-bold ${
|
||||
unused === 0 ? 'text-rose-600' : unused <= 2 ? 'text-amber-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
{unused} / 5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unused === 0 && (
|
||||
<span className="text-[11px] bg-rose-50 text-rose-600 border border-rose-200 rounded-full px-2 py-0.5 font-medium">
|
||||
Δεν υπάρχουν κωδικοί!
|
||||
</span>
|
||||
)}
|
||||
{unused > 0 && unused <= 2 && (
|
||||
<span className="text-[11px] bg-amber-50 text-amber-600 border border-amber-200 rounded-full px-2 py-0.5 font-medium">
|
||||
Λίγοι κωδικοί
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revealed codes (shown once after generation) */}
|
||||
{revealedCodes && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] font-semibold text-emerald-800">
|
||||
Αντιγράψτε τώρα — δεν θα εμφανιστούν ξανά!
|
||||
</p>
|
||||
<button onClick={() => setRevealedCodes(null)} className="text-emerald-500 hover:text-emerald-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{revealedCodes.map((code, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg bg-white border border-emerald-200 px-3 py-2">
|
||||
<span className="font-mono text-sm font-semibold text-emerald-900 tracking-wider">{code}</span>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(code); toast.success('Αντιγράφηκε') }}
|
||||
className="text-emerald-400 hover:text-emerald-600"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary" className="w-full justify-center gap-2 text-[13px]" onClick={copyAll}>
|
||||
<Copy className="h-3.5 w-3.5" /> Αντιγραφή όλων
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate / confirm */}
|
||||
{!revealedCodes && (
|
||||
confirming ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-3">
|
||||
<p className="text-[13px] text-amber-800">
|
||||
Αυτό θα <strong>ακυρώσει</strong> τους {unused} υπάρχοντες κωδικούς και θα δημιουργήσει 5 νέους.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1 justify-center text-[13px]"
|
||||
onClick={() => generateMut.mutate()}
|
||||
disabled={generateMut.isPending}
|
||||
>
|
||||
{generateMut.isPending ? 'Δημιουργία…' : 'Ναι, δημιούργησε νέους'}
|
||||
</Button>
|
||||
<Button variant="secondary" className="flex-1 justify-center text-[13px]" onClick={() => setConfirming(false)}>
|
||||
Άκυρο
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={unused === 0 ? 'primary' : 'secondary'}
|
||||
className="w-full justify-center gap-2 text-[13px]"
|
||||
onClick={() => unused > 0 ? setConfirming(true) : generateMut.mutate()}
|
||||
disabled={generateMut.isPending}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{unused === 0 ? 'Δημιουργία κωδικών ανάκτησης' : 'Αναδημιουργία κωδικών'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Αποθηκεύστε τους σε ασφαλές μέρος. Κάθε κωδικός δουλεύει μόνο μία φορά.
|
||||
Χρησιμοποιείται στη σελίδα σύνδεσης με το όνομα χρήστη + κωδικό ανάκτησης.
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SecurityTab() {
|
||||
@@ -265,6 +403,9 @@ export default function SecurityTab() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Κωδικοί Ανάκτησης ────────────────────────────────────────────── */}
|
||||
<RecoveryCodesSection />
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,10 @@ export default function TablesPage() {
|
||||
const [batchModal, setBatchModal] = useState(null) // group object or null
|
||||
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
|
||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||
const [bulkMoveModal, setBulkMoveModal] = useState(false)
|
||||
const [bulkRenameModal, setBulkRenameModal] = useState(false)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
||||
const [selected, setSelected] = useState(new Set())
|
||||
const [anyHovered, setAnyHovered] = useState(false)
|
||||
@@ -122,11 +125,59 @@ export default function TablesPage() {
|
||||
})
|
||||
const clearSelect = () => setSelected(new Set())
|
||||
const anySelected = selected.size > 0
|
||||
const allVisibleSelected = visibleTables.length > 0 && visibleTables.every(t => selected.has(t.id))
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allVisibleSelected) {
|
||||
// Deselect all visible
|
||||
setSelected(prev => {
|
||||
const n = new Set(prev)
|
||||
visibleTables.forEach(t => n.delete(t.id))
|
||||
return n
|
||||
})
|
||||
} else {
|
||||
// Select all visible
|
||||
setSelected(prev => {
|
||||
const n = new Set(prev)
|
||||
visibleTables.forEach(t => n.add(t.id))
|
||||
return n
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function exitSelectMode() {
|
||||
setSelectMode(false)
|
||||
clearSelect()
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
setConfirmDelete({ type: 'bulk', ids: [...selected] })
|
||||
}
|
||||
|
||||
async function handleBulkMove(groupId) {
|
||||
const ids = [...selected]
|
||||
await Promise.allSettled(ids.map(id => client.put(`/api/tables/${id}`, { group_id: groupId })))
|
||||
toast.success(`${ids.length} τραπέζια μετακινήθηκαν`)
|
||||
setBulkMoveModal(false)
|
||||
exitSelectMode()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
async function handleBulkRename(prefix, startNumber) {
|
||||
const ids = [...selected]
|
||||
// Apply names in the order tables appear in the visible list
|
||||
const orderedIds = visibleTables.filter(t => selected.has(t.id)).map(t => t.id)
|
||||
const patches = orderedIds.map((id, i) => ({
|
||||
id,
|
||||
label: `${prefix}${startNumber + i}`,
|
||||
}))
|
||||
await Promise.allSettled(patches.map(({ id, label }) => client.put(`/api/tables/${id}`, { label })))
|
||||
toast.success(`${orderedIds.length} τραπέζια μετονομάστηκαν`)
|
||||
setBulkRenameModal(false)
|
||||
exitSelectMode()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||||
|
||||
const zoneTabs = [
|
||||
@@ -135,22 +186,66 @@ export default function TablesPage() {
|
||||
...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
|
||||
]
|
||||
|
||||
const totalTables = tables.length
|
||||
const inactiveTables = tables.filter(t => !t.is_active).length
|
||||
const zoneCount = groups.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
|
||||
{anySelected ? (
|
||||
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ minHeight: 60 }}>
|
||||
{/* Stats — left side */}
|
||||
{!isLoading && !anySelected && !selectMode && (
|
||||
<div className="flex items-center gap-3 text-[12px] text-slate-400 mr-2">
|
||||
<span><strong className="text-slate-700 font-semibold">{totalTables}</strong> τραπέζια</span>
|
||||
<span className="text-slate-200">·</span>
|
||||
<span><strong className="text-slate-700 font-semibold">{zoneCount}</strong> {zoneCount === 1 ? 'ζώνη' : 'ζώνες'}</span>
|
||||
{inactiveTables > 0 && (
|
||||
<>
|
||||
<span className="text-slate-200">·</span>
|
||||
<span><strong className="text-amber-600 font-semibold">{inactiveTables}</strong> ανενεργά</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-select action bar */}
|
||||
{(selectMode || anySelected) && (
|
||||
<>
|
||||
<button onClick={clearSelect} className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1">
|
||||
{/* Select-all / deselect-all toggle + close */}
|
||||
<button
|
||||
onClick={exitSelectMode}
|
||||
className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1"
|
||||
>
|
||||
<span className="w-4 h-4 rounded border border-slate-300 flex items-center justify-center text-[10px]">✕</span>
|
||||
{selected.size} επιλεγμένα
|
||||
{anySelected ? `${selected.size} επιλεγμένα` : 'Επιλογή'}
|
||||
</button>
|
||||
<Button variant="danger" size="sm" onClick={bulkDelete}>Διαγραφή επιλεγμένων</Button>
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="text-xs text-sky-600 hover:text-sky-800 underline"
|
||||
>
|
||||
{allVisibleSelected ? 'Αποεπιλογή όλων' : 'Επιλογή όλων'}
|
||||
</button>
|
||||
{anySelected && (
|
||||
<>
|
||||
<span className="w-px h-4 bg-slate-200 mx-1" />
|
||||
<Button variant="secondary" size="sm" onClick={() => setBulkMoveModal(true)}>
|
||||
Μετακίνηση ζώνης
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setBulkRenameModal(true)}>
|
||||
Μαζική μετονομασία
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={bulkDelete}>
|
||||
Διαγραφή
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
</>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
|
||||
{!selectMode && !anySelected && <span className="flex-1" />}
|
||||
|
||||
<Button
|
||||
variant={showInactive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
@@ -158,6 +253,11 @@ export default function TablesPage() {
|
||||
>
|
||||
{showInactive ? '✓ ' : ''}Ανενεργά
|
||||
</Button>
|
||||
{!selectMode && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setSelectMode(true)}>
|
||||
Επιλογή
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => setGroupModal({})}>+ Νέα ζώνη</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέο τραπέζι</Button>
|
||||
</div>
|
||||
@@ -254,20 +354,29 @@ export default function TablesPage() {
|
||||
<p className="py-10 text-sm text-slate-400 text-center">
|
||||
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
|
||||
</p>
|
||||
) : (
|
||||
) : (() => {
|
||||
// Build a set of labels that appear more than once across ALL tables (not just visible)
|
||||
const labelCounts = {}
|
||||
tables.forEach(t => { if (t.label) labelCounts[t.label] = (labelCounts[t.label] || 0) + 1 })
|
||||
const duplicateLabels = new Set(Object.keys(labelCounts).filter(l => labelCounts[l] > 1))
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden divide-y divide-slate-100">
|
||||
{visibleTables.map((t, idx) => {
|
||||
const isSelected = selected.has(t.id)
|
||||
const isDuplicate = t.label && duplicateLabels.has(t.label)
|
||||
const showCheckbox = selectMode || anySelected || isSelected
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'}`}
|
||||
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'} ${selectMode ? 'cursor-pointer' : ''}`}
|
||||
onClick={selectMode ? () => toggleSelect(t.id) : undefined}
|
||||
onMouseEnter={() => setAnyHovered(true)}
|
||||
onMouseLeave={() => setAnyHovered(false)}
|
||||
>
|
||||
{/* Number / Checkbox */}
|
||||
<div className="w-6 shrink-0 flex items-center justify-center">
|
||||
{anySelected || isSelected ? (
|
||||
{showCheckbox ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -276,15 +385,13 @@ export default function TablesPage() {
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs text-slate-400 font-mono group-hover:hidden"
|
||||
>{idx + 1}</span>
|
||||
<span className="text-xs text-slate-400 font-mono group-hover:hidden">{idx + 1}</span>
|
||||
)}
|
||||
{!anySelected && !isSelected && (
|
||||
{!showCheckbox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
onChange={() => toggleSelect(t.id)}
|
||||
onChange={() => { setSelectMode(true); toggleSelect(t.id) }}
|
||||
className="hidden group-hover:block w-4 h-4 rounded accent-sky-500 cursor-pointer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
@@ -292,51 +399,66 @@ export default function TablesPage() {
|
||||
</div>
|
||||
|
||||
<p className="flex-1 font-medium text-slate-800">{t.label || `Τραπέζι ${t.number}`}</p>
|
||||
{isDuplicate && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 border border-amber-200">
|
||||
Διπλό όνομα
|
||||
</span>
|
||||
)}
|
||||
{t.seat_count != null && (
|
||||
<span className="text-xs text-slate-400 hidden sm:inline" title="Αριθμός θέσεων">
|
||||
{t.seat_count} θέσ.
|
||||
</span>
|
||||
)}
|
||||
{t.group && (
|
||||
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded hidden sm:inline">
|
||||
{t.group.name}
|
||||
</span>
|
||||
)}
|
||||
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
|
||||
{t.is_active
|
||||
? <Button
|
||||
variant="ghost"
|
||||
{!selectMode && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
|
||||
{t.is_active
|
||||
? <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Απενεργ.</Button>
|
||||
: <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
|
||||
className="text-green-600 hover:bg-green-50"
|
||||
>Ενεργοπ.</Button>
|
||||
}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Απενεργ.</Button>
|
||||
: <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
|
||||
className="text-green-600 hover:bg-green-50"
|
||||
>Ενεργοπ.</Button>
|
||||
}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
|
||||
disabled={t.has_active_order}
|
||||
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
|
||||
className="disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Διαγραφή</Button>
|
||||
className="disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>Διαγραφή</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Add single table */}
|
||||
{addModal && (
|
||||
<TableModal
|
||||
title="Νέο τραπέζι"
|
||||
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
|
||||
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '', seat_count: null }}
|
||||
groups={groups}
|
||||
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
|
||||
onClose={() => setAddModal(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -345,9 +467,9 @@ export default function TablesPage() {
|
||||
{editModal && (
|
||||
<TableModal
|
||||
title="Επεξεργασία τραπεζιού"
|
||||
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
|
||||
initial={{ label: editModal.label || '', group_id: editModal.group_id || '', seat_count: editModal.seat_count ?? null }}
|
||||
groups={groups}
|
||||
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
|
||||
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -362,6 +484,25 @@ export default function TablesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk move zone */}
|
||||
{bulkMoveModal && (
|
||||
<BulkMoveModal
|
||||
count={selected.size}
|
||||
groups={groups}
|
||||
onSave={handleBulkMove}
|
||||
onClose={() => setBulkMoveModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk rename */}
|
||||
{bulkRenameModal && (
|
||||
<BulkRenameModal
|
||||
selectedTables={visibleTables.filter(t => selected.has(t.id))}
|
||||
onSave={handleBulkRename}
|
||||
onClose={() => setBulkRenameModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Group/Zone form */}
|
||||
{groupModal !== null && (
|
||||
<GroupModal
|
||||
@@ -393,7 +534,7 @@ export default function TablesPage() {
|
||||
if (confirmDelete.type === 'bulk') {
|
||||
await Promise.allSettled(confirmDelete.ids.map(id => client.delete(`/api/tables/${id}?hard=true`)))
|
||||
toast.success(`${confirmDelete.ids.length} τραπέζια διαγράφηκαν`)
|
||||
setConfirmDelete(null); clearSelect(); invalidate()
|
||||
setConfirmDelete(null); exitSelectMode(); invalidate()
|
||||
} else {
|
||||
deleteTable.mutate({ id: confirmDelete.id, hard: confirmDelete.hard })
|
||||
}
|
||||
@@ -443,6 +584,19 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
|
||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Αριθμός θέσεων</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
placeholder="π.χ. 4"
|
||||
value={form.seat_count ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, seat_count: e.target.value ? Number(e.target.value) : null }))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό αν δεν θέλετε να ορίσετε θέσεις.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
@@ -458,6 +612,123 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BulkMoveModal({ count, groups, onSave, onClose }) {
|
||||
const [groupId, setGroupId] = useState('')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Μετακίνηση σε ζώνη</h2>
|
||||
<p className="text-sm text-slate-500">{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα.</p>
|
||||
<div>
|
||||
<label className="label">Νέα ζώνη</label>
|
||||
<select className="input" value={groupId} onChange={e => setGroupId(e.target.value)} autoFocus>
|
||||
<option value="">— Χωρίς ζώνη —</option>
|
||||
{groups.map(g => (
|
||||
<option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onSave(groupId ? Number(groupId) : null)}
|
||||
className="flex-1 btn btn-primary"
|
||||
>
|
||||
Μετακίνηση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkRenameModal({ selectedTables, onSave, onClose }) {
|
||||
const count = selectedTables.length
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [startNumber, setStartNumber] = useState(1)
|
||||
|
||||
const trimmedPrefix = prefix.trim()
|
||||
const lastN = startNumber + count - 1
|
||||
const worstCase = `${trimmedPrefix}${lastN}`
|
||||
const lengthError = trimmedPrefix && worstCase.length > MAX_TABLE_NAME_LENGTH
|
||||
? `Το τελευταίο όνομα θα είναι '${worstCase}' (${worstCase.length} χαρ.). Μικρύνετε το πρόθεμα ή αλλάξτε αριθμό εκκίνησης.`
|
||||
: null
|
||||
|
||||
// Live preview — at most 5 names shown
|
||||
const previewNames = Array.from({ length: Math.min(count, 5) }, (_, i) =>
|
||||
trimmedPrefix ? `${trimmedPrefix}${startNumber + i}` : null
|
||||
)
|
||||
const hasMore = count > 5
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||||
<h2 className="font-bold text-gray-800">Μαζική μετονομασία</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα — θα μετονομαστούν με τη σειρά που εμφανίζονται στη λίστα.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="label">Πρόθεμα</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
placeholder="π.χ. BS-"
|
||||
value={prefix}
|
||||
maxLength={MAX_TABLE_NAME_LENGTH - 1}
|
||||
onChange={e => setPrefix(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Αριθμός εκκίνησης</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="999"
|
||||
value={startNumber}
|
||||
onChange={e => setStartNumber(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live preview */}
|
||||
{trimmedPrefix && !lengthError && (
|
||||
<div className="bg-slate-50 rounded-lg px-3 py-2 text-xs text-slate-500 space-y-1">
|
||||
<p className="font-medium text-slate-600 mb-1">Προεπισκόπηση:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{previewNames.map((name, i) => (
|
||||
<span key={i} className="bg-white border border-slate-200 rounded px-2 py-0.5 font-mono text-slate-700">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && (
|
||||
<span className="text-slate-400 self-center">… +{count - 5} ακόμα</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lengthError && (
|
||||
<p className="text-xs text-red-500">{lengthError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onSave(trimmedPrefix, startNumber)}
|
||||
disabled={!trimmedPrefix || !!lengthError}
|
||||
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Μετονομασία {count} τραπεζιών
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function computeStartNumber(tables, groupId, prefix) {
|
||||
if (!prefix) return 1
|
||||
const inGroup = groupId ? tables.filter(t => t.group_id === groupId) : []
|
||||
|
||||
@@ -102,7 +102,7 @@ function occupiedMinsFromDate(openedAt) {
|
||||
function orderTotal(items = []) {
|
||||
return items
|
||||
.filter(i => i.status !== 'cancelled')
|
||||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
}
|
||||
|
||||
function avatarColor(name) {
|
||||
@@ -158,7 +158,7 @@ function QuickActionModal({ table, order, flagDefs, currentFlags, waiters, templ
|
||||
await setFlagsMut.mutateAsync()
|
||||
if (notifyWaiters && notifyMsg.trim() && order) {
|
||||
const waiterIds = notifyAll ? [] : order.waiters.map(w => w.waiter_id)
|
||||
await client.post('/api/messages/send', {
|
||||
await client.post('/api/notifications/send', {
|
||||
body: notifyMsg.trim(),
|
||||
target_waiter_ids: waiterIds,
|
||||
table_ids: [table.id],
|
||||
@@ -600,7 +600,7 @@ export default function TablesPage() {
|
||||
|
||||
const { data: quickTemplates = [] } = useQuery({
|
||||
queryKey: ['quick-templates'],
|
||||
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
|
||||
queryFn: () => client.get('/api/notifications/templates').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ChevronDown, Search } from 'lucide-react'
|
||||
import client from '../api/client'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -121,130 +122,174 @@ function ForgiveModal({ tab, onClose, onForgive, isPending }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab detail card ───────────────────────────────────────────────────────────
|
||||
// ── Tab detail card (collapsible) ─────────────────────────────────────────────
|
||||
|
||||
function TabDetail({ tab, onPay, onClose, onForgive }) {
|
||||
function TabDetail({ tab, onPay, onClose, onForgive, defaultExpanded = false }) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open
|
||||
const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '14px 18px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
|
||||
{/* Clickable header — always visible */}
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left',
|
||||
padding: '14px 18px', background: '#f9fafb',
|
||||
borderBottom: expanded ? '1px solid #e5e7eb' : 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
flexWrap: 'wrap', gap: 8, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
flexShrink: 0, color: '#9ca3af',
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.18s',
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
|
||||
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
|
||||
{fmt(tab.balance)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>
|
||||
{sc.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
|
||||
{[
|
||||
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
|
||||
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
|
||||
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
{tab.entries.length > 0 && (
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{tab.entries.map(e => (
|
||||
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
|
||||
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
|
||||
{/* Expanded body */}
|
||||
{expanded && (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
|
||||
{[
|
||||
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
|
||||
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
|
||||
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payments */}
|
||||
{tab.payments.length > 0 && (
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{tab.payments.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: '#374151' }}>
|
||||
{p.received_by_name} · {fmtDateTime(p.created_at)}
|
||||
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
|
||||
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}>— {p.notes}</span>}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
|
||||
{/* Entries */}
|
||||
{tab.entries.length > 0 && (
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{tab.entries.map(e => (
|
||||
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
|
||||
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{tab.status === 'open' && (
|
||||
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{tab.balance > 0.005 && (
|
||||
<button onClick={() => onPay(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Πληρωμή
|
||||
</button>
|
||||
{/* Payments */}
|
||||
{tab.payments.length > 0 && (
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{tab.payments.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: '#374151' }}>
|
||||
{p.received_by_name} · {fmtDateTime(p.created_at)}
|
||||
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
|
||||
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}>— {p.notes}</span>}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canClose && (
|
||||
<button onClick={() => onClose(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Κλείσιμο Καρτέλας
|
||||
</button>
|
||||
|
||||
{/* Actions */}
|
||||
{tab.status === 'open' && (
|
||||
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{tab.balance > 0.005 && (
|
||||
<button onClick={() => onPay(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Πληρωμή
|
||||
</button>
|
||||
)}
|
||||
{canClose && (
|
||||
<button onClick={() => onClose(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Κλείσιμο Καρτέλας
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onForgive(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
Χάρισμα Υπολοίπου
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => onForgive(tab)}
|
||||
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
Χάρισμα Υπολοίπου
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Filter chip ───────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChip({ label, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1 rounded-full text-[12px] font-semibold border transition-colors ${
|
||||
active
|
||||
? 'bg-slate-800 text-white border-slate-800'
|
||||
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-400 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TabsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const [modal, setModal] = useState(null) // { type: 'pay'|'forgive', tab }
|
||||
const [statusFilter, setStatusFilter] = useState('open') // 'open' | 'closed' | 'all'
|
||||
const [search, setSearch] = useState('')
|
||||
const [modal, setModal] = useState(null)
|
||||
|
||||
const { data: tabs = [], isLoading } = useQuery({
|
||||
queryKey: ['tabs', showAll],
|
||||
queryFn: () => client.get('/api/tabs/', { params: showAll ? { tab_status: 'all' } : {} }).then(r => r.data),
|
||||
// Fetch open and closed separately — merge for client-side filtering
|
||||
const { data: openTabs = [], isLoading: loadingOpen } = useQuery({
|
||||
queryKey: ['tabs-open'],
|
||||
queryFn: () => client.get('/api/tabs/').then(r => r.data),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
// Refetch tabs with all statuses when toggled
|
||||
const { data: allTabs = [] } = useQuery({
|
||||
queryKey: ['tabs-all'],
|
||||
const { data: closedTabs = [], isLoading: loadingClosed } = useQuery({
|
||||
queryKey: ['tabs-closed'],
|
||||
queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
enabled: showAll,
|
||||
staleTime: 15_000,
|
||||
})
|
||||
const tabs = [...openTabs, ...closedTabs]
|
||||
const isLoading = loadingOpen || loadingClosed
|
||||
|
||||
const payTab = useMutation({
|
||||
mutationFn: ({ id, amount, payment_method, notes }) =>
|
||||
client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||
qc.invalidateQueries({ queryKey: ['tabs-all'] })
|
||||
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
|
||||
setModal(null)
|
||||
toast.success('Πληρωμή καταγράφηκε')
|
||||
},
|
||||
@@ -254,7 +299,7 @@ export default function TabsPage() {
|
||||
const closeTab = useMutation({
|
||||
mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
|
||||
toast.success('Καρτέλα έκλεισε')
|
||||
},
|
||||
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
|
||||
@@ -263,61 +308,85 @@ export default function TabsPage() {
|
||||
const forgiveTab = useMutation({
|
||||
mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tabs'] })
|
||||
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
|
||||
setModal(null)
|
||||
toast.success('Το υπόλοιπο χαρίστηκε')
|
||||
},
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const openTabs = tabs.filter(t => t.status === 'open')
|
||||
const totalOutstanding = openTabs.reduce((s, t) => s + t.balance, 0)
|
||||
// Live client-side filtering
|
||||
const filtered = useMemo(() => {
|
||||
let result = tabs
|
||||
if (statusFilter !== 'all') {
|
||||
result = result.filter(t => {
|
||||
if (statusFilter === 'open') return t.status === 'open'
|
||||
if (statusFilter === 'closed') return t.status === 'closed' || t.status === 'forgiven'
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
result = result.filter(t => t.customer_name?.toLowerCase().includes(q))
|
||||
}
|
||||
return result
|
||||
}, [tabs, statusFilter, search])
|
||||
|
||||
const openCount = tabs.filter(t => t.status === 'open').length
|
||||
const closedCount = tabs.filter(t => t.status === 'closed' || t.status === 'forgiven').length
|
||||
const totalOutstanding = tabs.filter(t => t.status === 'open').reduce((s, t) => s + t.balance, 0)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Καρτέλες</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '}
|
||||
<strong style={{ color: totalOutstanding > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)}</strong>
|
||||
</p>
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="px-6 py-3 border-b border-slate-200 bg-white shrink-0 flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Αναζήτηση πελάτη…"
|
||||
className="pl-8 pr-3 py-1.5 text-[13px] bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-none focus:ring-2 focus:ring-sky-500/30 focus:border-sky-400 transition placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter chips */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterChip label={`Ανοιχτές (${openCount})`} active={statusFilter === 'open'} onClick={() => setStatusFilter('open')} />
|
||||
<FilterChip label={`Κλειστές (${closedCount})`} active={statusFilter === 'closed'} onClick={() => setStatusFilter('closed')} />
|
||||
<FilterChip label="Όλες" active={statusFilter === 'all'} onClick={() => setStatusFilter('all')} />
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="ml-auto text-[12px] text-slate-400">
|
||||
{statusFilter === 'open' && totalOutstanding > 0 && (
|
||||
<span>Σύνολο: <strong className="text-red-600">{fmt(totalOutstanding)}</strong></span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAll(s => !s)}
|
||||
style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#6b7280' }}
|
||||
>
|
||||
{showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||
{!isLoading && openTabs.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 14, padding: '48px 0' }}>
|
||||
Δεν υπάρχουν ανοιχτές καρτέλες.
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3">
|
||||
{isLoading && (
|
||||
<div className="text-center text-slate-400 text-[13px] py-12">Φόρτωση…</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-[14px] py-12">
|
||||
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν καρτέλες.'}
|
||||
</div>
|
||||
)}
|
||||
{openTabs.map(tab => (
|
||||
{filtered.map(tab => (
|
||||
<TabDetail
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
defaultExpanded={false}
|
||||
onPay={t => setModal({ type: 'pay', tab: t })}
|
||||
onClose={t => closeTab.mutate(t.id)}
|
||||
onForgive={t => setModal({ type: 'forgive', tab: t })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showAll && allTabs.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 8 }}>Κλειστές / Χαρισμένες</div>
|
||||
{allTabs.map(tab => (
|
||||
<TabDetail key={tab.id} tab={tab} onPay={() => {}} onClose={() => {}} onForgive={() => {}} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal?.type === 'pay' && (
|
||||
|
||||
@@ -218,8 +218,7 @@ export default function WastePage() {
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Αποβλήτα / Φθορές</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>
|
||||
Σήμερα: {todayCount} καταχωρήσεις
|
||||
{todayCost > 0 && <span style={{ color: '#dc2626', fontWeight: 600 }}> · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €</span>}
|
||||
</p>
|
||||
|
||||
1
manager_dashboard/src/pages/crm/ContactsPage.jsx
Normal file
1
manager_dashboard/src/pages/crm/ContactsPage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../ContactsPage'
|
||||
1
manager_dashboard/src/pages/crm/CustomersPage.jsx
Normal file
1
manager_dashboard/src/pages/crm/CustomersPage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../CustomersPage'
|
||||
1
manager_dashboard/src/pages/financials/ExpensesPage.jsx
Normal file
1
manager_dashboard/src/pages/financials/ExpensesPage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../ExpensesPage'
|
||||
1
manager_dashboard/src/pages/financials/TabsPage.jsx
Normal file
1
manager_dashboard/src/pages/financials/TabsPage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../TabsPage'
|
||||
11
manager_dashboard/src/pages/inventory/StockPage.jsx
Normal file
11
manager_dashboard/src/pages/inventory/StockPage.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Boxes } from 'lucide-react'
|
||||
|
||||
export default function StockPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center gap-3 text-slate-400">
|
||||
<Boxes size={36} className="opacity-40" />
|
||||
<p className="text-[15px] font-semibold text-slate-500">Stock Management</p>
|
||||
<p className="text-[13px]">Coming soon — manage item stock volumes here.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
manager_dashboard/src/pages/inventory/ThrowawaysPage.jsx
Normal file
1
manager_dashboard/src/pages/inventory/ThrowawaysPage.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '../WastePage'
|
||||
439
manager_dashboard/src/pages/reports/ExpensesReportPage.jsx
Normal file
439
manager_dashboard/src/pages/reports/ExpensesReportPage.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, AlertCircle, CheckCircle, Clock } from 'lucide-react'
|
||||
import client from '../../api/client'
|
||||
import { FilterBar, FilterSelect } from './shared/FilterBar'
|
||||
import { Panel, DataTable, THead, TH, TR, TD } from './shared/TablePrimitives'
|
||||
import StatCard from './shared/StatCard'
|
||||
import EmptyState from './shared/EmptyState'
|
||||
import SkeletonTable from './shared/SkeletonTable'
|
||||
import { fmtEUR, fmtDate } from './shared/reportDesignTokens'
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
food: 'Τρόφιμα',
|
||||
beverage: 'Ποτά',
|
||||
supplies: 'Αναλώσιμα',
|
||||
utilities: 'Κοινόχρηστα',
|
||||
rent: 'Ενοίκιο',
|
||||
salary: 'Μισθοί',
|
||||
maintenance: 'Συντήρηση',
|
||||
other: 'Άλλο',
|
||||
}
|
||||
|
||||
const STATUS_META = {
|
||||
due: { label: 'Εκκρεμεί', bg: '#fef2f2', color: '#dc2626' },
|
||||
partial: { label: 'Μερική', bg: '#fff7ed', color: '#c2410c' },
|
||||
paid: { label: 'Πληρώθηκε', bg: '#eef7f0', color: '#1f7042' },
|
||||
}
|
||||
|
||||
// 12 distinct colours for combined view supplier segments
|
||||
const SUPPLIER_COLORS = [
|
||||
'#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6',
|
||||
'#06b6d4','#f97316','#84cc16','#ec4899','#14b8a6',
|
||||
'#a78bfa','#fb923c',
|
||||
]
|
||||
|
||||
function fmtCat(cat) {
|
||||
return CATEGORY_LABELS[cat] || cat || '—'
|
||||
}
|
||||
|
||||
// ── Breakdown section ─────────────────────────────────────────────────────────
|
||||
|
||||
const BREAKDOWN_VIEWS = [
|
||||
{ key: 'category', label: 'ΑΝΑ ΚΑΤΗΓΟΡΙΑ' },
|
||||
{ key: 'contact', label: 'ΑΝΑ ΕΠΑΦΗ' },
|
||||
{ key: 'combined', label: 'ΣΥΝΔΙΑΣΤΙΚΑ' },
|
||||
]
|
||||
|
||||
function BreakdownToggle({ value, onChange }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', background: '#f1f5f9', borderRadius: 8, padding: 3, gap: 2,
|
||||
}}>
|
||||
{BREAKDOWN_VIEWS.map(v => (
|
||||
<button
|
||||
key={v.key}
|
||||
onClick={() => onChange(v.key)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: 0.4,
|
||||
transition: 'all 120ms',
|
||||
background: value === v.key ? 'white' : 'transparent',
|
||||
color: value === v.key ? '#0f172a' : '#64748b',
|
||||
boxShadow: value === v.key ? '0 1px 3px rgba(0,0,0,0.12)' : 'none',
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownByCategoryRow({ cat, total }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="w-28 text-[13px] text-slate-700 font-medium flex-shrink-0">{fmtCat(cat.category)}</div>
|
||||
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-sky-500 rounded-full transition-all" style={{ width: `${total > 0 ? (cat.total / total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 w-20">
|
||||
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(cat.total)}</div>
|
||||
{cat.due > 0 && <div className="text-[11px] text-rose-500">{fmtEUR(cat.due)} εκκρεμεί</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownByContactRow({ name, total, due, grandTotal }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
<div style={{ width: 140 }} className="text-[13px] text-slate-700 font-medium flex-shrink-0 truncate">{name}</div>
|
||||
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{
|
||||
width: `${grandTotal > 0 ? (total / grandTotal) * 100 : 0}%`,
|
||||
background: '#10b981',
|
||||
}} />
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 w-20">
|
||||
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(total)}</div>
|
||||
{due > 0 && <div className="text-[11px] text-rose-500">{fmtEUR(due)} εκκρεμεί</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownCombinedRow({ cat, segments, supplierColorMap, grandTotal }) {
|
||||
const catTotal = segments.reduce((s, sg) => s + sg.amount, 0)
|
||||
if (catTotal === 0) return null
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="w-28 text-[13px] text-slate-700 font-medium flex-shrink-0">{fmtCat(cat)}</div>
|
||||
<div className="flex-1 h-4 bg-slate-100 rounded-full overflow-hidden flex">
|
||||
{segments.map(sg => (
|
||||
sg.amount > 0 ? (
|
||||
<div
|
||||
key={sg.name}
|
||||
title={`${sg.name}: ${fmtEUR(sg.amount)}`}
|
||||
style={{
|
||||
width: `${grandTotal > 0 ? (sg.amount / grandTotal) * 100 : 0}%`,
|
||||
background: supplierColorMap[sg.name] || '#94a3b8',
|
||||
transition: 'width 300ms',
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 w-20">
|
||||
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(catTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownSection({ expenses, summary }) {
|
||||
const [view, setView] = useState('category')
|
||||
|
||||
// By contact
|
||||
const byContact = useMemo(() => {
|
||||
const map = {}
|
||||
for (const e of expenses) {
|
||||
const key = e.contact_name || '(χωρίς προμηθευτή)'
|
||||
if (!map[key]) map[key] = { name: key, total: 0, due: 0 }
|
||||
map[key].total += e.total_amount
|
||||
map[key].due += e.due_amount
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.total - a.total)
|
||||
}, [expenses])
|
||||
|
||||
// By combined (category × contact stacked)
|
||||
const { combinedRows, supplierColorMap, allSuppliers } = useMemo(() => {
|
||||
const catMap = {}
|
||||
const suppSet = new Set()
|
||||
for (const e of expenses) {
|
||||
const cat = e.category || 'other'
|
||||
const supp = e.contact_name || '(χωρίς)'
|
||||
suppSet.add(supp)
|
||||
if (!catMap[cat]) catMap[cat] = {}
|
||||
catMap[cat][supp] = (catMap[cat][supp] || 0) + e.total_amount
|
||||
}
|
||||
const allSupps = [...suppSet]
|
||||
const colorMap = {}
|
||||
allSupps.forEach((s, i) => { colorMap[s] = SUPPLIER_COLORS[i % SUPPLIER_COLORS.length] })
|
||||
|
||||
const rows = Object.entries(catMap)
|
||||
.map(([cat, suppAmounts]) => ({
|
||||
cat,
|
||||
segments: allSupps.map(s => ({ name: s, amount: suppAmounts[s] || 0 })),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const ta = a.segments.reduce((s, sg) => s + sg.amount, 0)
|
||||
const tb = b.segments.reduce((s, sg) => s + sg.amount, 0)
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
return { combinedRows: rows, supplierColorMap: colorMap, allSuppliers: allSupps }
|
||||
}, [expenses])
|
||||
|
||||
const grandTotal = expenses.reduce((s, e) => s + e.total_amount, 0)
|
||||
const catGrandTotal = (summary?.by_category || []).reduce((s, c) => s + c.total, 0)
|
||||
|
||||
const hasData = expenses.length > 0 || (summary?.by_category?.length > 0)
|
||||
if (!hasData) return null
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)] overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.1em] text-slate-500">
|
||||
{view === 'category' ? 'Ανά Κατηγορία' : view === 'contact' ? 'Ανά Επαφή / Προμηθευτή' : 'Συνδιαστική Ανάλυση'}
|
||||
</div>
|
||||
<BreakdownToggle value={view} onChange={setView} />
|
||||
</div>
|
||||
|
||||
{view === 'category' && (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(summary?.by_category || [])
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map(cat => (
|
||||
<BreakdownByCategoryRow key={cat.category} cat={cat} total={catGrandTotal} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'contact' && (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{byContact.map(row => (
|
||||
<BreakdownByContactRow
|
||||
key={row.name} name={row.name}
|
||||
total={row.total} due={row.due}
|
||||
grandTotal={grandTotal}
|
||||
/>
|
||||
))}
|
||||
{byContact.length === 0 && (
|
||||
<div className="px-5 py-4 text-[13px] text-slate-400">Δεν υπάρχουν δεδομένα.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'combined' && (
|
||||
<>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{combinedRows.map(row => (
|
||||
<BreakdownCombinedRow
|
||||
key={row.cat} cat={row.cat}
|
||||
segments={row.segments}
|
||||
supplierColorMap={supplierColorMap}
|
||||
grandTotal={grandTotal}
|
||||
/>
|
||||
))}
|
||||
{combinedRows.length === 0 && (
|
||||
<div className="px-5 py-4 text-[13px] text-slate-400">Δεν υπάρχουν δεδομένα.</div>
|
||||
)}
|
||||
</div>
|
||||
{allSuppliers.length > 0 && (
|
||||
<div className="px-5 py-3 border-t border-slate-100 flex flex-wrap gap-3">
|
||||
{allSuppliers.map(s => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: supplierColorMap[s], flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#64748b' }}>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ExpenseDetailModal({ expense, onClose }) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ padding: '20px 24px', borderBottom: '1px solid #edeff1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>{expense.description}</div>
|
||||
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>{fmtCat(expense.category)} · {fmtDate(expense.created_at)}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid #edeff1', background: 'white', fontSize: 16, cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
{[
|
||||
{ label: 'Σύνολο', value: fmtEUR(expense.total_amount), color: '#111315' },
|
||||
{ label: 'Πληρώθηκε', value: fmtEUR(expense.paid_amount), color: '#1f7042' },
|
||||
{ label: 'Εκκρεμεί', value: fmtEUR(expense.due_amount), color: expense.due_amount > 0 ? '#dc2626' : '#1f7042' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ padding: '12px 14px', borderRadius: 10, background: '#fafafa', border: '1px solid #edeff1' }}>
|
||||
<div style={{ fontSize: 11, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: s.color, fontFamily: 'monospace', marginTop: 4 }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{expense.contact_name && (
|
||||
<div style={{ fontSize: 13, color: '#374151' }}>
|
||||
<span style={{ fontWeight: 600 }}>Προμηθευτής:</span> {expense.contact_name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expense.due_date && (
|
||||
<div style={{ fontSize: 13, color: '#374151' }}>
|
||||
<span style={{ fontWeight: 600 }}>Ημ. λήξης:</span> {fmtDate(expense.due_date)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expense.notes && (
|
||||
<div style={{ fontSize: 13, color: '#374151', background: '#f9fafb', borderRadius: 8, padding: '10px 14px' }}>
|
||||
{expense.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expense.payments.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>Πληρωμές</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{expense.payments.map(p => (
|
||||
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '8px 12px', borderRadius: 8, background: '#eef7f0', border: '1px solid #d1fae5' }}>
|
||||
<span style={{ color: '#374151' }}>{fmtDate(p.paid_at)}{p.paid_by_name ? ` · ${p.paid_by_name}` : ''}</span>
|
||||
<span style={{ fontWeight: 700, color: '#1f7042', fontFamily: 'monospace' }}>{fmtEUR(p.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [statusF, setStatusF] = useState('all')
|
||||
const [categoryF, setCategoryF] = useState('all')
|
||||
const [selectedExpense, setSelectedExpense] = useState(null)
|
||||
|
||||
const queryParams = {
|
||||
...(statusF !== 'all' ? { status: statusF } : {}),
|
||||
...(categoryF !== 'all' ? { category: categoryF } : {}),
|
||||
}
|
||||
|
||||
const { data: expenses = [], isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['expenses-report', statusF, categoryF],
|
||||
queryFn: () => client.get('/api/expenses/', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['expenses-summary'],
|
||||
queryFn: () => client.get('/api/expenses/summary').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const allCategories = [...new Set(expenses.map(e => e.category).filter(Boolean))]
|
||||
const categoryOptions = [
|
||||
{ value: 'all', label: 'Όλες οι Κατηγορίες' },
|
||||
...allCategories.map(c => ({ value: c, label: fmtCat(c) })),
|
||||
]
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'Όλες Κατάσταση' },
|
||||
{ value: 'due', label: 'Εκκρεμείς' },
|
||||
{ value: 'partial', label: 'Μερική Πληρωμή' },
|
||||
{ value: 'paid', label: 'Πληρωμένες' },
|
||||
]
|
||||
|
||||
const totalAmount = expenses.reduce((s, e) => s + e.total_amount, 0)
|
||||
const paidAmount = expenses.reduce((s, e) => s + e.paid_amount, 0)
|
||||
const dueAmount = expenses.reduce((s, e) => s + e.due_amount, 0)
|
||||
const dueCount = expenses.filter(e => e.status !== 'paid').length
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={6} /></div>
|
||||
if (isError) return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar>
|
||||
<FilterSelect value={statusF} onChange={setStatusF} options={statusOptions} label="Κατάσταση" />
|
||||
<FilterSelect value={categoryF} onChange={setCategoryF} options={categoryOptions} label="Κατηγορία" />
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Συνολικές Δαπάνες" value={fmtEUR(totalAmount)} icon={FileText} />
|
||||
<StatCard label="Πληρωμένο" value={fmtEUR(paidAmount)} icon={CheckCircle} accent />
|
||||
<StatCard label="Εκκρεμεί" value={fmtEUR(dueAmount)} icon={AlertCircle} sub={dueCount > 0 ? `${dueCount} εκκρεμείς` : 'όλα πληρωμένα'} />
|
||||
<StatCard label="Αριθμός Δαπανών" value={String(expenses.length)} icon={Clock} />
|
||||
</div>
|
||||
|
||||
<BreakdownSection expenses={expenses} summary={summary} />
|
||||
|
||||
{/* Expense list */}
|
||||
<Panel title="Λίστα Δαπανών" subtitle={`${expenses.length} αποτελέσματα`} padded={false}>
|
||||
{expenses.length === 0 ? (
|
||||
<EmptyState title="Δεν βρέθηκαν δαπάνες" description="Δοκιμάστε διαφορετικά φίλτρα." />
|
||||
) : (
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Περιγραφή</TH>
|
||||
<TH>Κατηγορία</TH>
|
||||
<TH>Προμηθευτής</TH>
|
||||
<TH>Σύνολο</TH>
|
||||
<TH>Εκκρεμεί</TH>
|
||||
<TH>Κατάσταση</TH>
|
||||
<TH>Ημερομηνία</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{expenses.map(e => {
|
||||
const meta = STATUS_META[e.status] || STATUS_META.due
|
||||
return (
|
||||
<TR
|
||||
key={e.id}
|
||||
striped
|
||||
className="cursor-pointer hover:bg-sky-50/40"
|
||||
onClick={() => setSelectedExpense(e)}
|
||||
>
|
||||
<TD className="font-medium">{e.description}</TD>
|
||||
<TD>{fmtCat(e.category)}</TD>
|
||||
<TD className="text-slate-500">{e.contact_name || '—'}</TD>
|
||||
<TD mono>{fmtEUR(e.total_amount)}</TD>
|
||||
<TD mono className={e.due_amount > 0 ? 'text-rose-600 font-semibold' : 'text-slate-400'}>
|
||||
{e.due_amount > 0 ? fmtEUR(e.due_amount) : '—'}
|
||||
</TD>
|
||||
<TD>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, background: meta.bg, color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-slate-500 text-[12px]">{fmtDate(e.created_at)}</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{selectedExpense && (
|
||||
<ExpenseDetailModal expense={selectedExpense} onClose={() => setSelectedExpense(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
manager_dashboard/src/pages/reports/MiscPage.jsx
Normal file
24
manager_dashboard/src/pages/reports/MiscPage.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { TabBar } from '../../ui/Tabs'
|
||||
import CancellationsLog from './operations/CancellationsLog'
|
||||
import DiscountsLog from './operations/DiscountsLog'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
|
||||
{ id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog },
|
||||
]
|
||||
|
||||
export default function MiscPage() {
|
||||
const [active, setActive] = useState('cancellations')
|
||||
const tab = TABS.find(t => t.id === active) || TABS[0]
|
||||
const { Component } = tab
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabBar tabs={TABS} active={active} onChange={setActive} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Component key={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
524
manager_dashboard/src/pages/reports/PrepZonesPage.jsx
Normal file
524
manager_dashboard/src/pages/reports/PrepZonesPage.jsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ConciergeBell, Printer, Download, PrinterIcon, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../api/client'
|
||||
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from './shared/FilterBar'
|
||||
import StatCard from './shared/StatCard'
|
||||
import SkeletonTable from './shared/SkeletonTable'
|
||||
import { DataTable, THead, TH, TR, TD } from './shared/TablePrimitives'
|
||||
import { fmtNum, fmtEUR, fmtDate, fmtDateTime } from './shared/reportDesignTokens'
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10) }
|
||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||
|
||||
// ── CSV export (client-side) ──────────────────────────────────────────────────
|
||||
|
||||
function downloadCSV(filename, rows, headers) {
|
||||
const escape = v => `"${String(v ?? '').replace(/"/g, '""')}"`
|
||||
const lines = [
|
||||
headers.map(escape).join(','),
|
||||
...rows.map(r => r.map(escape).join(',')),
|
||||
]
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = filename; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── Print modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PrintModal({ zone, queryParams, periodLabel, onClose }) {
|
||||
const [targetPrinter, setTargetPrinter] = useState('browser')
|
||||
const [printing, setPrinting] = useState(false)
|
||||
|
||||
const { data: printersData } = useQuery({
|
||||
queryKey: ['printers-list'],
|
||||
queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const printers = printersData?.printers || []
|
||||
|
||||
const printerOptions = [
|
||||
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
|
||||
...printers.map(p => ({ value: String(p.id), label: p.name })),
|
||||
]
|
||||
|
||||
function handleBrowserPrint() {
|
||||
const items = [...zone.all_products].sort((a, b) => b.count - a.count)
|
||||
const totalItems = items.reduce((s, i) => s + i.count, 0)
|
||||
|
||||
const rows = items.map(item => {
|
||||
const dots = '.'.repeat(Math.max(2, 42 - item.name.length - String(item.count).length))
|
||||
return `<tr><td>${item.name}</td><td style="text-align:right;font-family:monospace;white-space:nowrap">${dots} ${item.count}</td></tr>`
|
||||
}).join('')
|
||||
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ζώνη: ${zone.name}</title>
|
||||
<style>* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: Arial,sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 680px; }
|
||||
h1 { font-size: 15px; font-weight: bold; font-family: 'Courier New',monospace; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 10px; }
|
||||
.meta { font-size: 11px; color: #555; margin-bottom: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
td { padding: 4px 4px; border-bottom: 1px dotted #ccc; }
|
||||
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 8px; }
|
||||
@media print { body { padding: 0; } }</style></head>
|
||||
<body>
|
||||
<h1>ΖΩΝΗ ΠΡΟΕΤΟΙΜΑΣΙΑΣ: ${zone.name.toUpperCase()}</h1>
|
||||
<div class="meta">Περίοδος: ${periodLabel}</div>
|
||||
<table>
|
||||
<thead><tr><th style="text-align:left">Προϊόν</th><th style="text-align:right">Ποσότητα</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr><td>ΣΥΝΟΛΟ ΕΙΔΩΝ</td><td style="text-align:right">${totalItems}</td></tr></tfoot>
|
||||
</table>
|
||||
</body></html>`
|
||||
|
||||
const win = window.open('', '_blank', 'width=800,height=600')
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
win.focus()
|
||||
win.print()
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function handleThermalPrint() {
|
||||
setPrinting(true)
|
||||
try {
|
||||
const body = {
|
||||
printer_id: parseInt(targetPrinter, 10),
|
||||
zone_id: zone.id,
|
||||
...queryParams,
|
||||
}
|
||||
await client.post('/api/reports/print/prep-zone', body)
|
||||
toast.success(`Αποστολή στον εκτυπωτή…`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Αποτυχία εκτύπωσης')
|
||||
} finally {
|
||||
setPrinting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isBrowser = targetPrinter === 'browser'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 460, boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||
<div style={{ padding: '18px 22px', borderBottom: '1px solid #edeff1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>Εκτύπωση Σύνοψης</div>
|
||||
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>Ζώνη: {zone.name} · {periodLabel}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ width: 28, height: 28, borderRadius: 8, border: '1px solid #edeff1', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Preview of shopping list */}
|
||||
<div style={{ background: '#f8fafc', borderRadius: 10, border: '1px solid #e2e8f0', padding: '10px 14px', maxHeight: 180, overflowY: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{zone.all_products.slice(0, 20).map(p => {
|
||||
const dots = '.'.repeat(Math.max(2, 34 - p.name.length - String(p.count).length))
|
||||
return <div key={p.name}>{p.name}{dots}{p.count}</div>
|
||||
})}
|
||||
{zone.all_products.length > 20 && (
|
||||
<div style={{ color: '#94a3b8', marginTop: 4 }}>…+{zone.all_products.length - 20} ακόμα</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Printer target */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Προορισμός</label>
|
||||
<select
|
||||
value={targetPrinter}
|
||||
onChange={e => setTargetPrinter(e.target.value)}
|
||||
style={{ width: '100%', border: '1px solid #d1d5db', borderRadius: 8, padding: '8px 10px', fontSize: 13, background: 'white', color: '#374151' }}
|
||||
>
|
||||
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
onClick={isBrowser ? handleBrowserPrint : handleThermalPrint}
|
||||
disabled={printing}
|
||||
style={{
|
||||
width: '100%', padding: '10px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: '#3758c9', color: 'white', fontWeight: 700, fontSize: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
opacity: printing ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<PrinterIcon size={16} />
|
||||
{printing ? 'Εκτύπωση…' : isBrowser ? 'Άνοιγμα / Εκτύπωση PDF' : 'Αποστολή στον Εκτυπωτή'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zone multi-select filter ──────────────────────────────────────────────────
|
||||
|
||||
function ZoneFilter({ zones, selected, onChange }) {
|
||||
const allSelected = selected.length === 0 || selected.length === zones.length
|
||||
|
||||
function toggleAll() { onChange([]) }
|
||||
function toggle(id) {
|
||||
if (allSelected) {
|
||||
onChange([id])
|
||||
} else {
|
||||
const next = selected.includes(id) ? selected.filter(z => z !== id) : [...selected, id]
|
||||
onChange(next.length === zones.length ? [] : next)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>Ζώνες:</span>
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700,
|
||||
border: `1.5px solid ${allSelected ? '#3758c9' : '#d1d5db'}`,
|
||||
background: allSelected ? '#3758c9' : 'white',
|
||||
color: allSelected ? 'white' : '#374151',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Όλες
|
||||
</button>
|
||||
{zones.map(z => {
|
||||
const on = !allSelected && selected.includes(z.id)
|
||||
return (
|
||||
<button
|
||||
key={z.id}
|
||||
onClick={() => toggle(z.id)}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700,
|
||||
border: `1.5px solid ${on ? '#3758c9' : '#d1d5db'}`,
|
||||
background: on ? '#3758c9' : 'white',
|
||||
color: on ? 'white' : '#374151',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{z.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zone detail card ──────────────────────────────────────────────────────────
|
||||
|
||||
function ZoneDetailCard({ zone, queryParams, periodLabel }) {
|
||||
const [showPrintModal, setShowPrintModal] = useState(false)
|
||||
const [ordersExpanded, setOrdersExpanded] = useState(false)
|
||||
|
||||
function handleCSVItems() {
|
||||
downloadCSV(
|
||||
`prep-zone-${zone.name}-items.csv`,
|
||||
zone.all_products.map(p => [p.name, p.count, p.value]),
|
||||
['Προϊόν', 'Ποσότητα', 'Αξία (€)'],
|
||||
)
|
||||
}
|
||||
|
||||
function handleCSVOrders() {
|
||||
downloadCSV(
|
||||
`prep-zone-${zone.name}-orders.csv`,
|
||||
zone.all_orders.map(o => [o.order_id, o.table, o.opened_at ? new Date(o.opened_at).toLocaleString('el-GR') : '', o.items_count, o.value]),
|
||||
['Αρ. Παραγγελίας', 'Τραπέζι', 'Ώρα', 'Τεμάχια', 'Αξία (€)'],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
border: '1px solid #e2e8f0', borderRadius: 14, overflow: 'hidden',
|
||||
background: 'white', boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{/* Zone header bar */}
|
||||
<div style={{
|
||||
padding: '16px 20px', background: '#fafbff', borderBottom: '1px solid #e2e8f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ConciergeBell size={16} style={{ color: '#3758c9' }} />
|
||||
{zone.name}
|
||||
{zone.notification_name && zone.notification_name !== zone.name && (
|
||||
<span style={{ fontSize: 12, color: '#8a9099', fontWeight: 500 }}>({zone.notification_name})</span>
|
||||
)}
|
||||
</div>
|
||||
{zone.printers.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||
<Printer size={11} style={{ color: '#8a9099' }} />
|
||||
<span style={{ fontSize: 11, color: '#8a9099' }}>{zone.printers.map(p => p.name).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
{/* Stats */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Συνολικά Είδη</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#111315', fontFamily: 'monospace' }}>{fmtNum(zone.item_count)}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Αξία</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#3758c9', fontFamily: 'monospace' }}>{fmtEUR(zone.total_value)}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '6px 12px', borderRadius: 8, border: '1.5px solid #3758c9',
|
||||
background: '#3758c9', color: 'white',
|
||||
fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<PrinterIcon size={13} />
|
||||
Εκτύπωση
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zone.item_count === 0 ? (
|
||||
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
Δεν βρέθηκαν παραγγελίες για αυτή την ζώνη στο επιλεγμένο διάστημα.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '0 0 4px' }}>
|
||||
{/* Items table */}
|
||||
<div style={{ padding: '14px 20px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#374151', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Είδη ({zone.all_products.length})
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCSVItems}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db',
|
||||
background: 'white', color: '#374151', fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Download size={11} />
|
||||
CSV Ειδών
|
||||
</button>
|
||||
</div>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>Προϊόν</TH>
|
||||
<TH>Ποσότητα</TH>
|
||||
<TH>Αξία</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{zone.all_products.map((p, i) => (
|
||||
<TR key={p.name} striped>
|
||||
<TD className="font-medium">{p.name}</TD>
|
||||
<TD mono>{fmtNum(p.count)}</TD>
|
||||
<TD mono className="text-slate-500">{fmtEUR(p.value)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
{/* Orders section */}
|
||||
<div style={{ padding: '14px 20px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
onClick={() => setOrdersExpanded(v => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: 12, fontWeight: 700, color: '#374151', textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{ordersExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
Παραγγελίες ({zone.all_orders.length})
|
||||
</button>
|
||||
{ordersExpanded && (
|
||||
<button
|
||||
onClick={handleCSVOrders}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db',
|
||||
background: 'white', color: '#374151', fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Download size={11} />
|
||||
CSV Παραγγελιών
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{ordersExpanded && (
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH>
|
||||
<TH>Τραπέζι</TH>
|
||||
<TH>Ώρα</TH>
|
||||
<TH>Τεμάχια</TH>
|
||||
<TH>Αξία</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{zone.all_orders.map(o => (
|
||||
<TR key={o.order_id} striped>
|
||||
<TD mono className="text-slate-400 text-[11px]">#{o.order_id}</TD>
|
||||
<TD className="font-medium">{o.table}</TD>
|
||||
<TD className="text-slate-500 text-[12px]">
|
||||
{o.opened_at ? new Date(o.opened_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</TD>
|
||||
<TD mono>{fmtNum(o.items_count)}</TD>
|
||||
<TD mono className="text-slate-500">{fmtEUR(o.value)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
)}
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPrintModal && (
|
||||
<PrintModal
|
||||
zone={zone}
|
||||
queryParams={queryParams}
|
||||
periodLabel={periodLabel}
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PrepZonesPage() {
|
||||
const [mode, setMode] = useState('range')
|
||||
const [from, setFrom] = useState(monthAgo())
|
||||
const [to, setTo] = useState(today())
|
||||
const [businessDayId, setBusinessDayId] = useState('all')
|
||||
const [selectedZoneIds, setSelectedZoneIds] = useState([]) // [] = all
|
||||
|
||||
const { data: bdData } = useQuery({
|
||||
queryKey: ['business-days-list'],
|
||||
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const queryParams = {
|
||||
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
|
||||
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
|
||||
}
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['prep-zones-report', mode, from, to, businessDayId],
|
||||
queryFn: () => client.get('/api/reports/prep-zones', { params: queryParams }).then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const bdOptions = [
|
||||
{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' },
|
||||
...((bdData?.business_days || []).map(bd => ({
|
||||
value: String(bd.id),
|
||||
label: `${fmtDate(bd.opened_at)}`,
|
||||
}))),
|
||||
]
|
||||
|
||||
const allZones = data?.zones || []
|
||||
|
||||
const visibleZones = useMemo(() => {
|
||||
if (selectedZoneIds.length === 0) return allZones
|
||||
return allZones.filter(z => selectedZoneIds.includes(z.id))
|
||||
}, [allZones, selectedZoneIds])
|
||||
|
||||
const totalItems = visibleZones.reduce((s, z) => s + z.item_count, 0)
|
||||
const totalValue = visibleZones.reduce((s, z) => s + z.total_value, 0)
|
||||
const busyZone = visibleZones.reduce((best, z) => (!best || z.item_count > best.item_count ? z : best), null)
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (mode === 'workday' && businessDayId !== 'all') {
|
||||
const bd = (bdData?.business_days || []).find(b => String(b.id) === businessDayId)
|
||||
return bd ? fmtDate(bd.opened_at) : `Εργάσιμη #${businessDayId}`
|
||||
}
|
||||
return `${from} – ${to}`
|
||||
}, [mode, businessDayId, from, to, bdData])
|
||||
|
||||
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={4} /></div>
|
||||
if (isError) return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<FilterBar>
|
||||
<WorkDayDateToggle mode={mode} onChange={setMode} />
|
||||
{mode === 'workday' ? (
|
||||
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} label="Μέρα" />
|
||||
) : (
|
||||
<>
|
||||
<FilterDateInput value={from} onChange={setFrom} label="Από" />
|
||||
<FilterDateInput value={to} onChange={setTo} label="Έως" />
|
||||
</>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{/* Zone multi-select */}
|
||||
{allZones.length > 1 && (
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid #e2e8f0', background: 'white' }}>
|
||||
<ZoneFilter
|
||||
zones={allZones}
|
||||
selected={selectedZoneIds}
|
||||
onChange={setSelectedZoneIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<StatCard label="Συνολικά Είδη" value={fmtNum(totalItems)} icon={ConciergeBell} />
|
||||
<StatCard label="Συνολική Αξία" value={fmtEUR(totalValue)} icon={ConciergeBell} accent />
|
||||
<StatCard
|
||||
label="Πιο Απασχολημένη Ζώνη"
|
||||
value={busyZone?.name || '—'}
|
||||
sub={busyZone ? `${fmtNum(busyZone.item_count)} είδη` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allZones.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-slate-400">
|
||||
<ConciergeBell size={36} className="opacity-40" />
|
||||
<p className="text-[15px] font-semibold text-slate-500">Δεν βρέθηκαν ζώνες προετοιμασίας</p>
|
||||
<p className="text-[13px]">Δημιουργήστε ζώνες από το Management → Prep Zones.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{visibleZones.map(zone => (
|
||||
<ZoneDetailCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
queryParams={queryParams}
|
||||
periodLabel={periodLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
manager_dashboard/src/pages/reports/ProductsReportPage.jsx
Normal file
24
manager_dashboard/src/pages/reports/ProductsReportPage.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { TabBar } from '../../ui/Tabs'
|
||||
import ProductPerformance from './restaurant/ProductPerformance'
|
||||
import CategoryPerformance from './restaurant/CategoryPerformance'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'products', label: 'Απόδοση Προϊόντων', Component: ProductPerformance },
|
||||
{ id: 'categories', label: 'Κατηγορίες', Component: CategoryPerformance },
|
||||
]
|
||||
|
||||
export default function ProductsReportPage() {
|
||||
const [active, setActive] = useState('products')
|
||||
const tab = TABS.find(t => t.id === active) || TABS[0]
|
||||
const { Component } = tab
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabBar tabs={TABS} active={active} onChange={setActive} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Component key={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
manager_dashboard/src/pages/reports/StaffReportPage.jsx
Normal file
37
manager_dashboard/src/pages/reports/StaffReportPage.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { TabBar } from '../../ui/Tabs'
|
||||
import ShiftsOverview from './staff/ShiftsOverview'
|
||||
import Payments from './staff/Payments'
|
||||
import Activity from './staff/Activity'
|
||||
import StaffLeaderboard from './staff/StaffLeaderboard'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'shifts', label: 'Βάρδιες', Component: ShiftsOverview },
|
||||
{ id: 'payments', label: 'Πληρωμές', Component: Payments },
|
||||
{ id: 'activity', label: 'Δραστηριότητα', Component: Activity },
|
||||
{ id: 'leaderboard', label: 'Κατάταξη', Component: StaffLeaderboard },
|
||||
]
|
||||
|
||||
export default function StaffReportPage() {
|
||||
const [active, setActive] = useState('shifts')
|
||||
const navigate = useNavigate()
|
||||
const tab = TABS.find(t => t.id === active) || TABS[0]
|
||||
const { Component } = tab
|
||||
|
||||
// ShiftsOverview uses onNavigate to drill into order history — redirect to Store page
|
||||
function handleNavigate({ parent, sub, ...rest }) {
|
||||
if (parent === 'restaurant' && sub === 'orders') {
|
||||
navigate('/reports/store')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabBar tabs={TABS} active={active} onChange={setActive} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Component key={active} onNavigate={handleNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
manager_dashboard/src/pages/reports/StorePage.jsx
Normal file
30
manager_dashboard/src/pages/reports/StorePage.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react'
|
||||
import { TabBar } from '../../ui/Tabs'
|
||||
import WorkDaySummary from './restaurant/WorkDaySummary'
|
||||
import OrderHistory from './restaurant/OrderHistory'
|
||||
import TrafficAnalytics from './restaurant/TrafficAnalytics'
|
||||
import RevenueTrends from './restaurant/RevenueTrends'
|
||||
import TableAnalytics from './restaurant/TableAnalytics'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'workday', label: 'Ημέρες Λειτουργίας', Component: WorkDaySummary },
|
||||
{ id: 'orders', label: 'Ιστορικό Παραγγελιών', Component: OrderHistory },
|
||||
{ id: 'traffic', label: 'Κίνηση', Component: TrafficAnalytics },
|
||||
{ id: 'trends', label: 'Τάσεις Εσόδων', Component: RevenueTrends },
|
||||
{ id: 'tables', label: 'Τραπέζια', Component: TableAnalytics },
|
||||
]
|
||||
|
||||
export default function StorePage() {
|
||||
const [active, setActive] = useState('workday')
|
||||
const tab = TABS.find(t => t.id === active) || TABS[0]
|
||||
const { Component } = tab
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabBar tabs={TABS} active={active} onChange={setActive} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Component key={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
manager_dashboard/src/pages/reports/TechnicalPage.jsx
Normal file
24
manager_dashboard/src/pages/reports/TechnicalPage.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { TabBar } from '../../ui/Tabs'
|
||||
import PrinterHistory from './operations/PrinterHistory'
|
||||
import PrinterHealthLog from './operations/PrinterHealthLog'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
|
||||
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
|
||||
]
|
||||
|
||||
export default function TechnicalPage() {
|
||||
const [active, setActive] = useState('printer-history')
|
||||
const tab = TABS.find(t => t.id === active) || TABS[0]
|
||||
const { Component } = tab
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<TabBar tabs={TABS} active={active} onChange={setActive} />
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<Component key={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
manager_dashboard/src/pages/reports/TodayPage.jsx
Normal file
9
manager_dashboard/src/pages/reports/TodayPage.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Today from './restaurant/Today'
|
||||
|
||||
export default function TodayPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<Today />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -83,7 +83,19 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
</FilterBar>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Panel title="Παραγγελίες" subtitle={`${orders.length} αποτελέσματα`} padded={false}>
|
||||
<Panel
|
||||
padded={false}
|
||||
title={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>Παραγγελίες</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-normal text-slate-500">{orders.length} αποτελέσματα</span>
|
||||
</span>
|
||||
}
|
||||
right={
|
||||
<span className="text-[11px] text-slate-400 italic">Κάντε Click για Λεπτομέρειες Παραγγελίας</span>
|
||||
}
|
||||
>
|
||||
{orders.length === 0 ? (
|
||||
<EmptyState title="Δεν βρέθηκαν παραγγελίες" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή φίλτρο κατάστασης." />
|
||||
) : (
|
||||
@@ -91,7 +103,7 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
|
||||
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH>
|
||||
</THead>
|
||||
<tbody>
|
||||
{orders.slice(0, 200).map(o => {
|
||||
@@ -100,10 +112,10 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
|
||||
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
|
||||
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
const isCancelled = o.status === 'cancelled'
|
||||
return (
|
||||
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className={isCancelled ? 'opacity-50' : ''}>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_name ?? o.table_id}</TD>
|
||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
@@ -116,14 +128,6 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
|
||||
: <span className="text-slate-300">—</span>}
|
||||
</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
|
||||
<TD align="right">
|
||||
<button
|
||||
onClick={() => setDrillOrder(o)}
|
||||
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Λεπτομέρειες
|
||||
</button>
|
||||
</TD>
|
||||
</TR>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset, PiggyBank, ShoppingBag } from 'lucide-react'
|
||||
@@ -8,9 +9,12 @@ import StatCard from '../shared/StatCard'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import SkeletonTable from '../shared/SkeletonTable'
|
||||
import ExportButton from '../shared/ExportButton'
|
||||
import OrderDetailModal from '../shared/OrderDetailModal'
|
||||
import { fmtEUR, fmtNum, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
|
||||
|
||||
export default function Today() {
|
||||
const [drillOrder, setDrillOrder] = useState(null)
|
||||
|
||||
const { data: bdData, isLoading: bdLoading } = useQuery({
|
||||
queryKey: ['business-day-current'],
|
||||
queryFn: () => client.get('/api/reports/business-days/current').then(r => r.data),
|
||||
@@ -164,13 +168,26 @@ export default function Today() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Panel title="Παραγγελίες Σήμερα" subtitle={`${orders.length} συνολικά`} padded={false} right={
|
||||
<ExportButton
|
||||
endpoint="/api/reports/orders/export"
|
||||
params={{ business_day_id: bd.id }}
|
||||
filename={`today-orders-${bd.id}.csv`}
|
||||
/>
|
||||
}>
|
||||
<Panel
|
||||
padded={false}
|
||||
title={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>Παραγγελίες Σήμερα</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-normal text-slate-500">{orders.length} συνολικά</span>
|
||||
</span>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-slate-400 italic">Κάντε Click για Λεπτομέρειες Παραγγελίας</span>
|
||||
<ExportButton
|
||||
endpoint="/api/reports/orders/export"
|
||||
params={{ business_day_id: bd.id }}
|
||||
filename={`today-orders-${bd.id}.csv`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DataTable>
|
||||
<THead>
|
||||
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
|
||||
@@ -179,14 +196,14 @@ export default function Today() {
|
||||
<tbody>
|
||||
{orders.slice(0, 30).map(o => {
|
||||
const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
const costedItems = activeItems.filter(i => i.unit_cost != null)
|
||||
const orderProfit = costedItems.length > 0
|
||||
? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
|
||||
? costedItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0) - (i.unit_cost ?? 0)) * i.quantity, 0)
|
||||
: null
|
||||
const isCancelled = o.status === 'cancelled'
|
||||
return (
|
||||
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
|
||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className={isCancelled ? 'opacity-50' : ''}>
|
||||
<TD mono>#{o.id}</TD>
|
||||
<TD>{o.table_id}</TD>
|
||||
<TD mono>{fmtDateTime(o.opened_at)}</TD>
|
||||
@@ -208,6 +225,10 @@ export default function Today() {
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drillOrder && (
|
||||
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Trash2, Pencil, Check, X as XIcon } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import client from '../../../api/client'
|
||||
@@ -18,8 +18,73 @@ import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr
|
||||
function today() { return new Date().toISOString().slice(0, 10) }
|
||||
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
|
||||
|
||||
// ── Edit close-time inline control ─────────────────────────────────────────
|
||||
function EditCloseTime({ day, onSaved }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
function startEdit() {
|
||||
// pre-fill with current closed_at in local datetime-local format
|
||||
const d = day.closed_at ? new Date(day.closed_at) : new Date()
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const local = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
setValue(local)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (iso) => client.patch(`/api/business-day/${day.id}`, { closed_at: iso }).then(r => r.data),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Ώρα κλεισίματος ενημερώθηκε')
|
||||
qc.invalidateQueries({ queryKey: ['business-days'] })
|
||||
setEditing(false)
|
||||
onSaved && onSaved(data)
|
||||
},
|
||||
onError: (err) => toast.error(err?.response?.data?.detail || 'Σφάλμα ενημέρωσης'),
|
||||
})
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
title="Επεξεργασία ώρας κλεισίματος"
|
||||
className="ml-1 rounded p-0.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!value) return
|
||||
const iso = new Date(value).toISOString()
|
||||
mut.mutate(iso)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 ml-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
className="rounded border border-slate-300 px-1 py-0 text-[11px] font-mono text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
style={{ height: 22 }}
|
||||
max={new Date().toISOString().slice(0, 16)}
|
||||
/>
|
||||
<button onClick={handleSave} disabled={mut.isPending} className="rounded p-0.5 text-emerald-600 hover:bg-emerald-50" title="Αποθήκευση">
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="rounded p-0.5 text-slate-400 hover:bg-slate-100" title="Ακύρωση">
|
||||
<XIcon size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Workday drill-down modal ────────────────────────────────────────────────
|
||||
function WorkDayModal({ day, onClose, onDeleteShift }) {
|
||||
function WorkDayModal({ day: initialDay, onClose, onDeleteShift }) {
|
||||
const [day, setDay] = useState(initialDay)
|
||||
const [tab, setTab] = useState('orders')
|
||||
const [drillOrder, setDrillOrder] = useState(null)
|
||||
const [detailShift, setDetailShift] = useState(null)
|
||||
@@ -44,11 +109,44 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
|
||||
{ key: 'shifts', label: `Βάρδιες (${shifts.length})` },
|
||||
]
|
||||
|
||||
const hasCost = (day.total_cost || 0) > 0
|
||||
const hasProfit = (day.trackable_profit || 0) > 0
|
||||
|
||||
// Build the rich subtitle
|
||||
const openDate = fmtDate(day.opened_at)
|
||||
const closeDate = day.closed_at ? fmtDate(day.closed_at) : null
|
||||
const openTime = fmtTime(day.opened_at)
|
||||
const closeTime = day.closed_at ? fmtTime(day.closed_at) : null
|
||||
const spansDays = closeDate && closeDate !== openDate
|
||||
|
||||
const subtitleParts = [
|
||||
`${fmtEUR(day.revenue)} έσοδα`,
|
||||
hasCost ? `${fmtEUR(day.total_cost)} Έξοδα` : null,
|
||||
hasProfit ? `${fmtEUR(day.trackable_profit)} Κέρδος` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
const openLabel = spansDays ? `${openDate} ${openTime}` : openTime
|
||||
const closeLabel = day.closed_at
|
||||
? (spansDays ? `${closeDate} ${closeTime}` : closeTime)
|
||||
: 'ανοιχτή'
|
||||
|
||||
const subtitle = subtitleParts.join(' · ')
|
||||
const timeRange = `${openLabel} – ${closeLabel}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrillDownModal
|
||||
title={`Εργάσιμη Μέρα · ${fmtDate(day.opened_at)}`}
|
||||
subtitle={`${fmtEUR(day.revenue)} έσοδα · ${fmtTime(day.opened_at)} – ${day.closed_at ? fmtTime(day.closed_at) : 'ανοιχτή'}`}
|
||||
subtitle={
|
||||
<span>
|
||||
{subtitle}
|
||||
<span className="text-slate-400 mx-1">·</span>
|
||||
{timeRange}
|
||||
{day.status === 'closed' && (
|
||||
<EditCloseTime day={day} onSaved={updated => setDay(d => ({ ...d, closed_at: updated.closed_at }))} />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* Tabs */}
|
||||
@@ -80,7 +178,7 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
|
||||
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
|
||||
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
|
||||
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
|
||||
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
|
||||
return (
|
||||
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
|
||||
<TD mono>#{o.id}</TD>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { fmtEUR, fmtDateTime } from './reportDesignTokens'
|
||||
import client from '../../../api/client'
|
||||
|
||||
// ── Status badge ────────────────────────────────────────────────────────────
|
||||
const STATUS_META = {
|
||||
@@ -47,6 +49,7 @@ const EVENT_LABELS = {
|
||||
ORDER_CLOSED: { label: 'Κλείσιμο', bg: '#f9fafb', text: '#374151' },
|
||||
ORDER_CANCELLED: { label: 'Ακύρωση', bg: '#fef2f2', text: '#b91c1c' },
|
||||
ITEM_CANCELLED: { label: 'Ακύρωση αντ.', bg: '#fef2f2', text: '#b91c1c' },
|
||||
PAYMENT_REVERTED:{ label: 'Αναίρεση Πληρωμής', bg: '#fffbeb', text: '#b45309' },
|
||||
}
|
||||
|
||||
function AuditTimeline({ logs, itemsById }) {
|
||||
@@ -115,11 +118,12 @@ function AuditTimeline({ logs, itemsById }) {
|
||||
}
|
||||
|
||||
// ── Items tab ───────────────────────────────────────────────────────────────
|
||||
function ItemsTab({ order }) {
|
||||
function ItemsTab({ order, priceEventsMap = {} }) {
|
||||
const itemsFiltered = order.items || []
|
||||
const billableItems = itemsFiltered.filter(i => i.status !== 'cancelled')
|
||||
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const total = billableItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||||
const effPrice = i => (i.unit_price ?? 0) + (i.price_adjustment ?? 0)
|
||||
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + effPrice(i) * i.quantity, 0)
|
||||
const total = billableItems.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
|
||||
|
||||
if (itemsFiltered.length === 0) {
|
||||
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>Κανένα αντικείμενο</p>
|
||||
@@ -128,6 +132,8 @@ function ItemsTab({ order }) {
|
||||
// Column widths: [item info] [ordered by] [paid by] [pay type] [total]
|
||||
const GRID = '1fr 150px 150px 110px 80px'
|
||||
|
||||
const EVENT_COLORS = { modifier_applied: '#2563eb', waiter_discount: '#7c3aed', free_item_added: '#16a34a' }
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 10, border: '1px solid #edeff1', overflow: 'hidden', marginBottom: 16 }}>
|
||||
{/* Header */}
|
||||
@@ -141,23 +147,37 @@ function ItemsTab({ order }) {
|
||||
|
||||
{itemsFiltered.map(item => {
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
|
||||
const isDealItem = !!item.deal_id
|
||||
const itemEvents = (priceEventsMap[item.id] ?? []).filter(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
|
||||
return (
|
||||
<div key={item.id} style={{
|
||||
display: 'grid', gridTemplateColumns: GRID, gap: 12,
|
||||
padding: '9px 14px', borderTop: '1px solid #f4f4f2',
|
||||
opacity: isCancelled ? 0.45 : 1,
|
||||
background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white',
|
||||
}}>
|
||||
<div key={item.id} style={{ borderTop: '1px solid #f4f4f2', opacity: isCancelled ? 0.45 : 1, background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: GRID, gap: 12, padding: '9px 14px' }}>
|
||||
{/* Item info */}
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none', display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
||||
{item.product?.name ?? `#${item.product_id}`}
|
||||
{isDealItem && <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 5px', borderRadius: 4, background: 'rgba(167,139,250,0.12)', color: '#7c3aed', border: '1px solid rgba(167,139,250,0.3)' }}>ΔΩΡΕΑΝ</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: '#8a9099', fontFamily: 'ui-monospace,monospace' }}>×{item.quantity}</span>
|
||||
<span style={{ fontSize: 12, color: '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(item.unit_price)}</span>
|
||||
<span style={{ fontSize: 12, color: hasPriceAdj ? '#3b82f6' : '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(effPrice(item))}</span>
|
||||
{hasPriceAdj && (
|
||||
<span style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'ui-monospace,monospace', textDecoration: 'line-through' }}>{fmtEUR(item.unit_price)}</span>
|
||||
)}
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
{itemEvents.map((ev, ei) => {
|
||||
const evColor = EVENT_COLORS[ev.event_type] || '#8a9099'
|
||||
const evLabel = ev.modifier_name || ev.deal_name || (ev.event_type === 'waiter_discount' ? 'Έκπτωση' : ev.event_type === 'free_item_added' ? 'Δωρεάν' : ev.event_type)
|
||||
return (
|
||||
<div key={ei} style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: 11, color: evColor }}>
|
||||
<span style={{ opacity: 0.6 }}>↳</span>
|
||||
<span style={{ flex: 1 }}>{evLabel}</span>
|
||||
{ev.delta_amount != null && <span style={{ fontWeight: 600 }}>{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount.toFixed(2)} €</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ordered by */}
|
||||
@@ -183,8 +203,9 @@ function ItemsTab({ order }) {
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
|
||||
{fmtEUR(item.unit_price * item.quantity)}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: hasPriceAdj ? '#3b82f6' : '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
|
||||
{fmtEUR(effPrice(item) * item.quantity)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -207,6 +228,19 @@ function ItemsTab({ order }) {
|
||||
// ── Main modal ──────────────────────────────────────────────────────────────
|
||||
export default function OrderDetailModal({ order, onClose }) {
|
||||
const [tab, setTab] = useState('items')
|
||||
|
||||
const { data: rawPriceEvents = [] } = useQuery({
|
||||
queryKey: ['price-events', order?.id],
|
||||
queryFn: () => client.get(`/api/pricing/events/order/${order.id}`).then(r => r.data),
|
||||
enabled: !!order?.id,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const priceEventsMap = rawPriceEvents.reduce((acc, ev) => {
|
||||
if (!acc[ev.order_item_id]) acc[ev.order_item_id] = []
|
||||
acc[ev.order_item_id].push(ev)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (!order) return null
|
||||
|
||||
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
|
||||
@@ -277,7 +311,7 @@ export default function OrderDetailModal({ order, onClose }) {
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{tab === 'items' && <ItemsTab order={order} />}
|
||||
{tab === 'items' && <ItemsTab order={order} priceEventsMap={priceEventsMap} />}
|
||||
{tab === 'audit' && <AuditTimeline logs={order.audit_logs} itemsById={itemsById} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,101 @@ import { X } from 'lucide-react'
|
||||
import client from '../../../api/client'
|
||||
import { fmtEUR, fmtDateTime, fmtTime, fmtDate } from './reportDesignTokens'
|
||||
|
||||
function fmtDur(ms) {
|
||||
const totalMin = Math.round(ms / 60000)
|
||||
const h = Math.floor(totalMin / 60)
|
||||
const m = totalMin % 60
|
||||
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
|
||||
}
|
||||
|
||||
// ── Breaks timeline bar ──────────────────────────────────────────────────────
|
||||
function BreaksTimeline({ started_at, ended_at, breaks }) {
|
||||
if (!breaks || breaks.length === 0) return null
|
||||
|
||||
const start = new Date(started_at).getTime()
|
||||
const end = ended_at ? new Date(ended_at).getTime() : Date.now()
|
||||
const total = end - start
|
||||
if (total <= 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 24px 12px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 6 }}>
|
||||
Διαλείμματα
|
||||
</div>
|
||||
<div style={{ position: 'relative', height: 8, borderRadius: 4, background: '#e2e8f0', overflow: 'hidden' }}>
|
||||
{/* work = whole bar base */}
|
||||
{breaks.map((b, i) => {
|
||||
const bs = new Date(b.started_at).getTime()
|
||||
const be = b.ended_at ? new Date(b.ended_at).getTime() : Date.now()
|
||||
const left = Math.max(0, ((bs - start) / total) * 100)
|
||||
const width = Math.min(100 - left, ((be - bs) / total) * 100)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
title={`Διάλειμμα ${fmtTime(b.started_at)} – ${b.ended_at ? fmtTime(b.ended_at) : 'ενεργό'} · Διάρκεια: ${fmtDur(be - bs)}`}
|
||||
style={{
|
||||
position: 'absolute', top: 0, height: '100%',
|
||||
left: `${left}%`, width: `${width}%`,
|
||||
background: '#f59e0b', borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', marginTop: 6 }}>
|
||||
{breaks.map(b => {
|
||||
const bs = new Date(b.started_at).getTime()
|
||||
const be = b.ended_at ? new Date(b.ended_at).getTime() : Date.now()
|
||||
return (
|
||||
<span key={b.id} style={{ fontSize: 11, color: '#5a6169' }}>
|
||||
<span style={{ color: '#b45309', fontWeight: 700 }}>◼</span>{' '}
|
||||
{fmtTime(b.started_at)} – {b.ended_at ? fmtTime(b.ended_at) : 'ενεργό'} · {fmtDur(be - bs)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Order-status distribution bar ───────────────────────────────────────────
|
||||
function StatusDistributionBar({ counts }) {
|
||||
const total = counts.all || 1
|
||||
const segments = [
|
||||
{ key: 'both', color: '#16a34a', label: 'Πλήρης' },
|
||||
{ key: 'paid', color: '#2563eb', label: 'Πληρώθηκε' },
|
||||
{ key: 'ordered', color: '#ca8a04', label: 'Παρήγγειλε' },
|
||||
{ key: 'unpaid', color: '#ea580c', label: 'Ανοιχτό' },
|
||||
{ key: 'cancelled', color: '#dc2626', label: 'Ακύρωση' },
|
||||
].filter(s => counts[s.key] > 0)
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/* bar */}
|
||||
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', gap: 1 }}>
|
||||
{segments.map(s => (
|
||||
<div
|
||||
key={s.key}
|
||||
title={`${s.label}: ${counts[s.key]} (${Math.round(counts[s.key] / total * 100)}%)`}
|
||||
style={{ flex: counts[s.key], background: s.color, minWidth: 2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* legend with % */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginTop: 6 }}>
|
||||
{segments.map(s => (
|
||||
<span key={s.key} style={{ fontSize: 10, color: '#5a6169', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: s.color, flexShrink: 0, display: 'inline-block' }} />
|
||||
{s.label}: {counts[s.key]} ({Math.round(counts[s.key] / total * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── colour rules ────────────────────────────────────────────────────────────
|
||||
function classify(item, shiftWaiterId) {
|
||||
if (item.status === 'cancelled')
|
||||
@@ -210,19 +305,30 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
||||
? '✓ Εντάξει'
|
||||
: discrepancy < 0
|
||||
? `Έλλειμμα ${fmtEUR(Math.abs(discrepancy))}`
|
||||
: `Πλεόνασμα ${fmtEUR(discrepancy)}`
|
||||
: `Πλεόνασμα +${fmtEUR(discrepancy)}`
|
||||
const discAccent = discrepancy == null
|
||||
? undefined
|
||||
: Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626'
|
||||
: Math.abs(discrepancy) < 0.005
|
||||
? '#16a34a'
|
||||
: discrepancy > 0
|
||||
? '#16a34a' // surplus → green
|
||||
: '#dc2626' // shortage → red
|
||||
|
||||
const cancelEvents = summary.cancellation_events ?? 0
|
||||
const cancelItemsQty = summary.orders
|
||||
? summary.orders.reduce((s, o) => s + o.items.filter(i => i.status === 'cancelled').reduce((ss, i) => ss + i.quantity, 0), 0)
|
||||
: 0
|
||||
|
||||
const cashSales = summary.cash_sales ?? summary.total_collected ?? 0
|
||||
const cardSales = summary.card_sales ?? 0
|
||||
const totalRev = cashSales + cardSales
|
||||
const collectLabel = cardSales > 0
|
||||
? `${fmtEUR(cashSales)} + ${fmtEUR(cardSales)}`
|
||||
: fmtEUR(cashSales)
|
||||
|
||||
const row2 = [
|
||||
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
|
||||
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
|
||||
{ label: 'Εισπράξεις (μετρητά + κάρτα)', value: collectLabel, accent: '#2f9e5e' },
|
||||
{
|
||||
label: 'Ακυρώσεις',
|
||||
value: cancelEvents > 0 ? `${cancelEvents} / ${cancelItemsQty} είδη` : '—',
|
||||
@@ -246,14 +352,16 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Colour legend */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{LEGEND.map(l => (
|
||||
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.dot, flexShrink: 0 }} />
|
||||
{l.label}
|
||||
</div>
|
||||
))}
|
||||
{/* Breaks timeline (hidden when no breaks) */}
|
||||
<BreaksTimeline
|
||||
started_at={summary.started_at}
|
||||
ended_at={summary.ended_at}
|
||||
breaks={summary.breaks || []}
|
||||
/>
|
||||
|
||||
{/* Status distribution bar */}
|
||||
<div style={{ padding: '8px 24px 10px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
|
||||
<StatusDistributionBar counts={counts} />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
|
||||
@@ -159,23 +159,32 @@ export default function ShiftsOverview({ onNavigate } = {}) {
|
||||
: <span className="text-slate-400 text-[11px]">Δεν παρακολουθείται</span>}
|
||||
</TD>
|
||||
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
|
||||
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
|
||||
<TD mono align="right">
|
||||
<span className="font-semibold text-emerald-700">{fmtEUR(s.cash_sales ?? s.total_collected)}</span>
|
||||
{(s.card_sales ?? 0) > 0 && (
|
||||
<span className="block text-[10px] text-slate-400">+{fmtEUR(s.card_sales)} κάρτα</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
|
||||
<TD mono align="right">
|
||||
{s.counted_cash_end != null ? (
|
||||
<span className={
|
||||
s.cash_discrepancy == null || Math.abs(s.cash_discrepancy) < 0.005
|
||||
? 'text-emerald-600 font-semibold'
|
||||
: 'text-red-600 font-semibold'
|
||||
}>
|
||||
{fmtEUR(s.counted_cash_end)}
|
||||
{s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
|
||||
<span className="block text-[10px] font-medium">
|
||||
{s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
{s.counted_cash_end != null ? (() => {
|
||||
const d = s.cash_discrepancy
|
||||
const isClean = d == null || Math.abs(d) < 0.005
|
||||
const isSurplus = d != null && d > 0.005
|
||||
const colorClass = isClean ? 'text-emerald-600 font-semibold'
|
||||
: isSurplus ? 'text-emerald-600 font-semibold'
|
||||
: 'text-red-600 font-semibold'
|
||||
return (
|
||||
<span className={colorClass}>
|
||||
{fmtEUR(s.counted_cash_end)}
|
||||
{d != null && Math.abs(d) >= 0.005 && (
|
||||
<span className="block text-[10px] font-medium">
|
||||
{isSurplus ? `+${fmtEUR(d)}` : `−${fmtEUR(Math.abs(d))}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})() : (
|
||||
<span className="text-slate-400 text-[11px]">—</span>
|
||||
)}
|
||||
</TD>
|
||||
|
||||
11
manager_dashboard/src/store/phoneStore.js
Normal file
11
manager_dashboard/src/store/phoneStore.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const usePhoneStore = create((set) => ({
|
||||
activeCall: null, // { caller, ext, at, customer, orders } | null
|
||||
log: [], // last 20 calls
|
||||
setActiveCall: (call) => set({ activeCall: call }),
|
||||
dismissCall: () => set({ activeCall: null }),
|
||||
addCallToLog: (entry) => set(s => ({ log: [entry, ...s.log].slice(0, 20) })),
|
||||
}))
|
||||
|
||||
export default usePhoneStore
|
||||
@@ -36,6 +36,17 @@ export const DEFAULT_COLOURS = {
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
kds_ready: {
|
||||
cardBg: '#5dc936',
|
||||
cardBg2: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.57)',
|
||||
badgeBg2: 'rgba(255,255,255,0.57)',
|
||||
nameText: '#ffffff',
|
||||
nameText2: '#ffffff',
|
||||
badgeText: 'rgba(45,71,36,0.63)',
|
||||
badgeText2:'rgba(45,71,36,0.63)',
|
||||
flash: true,
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
free: {
|
||||
@@ -68,6 +79,17 @@ export const DEFAULT_COLOURS = {
|
||||
nameText: '#ffffff',
|
||||
badgeText: '#81D264',
|
||||
},
|
||||
kds_ready: {
|
||||
cardBg: '#5dc936',
|
||||
cardBg2: '#FFDC67',
|
||||
badgeBg: 'rgba(255,255,255,0.57)',
|
||||
badgeBg2: 'rgba(255,255,255,0.57)',
|
||||
nameText: '#ffffff',
|
||||
nameText2: '#ffffff',
|
||||
badgeText: 'rgba(45,71,36,0.63)',
|
||||
badgeText2:'rgba(45,71,36,0.63)',
|
||||
flash: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -89,7 +111,7 @@ const useTableColourStore = create(persist(
|
||||
})),
|
||||
resetAll: () => set({ colours: DEFAULT_COLOURS }),
|
||||
}),
|
||||
{ name: 'pos-table-colours' }
|
||||
{ name: 'pos-table-colours-v2' }
|
||||
))
|
||||
|
||||
export default useTableColourStore
|
||||
|
||||
@@ -13,7 +13,7 @@ import Button from './Button'
|
||||
// </Modal.Footer>
|
||||
// </Modal>
|
||||
|
||||
export default function Modal({ title, onClose, children, maxWidth = 'max-w-sm', className = '' }) {
|
||||
export default function Modal({ title, onClose, children, footer, maxWidth = 'max-w-sm', className = '', tabs = null, activeTab = null, onTabChange = null }) {
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
@@ -28,10 +28,11 @@ export default function Modal({ title, onClose, children, maxWidth = 'max-w-sm',
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.() }}
|
||||
>
|
||||
<div className={`relative w-full ${maxWidth} rounded-xl border border-slate-200 bg-white shadow-xl ${className}`}>
|
||||
<div className={`relative w-full ${maxWidth} rounded-xl border border-slate-200 bg-white shadow-xl flex flex-col ${className}`}
|
||||
style={{ maxHeight: 'calc(100vh - 2rem)' }}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 shrink-0">
|
||||
<h2 className="text-[15px] font-semibold text-slate-900">{title}</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
@@ -44,16 +45,43 @@ export default function Modal({ title, onClose, children, maxWidth = 'max-w-sm',
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3 text-[13px] text-slate-700">
|
||||
{/* Tab bar */}
|
||||
{tabs && (
|
||||
<div className="flex border-b border-slate-100 px-5 shrink-0 gap-1">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange?.(tab.key)}
|
||||
className={`px-3 py-2.5 text-[12px] font-medium border-b-2 transition-colors -mb-px ${
|
||||
activeTab === tab.key
|
||||
? 'border-sky-500 text-sky-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body — scrollable */}
|
||||
<div className="px-5 py-4 text-[13px] text-slate-700 overflow-y-auto flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer — fixed outside scroll area when provided via prop */}
|
||||
{footer && (
|
||||
<div className="shrink-0 border-t border-slate-100 px-5 py-3 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer helper — renders a right-aligned row of action buttons
|
||||
// Footer helper — for modals without tabs (renders inline, modal doesn't scroll)
|
||||
function ModalFooter({ children }) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-4 mt-2">
|
||||
|
||||
@@ -3,6 +3,9 @@ export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Google Sans', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#0f766e',
|
||||
|
||||
Reference in New Issue
Block a user