Files
xenia-pos-local/manager_dashboard/src/pages/Management/pricing/DiscountSettingsTab.jsx
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

266 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { 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>
)
}