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,687 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Gift, Power, Pencil, Trash2, 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: () => client.get('/api/pricing/deals').then(r => r.data),
create: body => client.post('/api/pricing/deals', body).then(r => r.data),
update: (id,b) => client.put(`/api/pricing/deals/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/deals/${id}/toggle`).then(r => r.data),
remove: id => client.delete(`/api/pricing/deals/${id}`),
modifiers: () => client.get('/api/pricing/modifiers').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),
}
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
const CONDITION_TYPES = [
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα αντικειμένου' },
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
{ value: 'time_range', label: 'Ώρα ημέρας' },
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
{ value: 'price_group_active',label: 'Ομάδα τιμής ενεργή' },
]
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
const CHANNELS = ['pos','online','qr','takeaway']
const ACTION_TYPES = [
{ value: 'apply_modifier', label: 'Εφαρμογή τροποποιητή' },
{ value: 'set_price', label: 'Ορισμός τιμής' },
{ value: 'add_amount', label: '± Ποσό €' },
{ value: 'add_percent', label: '± Ποσοστό %' },
{ value: 'free_item', label: 'Δωρεάν προϊόν' },
{ value: 'free_choice', label: 'Επιλογή δωρεάν προϊόντος' },
]
// ─── Reuse condition editor (simplified for deals) ────────────────────────────
function ConditionRow({ cond, onChange, onRemove, groups }) {
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>
{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-28 text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</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-28 text-[12px]"
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{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 === '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 === '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 === '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>
<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>
)}
</div>
)
}
// ─── Shared multi-picker (same logic as ModifiersTab) ─────────────────────────
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id' }) {
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">Δεν βρέθηκαν αποτελέσματα</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">
{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.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>
)}
<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>
)
}
// ─── Deal target row ──────────────────────────────────────────────────────────
function DealTargetRow({ target, onChange, onRemove, products, categories, 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="any">Οποιοδήποτε προϊόν</option>
<option value="item">Προϊόντα</option>
<option value="category">Κατηγορίες</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 === 'tag' && (
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
)}
</div>
)
}
// ─── Deal form ────────────────────────────────────────────────────────────────
const DEAL_TABS = [
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'trigger', label: 'Trigger & Συνθήκες' },
{ key: 'action', label: 'Ενέργεια' },
]
function DealForm({ initial, onSave, onCancel, saving, modifiers, 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: 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[6], is_active: true, sort_order: 0,
action_type: 'free_item',
action_modifier_id: null, action_value: null,
action_free_item_id: null,
action_free_target_type: 'item', action_free_target_ids: [],
action_free_quantity: 1,
conditions: [], targets: [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
}
const [f, setF] = useState(initial ? {
...blank, ...initial,
conditions: initial.conditions ?? [],
targets: (initial.targets ?? [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }]).map(normalizeTarget),
action_free_target_ids: initial.action_free_target_ids ?? [],
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const addCond = () => set('conditions', [...f.conditions, { condition_type: 'min_item_quantity', params: { min: 1 } }])
const updCond = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
const remCond = i => set('conditions', f.conditions.filter((_, j) => j !== i))
const addTarget = () => set('targets', [...f.targets, { target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
const updTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
const remTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
const toggleFreeId = id => {
const ids = f.action_free_target_ids ?? []
set('action_free_target_ids', ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
}
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
onSave({
...f,
action_free_target_ids: f.action_free_target_ids?.length ? f.action_free_target_ids : null,
})
}
return (
<form id="deal-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="π.χ. 3+1 Μπύρες" 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 className="flex items-center justify-between">
<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>
<label className="flex items-center gap-2 cursor-pointer select-none">
<div onClick={() => set('is_active', !f.is_active)}
className={`relative rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ width: 32, height: 18 }}>
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-3.5' : ''}`} />
</div>
<span className="text-[12px] text-slate-700">Ενεργή</span>
</label>
</div>
</>
)}
{/* ── Trigger & Conditions tab ── */}
{activeTab === 'trigger' && (
<>
<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">Trigger</span>
<span className="text-[11px] text-slate-400 ml-1.5">Αγορά αυτών</span>
</div>
<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>
<p className="text-[11px] text-slate-400">
Ποια προϊόντα πρέπει να υπάρχουν στην παραγγελία για να ενεργοποιηθεί η προσφορά;
</p>
{f.targets.map((t, i) => (
<DealTargetRow key={i} target={t} onChange={v => updTarget(i, v)} onRemove={() => remTarget(i)}
products={products} categories={categories} allTags={allTags} />
))}
</div>
<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">(προαιρετικά)</span>
</div>
<button type="button" onClick={addCond}
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">
Χωρίς επιπλέον συνθήκες αρκεί μόνο το trigger.
</p>
)}
{f.conditions.map((c, i) => (
<ConditionRow key={i} cond={c} onChange={v => updCond(i, v)} onRemove={() => remCond(i)} groups={groups} />
))}
</div>
</>
)}
{/* ── Action tab ── */}
{activeTab === 'action' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ACTION_TYPES.map(({ value, label }) => (
<button type="button" key={value} onClick={() => set('action_type', value)}
className={`py-2 rounded-lg border text-[12px] font-medium transition-colors text-center ${
f.action_type === value ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{label}</button>
))}
</div>
{f.action_type === 'apply_modifier' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Τροποποιητής (χωρίς συνθήκες)</label>
<select className="input w-full text-[12px]" value={f.action_modifier_id ?? ''}
onChange={e => set('action_modifier_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(modifiers ?? []).filter(m => !m.conditions?.length).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[11px] text-slate-400 mt-1">Εμφανίζονται μόνο τροποποιητές χωρίς συνθήκες (χειροκίνητοι).</p>
</div>
)}
{(f.action_type === 'set_price' || f.action_type === 'add_amount' || f.action_type === 'add_percent') && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">
{f.action_type === 'set_price' ? 'Νέα τιμή (€)' :
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
'Ποσοστό (%, αρνητικό = έκπτωση)'}
</label>
<input type="number" step={0.1} className="input w-36 text-[12px]"
value={f.action_value ?? 0} onChange={e => set('action_value', Number(e.target.value))} />
</div>
)}
{f.action_type === 'free_item' && (
<div className="space-y-3">
<div>
<label className="block text-[11px] text-slate-500 mb-1">Δωρεάν προϊόν</label>
<select className="input w-full text-[12px]" value={f.action_free_item_id ?? ''}
onChange={e => set('action_free_item_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
{f.action_type === 'free_choice' && (
<div className="space-y-2">
<div className="flex gap-2">
{[['item','Προϊόντα'],['category','Κατηγορία'],['tag','Ετικέτα']].map(([v,l]) => (
<button type="button" key={v} onClick={() => { set('action_free_target_type', v); set('action_free_target_ids', []) }}
className={`px-3 py-1 rounded text-[12px] font-medium border transition-colors ${
f.action_free_target_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
{f.action_free_target_type === 'item' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{products.map(p => (
<label key={p.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(p.id)}
onChange={() => toggleFreeId(p.id)} />
<span className="text-[12px] text-slate-700">{p.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'category' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{categories.map(c => (
<label key={c.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(c.id)}
onChange={() => toggleFreeId(c.id)} />
<span className="text-[12px] text-slate-700">{c.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'tag' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ετικέτες (μία ανά γραμμή)</label>
<textarea className="input w-full text-[12px] font-mono" rows={3}
value={(f.action_free_target_ids ?? []).join('\n')}
onChange={e => set('action_free_target_ids', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
</div>
)}
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα δωρεάν</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
</div>
)}
</form>
)
}
// ─── Deal card ────────────────────────────────────────────────────────────────
function DealCard({ deal, onEdit, onDelete }) {
const qc = useQueryClient()
const toggle = useMutation({
mutationFn: () => api.toggle(deal.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-deals'] }),
onError: () => toast.error('Σφάλμα'),
})
const actionLabel = ACTION_TYPES.find(a => a.value === deal.action_type)?.label ?? deal.action_type
return (
<div className={`bg-white rounded-xl border shadow-sm p-4 flex items-start gap-3 transition-opacity ${deal.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: deal.color ?? '#22c55e' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-slate-800">{deal.name}</span>
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
deal.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>{deal.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
<span className="text-[11px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{actionLabel}</span>
</div>
{deal.description && <p className="text-[12px] text-slate-500 mt-0.5">{deal.description}</p>}
<p className="text-[11px] text-slate-400 mt-1">
{deal.conditions?.length ?? 0} συνθήκες · {deal.targets?.length ?? 0} targets
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded-lg transition-colors ${
deal.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => onEdit(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => onDelete(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function DealsTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [formTab, setFormTab] = useState('info')
const { data: deals = [], isLoading } = useQuery({ queryKey: ['pricing-deals'], queryFn: api.list })
const { data: modifiers = [] } = useQuery({ queryKey: ['pricing-modifiers'], queryFn: api.modifiers })
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: () => client.get('/api/pricing/groups').then(r => r.data) })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setShowForm(false); toast.success('Δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα'),
})
const formProps = { modifiers, groups }
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>
<div className="flex items-start gap-3 bg-sky-50 border border-sky-200 rounded-lg px-4 py-3">
<Info className="w-4 h-4 text-sky-500 shrink-0 mt-0.5" />
<p className="text-[12px] text-sky-700">
Όταν μια προσφορά ενεργοποιηθεί, ο σερβιτόρος λαμβάνει prompt στην εφαρμογή και επιλέγει αν θα την εφαρμόσει.
Τίποτα δεν εφαρμόζεται αυτόματα χωρίς επιβεβαίωση.
</p>
</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>
) : deals.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Gift className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν προσφορές</p>
<p className="text-[12px] mt-1">π.χ. «Αγόρασε 3 μπύρες, πάρε 1 δωρεάν»</p>
</div>
) : (
<div className="space-y-3">
{deals.map(d => (
<DealCard key={d.id} deal={d} onEdit={d => { setEditing(d); setFormTab('info') }} onDelete={setDeleting} />
))}
</div>
)}
{showForm && (
<Modal title="Νέα Προσφορά" onClose={() => { setShowForm(false); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={create.isPending}>
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
saving={create.isPending} {...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{editing && (
<Modal title="Επεξεργασία Προσφοράς" onClose={() => { setEditing(null); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={update.isPending}>
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
{...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{deleting && (
<ConfirmModal title="Διαγραφή Προσφοράς"
message={`Θέλετε να διαγράψετε την προσφορά "${deleting.name}";`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)} />
)}
</div>
)
}