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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View 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>
)
}