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>
688 lines
34 KiB
JavaScript
688 lines
34 KiB
JavaScript
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>
|
||
)
|
||
}
|