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:
810
manager_dashboard/src/pages/Management/pricing/ModifiersTab.jsx
Normal file
810
manager_dashboard/src/pages/Management/pricing/ModifiersTab.jsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus, Percent, Power, Pencil, Trash2, Star, Layers,
|
||||
GripVertical, ChevronDown, ChevronUp, X, Info,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import { ConfirmModal } from '../../../ui/Modal'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
list: (params) => client.get('/api/pricing/modifiers', { params }).then(r => r.data),
|
||||
create: body => client.post('/api/pricing/modifiers', body).then(r => r.data),
|
||||
update: (id, b) => client.put(`/api/pricing/modifiers/${id}`, b).then(r => r.data),
|
||||
toggle: id => client.patch(`/api/pricing/modifiers/${id}/toggle`).then(r => r.data),
|
||||
reorder: items => client.patch('/api/pricing/modifiers/reorder', { items }),
|
||||
remove: id => client.delete(`/api/pricing/modifiers/${id}`),
|
||||
groups: () => client.get('/api/pricing/groups').then(r => r.data),
|
||||
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
|
||||
categories: () => client.get('/api/products/categories').then(r => r.data),
|
||||
prepZones: () => client.get('/api/prep-zones').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
|
||||
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
|
||||
]
|
||||
|
||||
const CONDITION_TYPES = [
|
||||
{ value: 'time_range', label: 'Ώρα ημέρας (εύρος)' },
|
||||
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
|
||||
{ value: 'specific_date', label: 'Συγκεκριμένη ημερομηνία' },
|
||||
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
|
||||
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα προϊόντος' },
|
||||
{ value: 'min_category_qty', label: 'Ελάχιστη ποσότητα κατηγορίας' },
|
||||
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
|
||||
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
|
||||
{ value: 'price_group_active', label: 'Ομάδα τιμής ενεργή' },
|
||||
{ value: 'user_tier', label: 'Επίπεδο πελάτη (placeholder)' },
|
||||
{ value: 'low_stock', label: 'Χαμηλό απόθεμα (placeholder)' },
|
||||
]
|
||||
|
||||
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
|
||||
const CHANNELS = ['pos','online','qr','takeaway']
|
||||
|
||||
const ROUND_OPTIONS = [
|
||||
{ value: '', label: 'Χωρίς στρογγυλοποίηση' },
|
||||
{ value: '0.05', label: 'Στο κοντινότερο €0,05' },
|
||||
{ value: '0.10', label: 'Στο κοντινότερο €0,10' },
|
||||
{ value: '0.20', label: 'Στο κοντινότερο €0,20' },
|
||||
{ value: '0.50', label: 'Στο κοντινότερο €0,50' },
|
||||
{ value: 'x.99', label: 'Σε x,99 (π.χ. 4,99)' },
|
||||
{ value: 'x.00', label: 'Σε x,00 (στρογγυλό)' },
|
||||
]
|
||||
|
||||
// ─── Condition editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function ConditionEditor({ cond, onChange, onRemove, groups, categories, prepZones }) {
|
||||
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
|
||||
const setType = t => onChange({ condition_type: t, params: {} })
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={cond.condition_type}
|
||||
onChange={e => setType(e.target.value)}>
|
||||
{CONDITION_TYPES.map(ct => (
|
||||
<option key={ct.value} value={ct.value}>{ct.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Params per type */}
|
||||
{cond.condition_type === 'time_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'date_range' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
|
||||
onChange={e => set('from', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
|
||||
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
|
||||
onChange={e => set('to', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'specific_date' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ημερομηνίες (μία ανά γραμμή)</label>
|
||||
<textarea className="input w-full text-[12px] font-mono" rows={3}
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={(cond.params.dates ?? []).join('\n')}
|
||||
onChange={e => set('dates', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'day_of_week' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DAYS.map((d, i) => (
|
||||
<button type="button" key={i}
|
||||
onClick={() => {
|
||||
const days = cond.params.days ?? []
|
||||
set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i])
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{d.slice(0, 3)}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_item_quantity' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
|
||||
<input type="number" min={1} className="input w-32 text-[12px]"
|
||||
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_category_qty' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Κατηγορία</label>
|
||||
<select className="input w-full text-[12px]" value={cond.params.category_id ?? ''}
|
||||
onChange={e => set('category_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{(categories ?? []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχ.</label>
|
||||
<input type="number" min={1} className="input w-full text-[12px]"
|
||||
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'min_order_value' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία παραγγελίας (€)</label>
|
||||
<input type="number" min={0} step={0.5} className="input w-32 text-[12px]"
|
||||
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'order_channel' && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHANNELS.map(ch => (
|
||||
<button type="button" key={ch}
|
||||
onClick={() => {
|
||||
const channels = cond.params.channels ?? []
|
||||
set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch])
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{ch}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cond.condition_type === 'price_group_active' && (
|
||||
<div>
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Ομάδα τιμής</label>
|
||||
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
|
||||
onChange={e => set('price_group_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή —</option>
|
||||
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(cond.condition_type === 'user_tier' || cond.condition_type === 'low_stock') && (
|
||||
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1 flex items-center gap-1.5">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
Placeholder — θα ενεργοποιηθεί σε μελλοντική έκδοση
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared multi-picker ──────────────────────────────────────────────────────
|
||||
|
||||
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id', placeholder = 'Επιλέξτε...' }) {
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
{items.length === 0 && (
|
||||
<p className="text-[11px] text-slate-400 px-3 py-2">{placeholder}</p>
|
||||
)}
|
||||
{items.map(item => {
|
||||
const val = item[valueKey]
|
||||
const checked = selected.includes(val)
|
||||
return (
|
||||
<div key={val} onClick={() => onToggle(val)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
|
||||
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
|
||||
}`}>
|
||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
{item[labelKey]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagPicker({ selected, onToggle, onAdd, allTags }) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const handleAdd = () => {
|
||||
const tag = draft.trim()
|
||||
if (tag && !selected.includes(tag)) onAdd(tag)
|
||||
setDraft('')
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Quick-select existing tags */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map(tag => {
|
||||
const active = selected.includes(tag)
|
||||
return (
|
||||
<button type="button" key={tag} onClick={() => onToggle(tag)}
|
||||
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
|
||||
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
|
||||
}`}>{tag}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map(tag => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
|
||||
{tag}
|
||||
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Manual entry */}
|
||||
<div className="flex gap-2">
|
||||
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
|
||||
<button type="button" onClick={handleAdd}
|
||||
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Target editor ────────────────────────────────────────────────────────────
|
||||
|
||||
function TargetEditor({ target, onChange, onRemove, products, categories, prepZones, allTags }) {
|
||||
const ids = target.target_ids ?? []
|
||||
const tags = target.target_tags ?? []
|
||||
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
|
||||
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
|
||||
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
|
||||
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="input flex-1 text-[12px]" value={target.target_type}
|
||||
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
|
||||
<option value="all">Όλα τα προϊόντα</option>
|
||||
<option value="item">Προϊόντα</option>
|
||||
<option value="category">Κατηγορίες</option>
|
||||
<option value="prep_zone">Ζώνες ετοιμασίας</option>
|
||||
<option value="tag">Ετικέτες (tags)</option>
|
||||
</select>
|
||||
<button type="button" onClick={onRemove}
|
||||
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{target.target_type === 'item' && (
|
||||
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'category' && (
|
||||
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'prep_zone' && (
|
||||
<MultiPicker items={prepZones} selected={ids} onToggle={toggleId} />
|
||||
)}
|
||||
{target.target_type === 'tag' && (
|
||||
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modifier form ────────────────────────────────────────────────────────────
|
||||
|
||||
const MODIFIER_TABS = [
|
||||
{ key: 'info', label: 'Πληροφορίες' },
|
||||
{ key: 'target', label: 'Στόχος' },
|
||||
{ key: 'conditions', label: 'Συνθήκες' },
|
||||
{ key: 'price', label: 'Τιμή' },
|
||||
]
|
||||
|
||||
function ModifierForm({ initial, onSave, onCancel, saving, groups, activeTab, onTabChange }) {
|
||||
const { data: products = [] } = useQuery({
|
||||
queryKey: ['products-all-pricing'],
|
||||
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
|
||||
})
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => client.get('/api/products/categories').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: prepZones = [] } = useQuery({
|
||||
queryKey: ['prep-zones'],
|
||||
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ['product-tags'],
|
||||
queryFn: () => client.get('/api/products/tags').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const normalizeTarget = t => ({
|
||||
...t,
|
||||
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
|
||||
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
|
||||
})
|
||||
|
||||
const blank = {
|
||||
name: '', description: '', color: COLORS[0], is_active: true, is_favorite: false,
|
||||
allow_stack: false, sort_order: 0, scope: 'global', item_id: null,
|
||||
action_type: 'add_percent', action_value: -10, round_to: '',
|
||||
conditions: [], targets: [{ target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
|
||||
}
|
||||
const [f, setF] = useState(initial ? {
|
||||
...initial,
|
||||
action_value: initial.action_value ?? 0,
|
||||
round_to: initial.round_to ?? '',
|
||||
conditions: initial.conditions ?? [],
|
||||
targets: (initial.targets ?? []).map(normalizeTarget),
|
||||
} : blank)
|
||||
|
||||
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
|
||||
|
||||
const addCondition = () => set('conditions', [...f.conditions, { condition_type: 'time_range', params: {} }])
|
||||
const updateCondition = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
|
||||
const removeCondition = i => set('conditions', f.conditions.filter((_, j) => j !== i))
|
||||
|
||||
const addTarget = () => set('targets', [...f.targets, { target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
|
||||
const updateTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
|
||||
const removeTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
|
||||
if (f.scope === 'item' && !f.item_id) return toast.error('Επιλέξτε προϊόν για item-scope')
|
||||
onSave({ ...f, round_to: f.round_to || null })
|
||||
}
|
||||
|
||||
const actionLabel = f.action_type === 'set' ? 'Νέα τιμή (€)' :
|
||||
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
|
||||
'Ποσοστό (%, αρνητικό = έκπτωση)'
|
||||
|
||||
return (
|
||||
<form id="modifier-form" onSubmit={submit} className="space-y-5">
|
||||
|
||||
{/* ── Info tab ── */}
|
||||
{activeTab === 'info' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
|
||||
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
|
||||
placeholder="π.χ. Happy Hour -10%" autoFocus />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
|
||||
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map(c => (
|
||||
<button type="button" key={c} onClick={() => set('color', c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
|
||||
style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[
|
||||
{ key: 'is_active', label: 'Ενεργός' },
|
||||
{ key: 'is_favorite', label: 'Αγαπημένος (dashboard)' },
|
||||
{ key: 'allow_stack', label: 'Επιτρέπει συσσώρευση' },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<div onClick={() => set(key, !f[key])}
|
||||
className={`relative rounded-full transition-colors ${f[key] ? 'bg-sky-500' : 'bg-slate-200'}`}
|
||||
style={{ height: '18px', width: '32px' }}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f[key] ? 'translate-x-3.5' : ''}`} />
|
||||
</div>
|
||||
<span className="text-[12px] text-slate-700">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Target tab ── */}
|
||||
{activeTab === 'target' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-slate-600 mb-1">Εμβέλεια</label>
|
||||
<div className="flex gap-2">
|
||||
{[['global','Καθολικός (με στόχους)'],['item','Συγκεκριμένο προϊόν']].map(([v, l]) => (
|
||||
<button type="button" key={v} onClick={() => set('scope', v)}
|
||||
className={`flex-1 py-2 rounded-lg border text-[12px] font-medium transition-colors ${
|
||||
f.scope === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
{f.scope === 'item' && (
|
||||
<select className="input w-full mt-2 text-[13px]" value={f.item_id ?? ''}
|
||||
onChange={e => set('item_id', Number(e.target.value))}>
|
||||
<option value="">— Επιλογή προϊόντος —</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{f.scope === 'global' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">
|
||||
Στόχοι <span className="font-normal text-slate-400 normal-case ml-1">(OR — αρκεί ένας)</span>
|
||||
</span>
|
||||
<button type="button" onClick={addTarget}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
{f.targets.length === 0 && (
|
||||
<p className="text-[12px] text-amber-600 bg-amber-50 rounded-lg px-3 py-2.5">
|
||||
Χωρίς στόχο — ο τροποποιητής δεν θα εφαρμοστεί σε κανένα προϊόν.
|
||||
</p>
|
||||
)}
|
||||
{f.targets.map((t, i) => (
|
||||
<TargetEditor key={i} target={t}
|
||||
onChange={v => updateTarget(i, v)}
|
||||
onRemove={() => removeTarget(i)}
|
||||
products={products} categories={categories} prepZones={prepZones} allTags={allTags} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Conditions tab ── */}
|
||||
{activeTab === 'conditions' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Συνθήκες</span>
|
||||
<span className="text-[11px] text-slate-400 ml-1.5">AND — όλες πρέπει να ισχύουν</span>
|
||||
</div>
|
||||
<button type="button" onClick={addCondition}
|
||||
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />Προσθήκη
|
||||
</button>
|
||||
</div>
|
||||
{f.conditions.length === 0 && (
|
||||
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
|
||||
Χωρίς συνθήκες — ο τροποποιητής εφαρμόζεται χειροκίνητα μέσω ενεργοποίησης.
|
||||
</p>
|
||||
)}
|
||||
{f.conditions.map((c, i) => (
|
||||
<ConditionEditor key={i} cond={c}
|
||||
onChange={v => updateCondition(i, v)}
|
||||
onRemove={() => removeCondition(i)}
|
||||
groups={groups} categories={categories} prepZones={prepZones} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Price Modifier tab ── */}
|
||||
{activeTab === 'price' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{[['set','Ορισμός τιμής'],['add_amount','± Ποσό €'],['add_percent','± Ποσοστό %']].map(([v,l]) => (
|
||||
<button type="button" key={v} onClick={() => set('action_type', v)}
|
||||
className={`flex-1 py-1.5 rounded-lg border text-[12px] font-medium transition-colors ${
|
||||
f.action_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">{actionLabel}</label>
|
||||
<input type="number" step={f.action_type === 'set' ? 0.01 : f.action_type === 'add_amount' ? 0.1 : 1}
|
||||
className="input w-full" value={f.action_value}
|
||||
onChange={e => set('action_value', Number(e.target.value))} />
|
||||
</div>
|
||||
{f.action_type === 'add_percent' && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-500 mb-0.5">Στρογγυλοποίηση (προαιρ.)</label>
|
||||
<select className="input w-full text-[12px]" value={f.round_to ?? ''}
|
||||
onChange={e => set('round_to', e.target.value)}>
|
||||
{ROUND_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Το σύστημα στρογγυλοποιεί πάντα την τελική τιμή στο κοντινότερο €0,10 ανεξάρτητα από την παραπάνω επιλογή.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modifier card ────────────────────────────────────────────────────────────
|
||||
|
||||
function ModifierCard({ modifier, onEdit, onDelete, dragHandleProps }) {
|
||||
const qc = useQueryClient()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.toggle(modifier.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }),
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const actionText = modifier.action_type === 'set'
|
||||
? `= €${modifier.action_value}`
|
||||
: modifier.action_type === 'add_amount'
|
||||
? `${modifier.action_value >= 0 ? '+' : ''}€${modifier.action_value}`
|
||||
: `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}%`
|
||||
|
||||
const condCount = modifier.conditions?.length ?? 0
|
||||
const targetSummary = modifier.scope === 'item'
|
||||
? 'item-scope'
|
||||
: modifier.targets?.length === 1 && modifier.targets[0]?.target_type === 'all'
|
||||
? 'Όλα τα προϊόντα'
|
||||
: `${modifier.targets?.length ?? 0} στόχοι`
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm transition-opacity ${modifier.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
{/* Drag handle */}
|
||||
<div {...dragHandleProps} className="cursor-grab text-slate-300 hover:text-slate-400 shrink-0">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Color bar */}
|
||||
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: modifier.color ?? '#64748b' }} />
|
||||
|
||||
{/* Name + badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-semibold text-slate-800 truncate">{modifier.name}</span>
|
||||
{modifier.is_favorite && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
|
||||
{modifier.allow_stack && <Layers className="w-3 h-3 text-sky-400 shrink-0" title="Stackable" />}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
|
||||
modifier.action_value < 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-sky-100 text-sky-700'
|
||||
}`}>{actionText}</span>
|
||||
<span className="text-[10px] text-slate-400">{condCount === 0 ? 'χειροκίνητος' : `${condCount} συνθ.`} · {targetSummary}</span>
|
||||
</div>
|
||||
{modifier.description && <p className="text-[11px] text-slate-400 truncate mt-0.5">{modifier.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 transition-colors">
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => toggle.mutate()}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
modifier.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
|
||||
}`} title={modifier.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onEdit(modifier)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(modifier)}
|
||||
className="p-1.5 rounded text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-100 px-4 py-3 text-[12px] text-slate-500 space-y-1.5">
|
||||
{modifier.conditions?.length === 0 && <p className="italic">Χωρίς αυτόματες συνθήκες — μόνο χειροκίνητη ενεργοποίηση</p>}
|
||||
{modifier.conditions?.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-sky-400 shrink-0" />
|
||||
<span>{CONDITION_TYPES.find(x => x.value === c.condition_type)?.label ?? c.condition_type}</span>
|
||||
<span className="text-slate-400 font-mono text-[11px]">{JSON.stringify(c.params)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ModifiersTab() {
|
||||
const qc = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
const [formTab, setFormTab] = useState('info')
|
||||
const [scopeFilter, setScopeFilter] = useState('all')
|
||||
const [items, setItems] = useState(null)
|
||||
const [dragging, setDragging] = useState(null)
|
||||
|
||||
const { data: rawModifiers = [], isLoading } = useQuery({
|
||||
queryKey: ['pricing-modifiers'],
|
||||
queryFn: () => api.list(),
|
||||
})
|
||||
const modifiers = items ?? rawModifiers
|
||||
|
||||
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.groups, staleTime: 30_000 })
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: api.create,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setShowForm(false); toast.success('Δημιουργήθηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }) => api.update(id, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setEditing(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.remove,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setDeleting(null); toast.success('Διαγράφηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
const reorder = useMutation({
|
||||
mutationFn: api.reorder,
|
||||
onError: () => { setItems(null); toast.error('Σφάλμα αναδιάταξης') },
|
||||
})
|
||||
|
||||
// Simple drag-to-reorder (no extra library — HTML5 DnD)
|
||||
function handleDragStart(e, id) {
|
||||
setDragging(id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
function handleDragOver(e, id) {
|
||||
e.preventDefault()
|
||||
if (dragging == null || dragging === id) return
|
||||
const from = modifiers.findIndex(m => m.id === dragging)
|
||||
const to = modifiers.findIndex(m => m.id === id)
|
||||
if (from === -1 || to === -1) return
|
||||
const reordered = [...modifiers]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
setItems(reordered)
|
||||
}
|
||||
function handleDrop() {
|
||||
setDragging(null)
|
||||
const payload = (items ?? modifiers).map((m, i) => ({ id: m.id, sort_order: i }))
|
||||
reorder.mutate(payload)
|
||||
}
|
||||
|
||||
const filtered = scopeFilter === 'all' ? modifiers : modifiers.filter(m => m.scope === scopeFilter)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Τροποποιητές Τιμής</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Κανόνες που αλλάζουν τιμές αυτόματα ή χειροκίνητα. Η σειρά καθορίζει προτεραιότητα.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />Νέος Τροποποιητής
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="flex gap-2">
|
||||
{[['all','Όλοι'],['global','Καθολικοί'],['item','Item-scope']].map(([v,l]) => (
|
||||
<button key={v} onClick={() => setScopeFilter(v)}
|
||||
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||
scopeFilter === v ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Percent className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-[13px]">Δεν υπάρχουν τροποποιητές</p>
|
||||
<p className="text-[12px] mt-1">Δημιουργήστε έναν για Happy Hour, εποχιακές τιμές κ.λπ.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2" onDragOver={e => e.preventDefault()} onDrop={handleDrop}>
|
||||
{filtered.map(m => (
|
||||
<div key={m.id} draggable
|
||||
onDragStart={e => handleDragStart(e, m.id)}
|
||||
onDragOver={e => handleDragOver(e, m.id)}
|
||||
className={dragging === m.id ? 'opacity-50' : ''}>
|
||||
<ModifierCard modifier={m} onEdit={m => { setEditing(m); setFormTab('info') }} onDelete={setDeleting}
|
||||
dragHandleProps={{
|
||||
onMouseDown: () => {},
|
||||
title: 'Σύρετε για αναδιάταξη',
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create */}
|
||||
{showForm && (
|
||||
<Modal title="Νέος Τροποποιητής Τιμής" onClose={() => { setShowForm(false); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="modifier-form" variant="primary" disabled={create.isPending}>
|
||||
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<ModifierForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
|
||||
saving={create.isPending} groups={groups} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{editing && (
|
||||
<Modal title="Επεξεργασία Τροποποιητή" onClose={() => { setEditing(null); setFormTab('info') }}
|
||||
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
|
||||
footer={<>
|
||||
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
|
||||
<Button type="submit" form="modifier-form" variant="primary" disabled={update.isPending}>
|
||||
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</>}>
|
||||
<ModifierForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
|
||||
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
|
||||
groups={groups} activeTab={formTab} onTabChange={setFormTab} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{deleting && (
|
||||
<ConfirmModal title="Διαγραφή Τροποποιητή"
|
||||
message={`Θέλετε να διαγράψετε τον τροποποιητή "${deleting.name}";`}
|
||||
confirmLabel="Διαγραφή"
|
||||
onConfirm={() => remove.mutate(deleting.id)}
|
||||
onCancel={() => setDeleting(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user