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:
@@ -0,0 +1,265 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, ShieldCheck, Save } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../../../api/client'
|
||||
import Button from '../../../ui/Button'
|
||||
|
||||
// ─── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
globalGet: () => client.get('/api/pricing/discount-settings/global').then(r => r.data),
|
||||
globalPut: body => client.put('/api/pricing/discount-settings/global', body).then(r => r.data),
|
||||
waiterGet: id => client.get(`/api/pricing/discount-settings/${id}`).then(r => r.data),
|
||||
waiterPut: (id,b) => client.put(`/api/pricing/discount-settings/${id}`, b).then(r => r.data),
|
||||
staff: () => client.get('/api/waiters/').then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Toggle helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ value, onChange }) {
|
||||
return (
|
||||
<div onClick={() => onChange(!value)}
|
||||
className={`relative rounded-full cursor-pointer transition-colors shrink-0 ${value ? 'bg-sky-500' : 'bg-slate-200'}`}
|
||||
style={{ width: 36, height: 20 }}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-4' : ''}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Numeric field ────────────────────────────────────────────────────────────
|
||||
|
||||
function NumField({ label, hint, value, onChange, step = 1, min = 0, unit = '' }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-[12px] font-medium text-slate-600">{label}</label>
|
||||
{hint && <p className="text-[11px] text-slate-400">{hint}</p>}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number" min={min} step={step}
|
||||
className="input w-28 text-[12px]"
|
||||
placeholder="—"
|
||||
value={value ?? ''}
|
||||
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
/>
|
||||
{unit && <span className="text-[12px] text-slate-500">{unit}</span>}
|
||||
{value !== null && value !== undefined && (
|
||||
<button type="button" onClick={() => onChange(null)}
|
||||
className="text-[11px] text-slate-400 hover:text-slate-600 underline ml-1">
|
||||
Αφαίρεση
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Global settings panel ────────────────────────────────────────────────────
|
||||
|
||||
function GlobalPanel() {
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['discount-global'], queryFn: api.globalGet })
|
||||
|
||||
const blankGlobal = {
|
||||
enabled: false,
|
||||
max_total_value_workday: null,
|
||||
max_total_value_shift: null,
|
||||
max_items_per_shift: null,
|
||||
}
|
||||
const [f, setF] = useState(null)
|
||||
const current = f ?? (data ? { ...blankGlobal, ...data } : blankGlobal)
|
||||
const set = (k, v) => setF(p => ({ ...(p ?? current), [k]: v }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.globalPut(current),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-global'] }); setF(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" /></div>
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-sky-100 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-4 h-4 text-sky-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">Καθολικές Ρυθμίσεις</h3>
|
||||
<p className="text-[11px] text-slate-500">Εφαρμόζονται σε όλους τους σερβιτόρους ανεξαρτήτως ατομικών ρυθμίσεων</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master enable */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-slate-700">Εκπτώσεις Ενεργές</span>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Αν είναι ανενεργό, κανένας σερβιτόρος δεν μπορεί να εφαρμόσει εκπτώσεις</p>
|
||||
</div>
|
||||
<Toggle value={current.enabled ?? false} onChange={v => set('enabled', v)} />
|
||||
</div>
|
||||
|
||||
{/* Limits */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<NumField label="Μέγιστο ανά εργάσιμη ημέρα" hint="Ολόκληρο το κατάστημα"
|
||||
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
|
||||
<NumField label="Μέγιστο ανά βάρδια" hint="Όλες οι βάρδιες μαζί"
|
||||
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
|
||||
<NumField label="Μέγιστα αντικείμενα/βάρδια" hint="Σύνολο αντικειμένων με έκπτωση"
|
||||
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Per-waiter settings panel ────────────────────────────────────────────────
|
||||
|
||||
const WAITER_BLANK = {
|
||||
can_apply_discounts: false,
|
||||
max_discount_percent: null,
|
||||
max_discount_amount: null,
|
||||
max_total_value_shift: null,
|
||||
max_total_value_workday: null,
|
||||
max_items_per_shift: null,
|
||||
max_items_per_workday: null,
|
||||
max_items_per_order: null,
|
||||
}
|
||||
|
||||
function WaiterPanel({ waiters }) {
|
||||
const qc = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState(waiters[0]?.id ?? null)
|
||||
const [dirty, setDirty] = useState(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['discount-waiter', selectedId],
|
||||
queryFn: () => api.waiterGet(selectedId),
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const current = dirty ?? (data ? { ...WAITER_BLANK, ...data } : WAITER_BLANK)
|
||||
const set = (k, v) => setDirty(p => ({ ...(p ?? current), [k]: v }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.waiterPut(selectedId, current),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-waiter', selectedId] }); setDirty(null); toast.success('Αποθηκεύτηκε') },
|
||||
onError: () => toast.error('Σφάλμα'),
|
||||
})
|
||||
|
||||
const selectedWaiter = waiters.find(w => w.id === selectedId)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<Users className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-800">Ανά Σερβιτόρο</h3>
|
||||
<p className="text-[11px] text-slate-500">Ατομικά όρια — ισχύουν παράλληλα με τα καθολικά</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waiter picker */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{waiters.map(w => (
|
||||
<button key={w.id} type="button"
|
||||
onClick={() => { setSelectedId(w.id); setDirty(null) }}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium border transition-colors ${
|
||||
w.id === selectedId ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
{w.nickname || w.full_name || w.username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!selectedId ? (
|
||||
<p className="text-[12px] text-slate-400 text-center py-8">Επιλέξτε σερβιτόρο</p>
|
||||
) : isLoading ? (
|
||||
<div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin" /></div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Can apply toggle */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<div>
|
||||
<span className="text-[13px] font-medium text-slate-700">
|
||||
{selectedWaiter?.nickname || selectedWaiter?.full_name || selectedWaiter?.username} — Δικαίωμα εκπτώσεων
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">Απενεργοποίηση κλειδώνει τον σερβιτόρο εντελώς</p>
|
||||
</div>
|
||||
<Toggle value={current.can_apply_discounts} onChange={v => set('can_apply_discounts', v)} />
|
||||
</div>
|
||||
|
||||
{/* Per-discount limits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Ανά Έκπτωση</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<NumField label="Μέγιστο ποσοστό %" hint="Μέγιστη έκπτωση σε % ανά εφαρμογή"
|
||||
value={current.max_discount_percent} onChange={v => set('max_discount_percent', v)} step={5} unit="%" />
|
||||
<NumField label="Μέγιστο ποσό €" hint="Μέγιστο € που αφαιρείται ανά εφαρμογή"
|
||||
value={current.max_discount_amount} onChange={v => set('max_discount_amount', v)} step={0.5} unit="€" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope limits */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Συνολικά Όρια</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<NumField label="Αξία/Βάρδια" hint="Συνολική αξία εκπτώσεων ανά βάρδια"
|
||||
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
|
||||
<NumField label="Αξία/Εργάσιμη ημέρα" hint="Συνολική αξία εκπτώσεων ανά ημέρα"
|
||||
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
|
||||
<NumField label="Αντικείμενα/Βάρδια"
|
||||
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
|
||||
<NumField label="Αντικείμενα/Εργάσιμη ημέρα"
|
||||
value={current.max_items_per_workday} onChange={v => set('max_items_per_workday', v)} unit="τεμ." />
|
||||
<NumField label="Αντικείμενα/Παραγγελία" hint="Μέγιστος αριθμός αντικειμένων με έκπτωση ανά παραγγελία"
|
||||
value={current.max_items_per_order} onChange={v => set('max_items_per_order', v)} unit="τεμ." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscountSettingsTab() {
|
||||
const { data: staff = [] } = useQuery({ queryKey: ['waiters'], queryFn: api.staff })
|
||||
const waiters = staff.filter(u => u.is_active !== false)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-slate-800">Ρυθμίσεις Εκπτώσεων</h2>
|
||||
<p className="text-[12px] text-slate-500 mt-0.5">
|
||||
Ελέγξτε ποιοι σερβιτόροι μπορούν να εφαρμόζουν εκπτώσεις και ποια είναι τα όρια ανά βάρδια ή εργάσιμη ημέρα.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlobalPanel />
|
||||
|
||||
{waiters.length > 0
|
||||
? <WaiterPanel waiters={waiters} />
|
||||
: (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-[13px]">Δεν βρέθηκαν ενεργοί σερβιτόροι</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user