Files
xenia-pos-local/manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx
bonamin 8ba8c95ecd feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:04:38 +03:00

495 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { DEFAULT_COLOURS } from '../../../store/tableColourStore'
import client from '../../../api/client'
import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
]
const STATUSES = [
{ key: 'free', label: 'Ελεύθερο' },
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
{ key: 'paid', label: 'Πληρωμένο' },
]
const STATUS_LABELS_MOCK = {
free: 'ΕΛΕΥΘΕΡΟ',
open: 'ΑΝΟΙΧΤΟ',
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
}
// Quick-suggest palettes per slot type
const QUICK_SWATCHES = {
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
// Parse any css colour string into { hex, alpha }.
// Handles: #rrggbb, #rgb, rgba(r,g,b,a), rgb(r,g,b)
function parseColour(v) {
if (!v) return { hex: '#ffffff', alpha: 1 }
const s = v.trim()
// rgba / rgb
const rgbaMatch = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/)
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0')
const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0')
const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0')
const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1
return { hex: `#${r}${g}${b}`, alpha: Math.min(1, Math.max(0, a)) }
}
// #rgb shorthand
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
const [, r, g, b] = s
return { hex: `#${r}${r}${g}${g}${b}${b}`, alpha: 1 }
}
// #rrggbb
if (/^#[0-9a-fA-F]{6}$/.test(s)) return { hex: s, alpha: 1 }
return { hex: '#ffffff', alpha: 1 }
}
function buildColour(hex, alpha) {
if (alpha >= 1) return hex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha.toFixed(2)})`
}
function ColourPickerModal({ value, onClose, onChange, slot }) {
const parsed = parseColour(value)
const [hex, setHex] = useState(parsed.hex)
const [alpha, setAlpha] = useState(parsed.alpha)
// keep parent in sync whenever hex or alpha changes
useEffect(() => { onChange(buildColour(hex, alpha)) }, [hex, alpha])
function commitSwatch(v) {
const p = parseColour(v)
setHex(p.hex)
setAlpha(p.alpha)
}
const preview = buildColour(hex, alpha)
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
onClick={onClose}
>
<div
style={{
background: '#fff', borderRadius: 20, padding: 28, width: '100%', maxWidth: 400,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
{/* Preview swatch — checkerboard behind so alpha is visible */}
<div style={{
width: '100%', height: 56, borderRadius: 12, marginBottom: 20,
border: '1px solid #e5e7eb', overflow: 'hidden', position: 'relative',
backgroundImage: 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)',
backgroundSize: '12px 12px',
backgroundPosition: '0 0,0 6px,6px -6px,-6px 0',
}}>
<div style={{
position: 'absolute', inset: 0, background: preview,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontFamily: 'monospace', color: alpha > 0.5 ? '#fff' : '#374151',
textShadow: alpha > 0.5 ? '0 1px 3px rgba(0,0,0,0.5)' : 'none',
}}>
{preview}
</div>
</div>
{/* Colour picker + hex input */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Χρώμα</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
type="color"
value={hex}
onChange={e => setHex(e.target.value)}
style={{ width: 48, height: 40, borderRadius: 8, border: '1px solid #e5e7eb', cursor: 'pointer', padding: 2, flexShrink: 0 }}
/>
<input
type="text"
value={hex}
onChange={e => {
const v = e.target.value
setHex(v)
}}
spellCheck={false}
style={{
flex: 1, height: 40, borderRadius: 8, border: '1px solid #e5e7eb',
padding: '0 12px', fontSize: 13, fontFamily: 'monospace', color: '#111827',
}}
/>
</div>
</div>
{/* Opacity slider — always visible */}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>Διαφάνεια</div>
<div style={{ fontSize: 12, fontFamily: 'monospace', color: '#6b7280' }}>{Math.round(alpha * 100)}%</div>
</div>
{/* Gradient track so you can see what you're dragging */}
<div style={{
position: 'relative', height: 28,
background: `linear-gradient(to right, transparent, ${hex})`,
borderRadius: 8, border: '1px solid #e5e7eb',
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%),linear-gradient(to right,transparent,${hex})`,
backgroundSize: '10px 10px,10px 10px,10px 10px,10px 10px,100% 100%',
backgroundPosition: '0 0,0 5px,5px -5px,-5px 0,0 0',
}}>
<input
type="range"
min={0} max={1} step={0.01}
value={alpha}
onChange={e => setAlpha(parseFloat(e.target.value))}
style={{
position: 'absolute', inset: 0, width: '100%', height: '100%',
opacity: 0, cursor: 'pointer', margin: 0,
}}
/>
{/* thumb indicator */}
<div style={{
position: 'absolute', top: '50%', transform: 'translate(-50%,-50%)',
left: `${alpha * 100}%`,
width: 20, height: 20, borderRadius: '50%',
background: preview, border: '2px solid #fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}} />
</div>
</div>
{/* Quick swatches */}
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 8 }}>Γρήγορη επιλογή</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(QUICK_SWATCHES[slot] || []).map(c => {
const p = parseColour(c)
const built = buildColour(p.hex, p.alpha)
return (
<button
key={c}
title={c}
onClick={() => commitSwatch(c)}
style={{
width: 36, height: 36, borderRadius: 8,
backgroundImage: `linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)`,
backgroundSize: '8px 8px',
backgroundPosition: '0 0,0 4px,4px -4px,-4px 0',
position: 'relative', overflow: 'hidden',
border: built === preview ? '3px solid #3758c9' : '2px solid #e5e7eb',
cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
>
<div style={{ position: 'absolute', inset: 0, background: c }} />
</button>
)
})}
</div>
</div>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid #f3f4f6', display: 'flex', gap: 10 }}>
<button
onClick={onClose}
style={{
flex: 1, height: 40, borderRadius: 10, border: '1px solid #e5e7eb',
background: '#f9fafb', fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}
>Κλείσιμο</button>
</div>
</div>
</div>
)
}
// ─── Single colour slot row ──────────────────────────────────────────────────
function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
<button
onClick={() => onOpen(mode, status, slotKey, value)}
style={{
width: 44, height: 28, borderRadius: 8, background: value,
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'monospace', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
</div>
</div>
)
}
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
{/* Table name + group */}
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{
fontSize: 17, fontWeight: 800, color: cfg.nameText,
lineHeight: 1, letterSpacing: -0.5,
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
{label}
</span>
</div>
</div>
)
}
// ─── Preview panel (6 mock cards per theme) ──────────────────────────────────
function PreviewPanel({ colours, mode }) {
const isDark = mode === 'dark'
const panelBg = isDark ? '#0d1520' : '#f1f5f9'
const panelLabel = isDark ? '🌙 Προεπισκόπηση σκοτεινού θέματος' : '☀️ Προεπισκόπηση φωτεινού θέματος'
const labelCol = isDark ? '#94a3b8' : '#64748b'
const mockCards = [
{ status: 'free', name: 'TABLE 1', group: 'ΜΕΣΑ' },
{ status: 'open', name: 'TABLE 2', group: 'ΜΕΣΑ' },
{ status: 'mine', name: 'TABLE 3', group: 'ΜΕΣΑ' },
{ status: 'partially_paid', name: 'TABLE 4', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'paid', name: 'TABLE 5', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
{ status: 'free', name: 'TABLE 6', group: 'ΞΑΠΛΩΣΤΡΕΣ' },
]
return (
<div style={{
background: panelBg, borderRadius: 16, padding: 16,
border: '1px solid ' + (isDark ? '#253245' : '#cbd5e1'),
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: labelCol, marginBottom: 12, letterSpacing: 0.3 }}>
{panelLabel}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{mockCards.map((mc, i) => (
<MockCard
key={i}
cfg={colours[mode][mc.status]}
label={STATUS_LABELS_MOCK[mc.status]}
mockName={mc.name}
groupName={mc.group}
/>
))}
</div>
</div>
)
}
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
const cfg = colours[mode][status]
return (
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 88, flexShrink: 0 }}>
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
{SLOTS.map(slot => (
<ColourSlotRow
key={slot.key}
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
))}
</div>
</div>
)
}
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
return (
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111827', marginBottom: 14 }}>{label}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{STATUSES.map(s => (
<StatusBlock
key={s.key}
mode={mode}
status={s.key}
label={s.label}
colours={colours}
onOpen={onOpen}
/>
))}
</div>
</div>
)
}
// ─── Main tab ────────────────────────────────────────────────────────────────
export default function ColoursTab() {
const [colours, setColours] = useState(DEFAULT_COLOURS)
const [modal, setModal] = useState(null) // { mode, status, slot, value }
const [saving, setSaving] = useState(false)
const saveTimer = useRef(null)
// Load from backend on mount
useEffect(() => {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
}
})
}, [])
// Debounced save to backend — 600 ms after last change
const saveToBackend = useCallback((next) => {
clearTimeout(saveTimer.current)
setSaving(true)
saveTimer.current = setTimeout(() => {
client.put('/api/settings/ui.table_colours', { value: JSON.stringify(next) })
.then(() => setSaving(false))
.catch(() => { toast.error('Σφάλμα αποθήκευσης χρωμάτων'); setSaving(false) })
}, 600)
}, [])
function setColour(mode, status, slot, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
[status]: { ...prev[mode][status], [slot]: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
function handleChange(value) {
setColour(modal.mode, modal.status, modal.slot, value)
setModal(m => ({ ...m, value }))
}
function handleReset() {
if (window.confirm('Επαναφορά όλων των χρωμάτων στις προεπιλογές; Δεν μπορεί να αναιρεθεί.')) {
setColours(DEFAULT_COLOURS)
saveToBackend(DEFAULT_COLOURS)
}
}
return (
<div>
<div className="card" style={{ padding: 24 }}>
{saving && <p style={{ fontSize: 12, color: '#9ca3af', marginBottom: 16 }}>Αποθήκευση</p>}
{/* Live previews side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 32 }}>
<PreviewPanel colours={colours} mode="light" />
<PreviewPanel colours={colours} mode="dark" />
</div>
{/* Light + Dark mode settings */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
<ModeSection mode="light" colours={colours} onOpen={openModal} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
</div>
</div>
{/* Reset all button at bottom */}
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
height: 40, padding: '0 20px', borderRadius: 10,
border: '1.5px solid #fca5a5', background: '#fff5f5',
color: '#dc2626', fontSize: 14, fontWeight: 600, cursor: 'pointer',
}}
>
Επαναφορά προεπιλογών
</button>
</div>
{/* Colour picker modal */}
{modal && (
<ColourPickerModal
value={modal.value}
slot={modal.slot}
onClose={() => setModal(null)}
onChange={handleChange}
/>
)}
</div>
)
}