Files
xenia-pos-local/manager_dashboard/src/pages/Management/pricing/ModifiersTab.jsx
bonamin 34ae328b0d 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>
2026-07-19 10:00:14 +03:00

811 lines
39 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 } 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>
)
}