Files
simple-pos-system/manager_dashboard/src/pages/Settings/tabs/ColoursTab.jsx

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