|
| 1 | +// src/modules/kds/components/AgotarProductoModal.tsx |
| 2 | +// |
| 3 | +// DISPONIBILIDAD · C2 — modal ÚNICO "Agotar producto". Antes había dos casi |
| 4 | +// idénticos (KitchenAvailabilityPage y TabletAvailabilityTab): mismo flujo |
| 5 | +// (buscar → confirmar alcance real → agotar), con solo el tema y la puerta |
| 6 | +// de autenticación (sesión|token) distintos. Se fusionan aquí parametrizado |
| 7 | +// por tema + un adapter fino que cada envoltorio construye con su propio |
| 8 | +// servicio (availabilityService en web, tabletAvailabilityService en |
| 9 | +// tablet) — cero cambio de las RPC ni de los flujos, solo de dónde vive el |
| 10 | +// JSX. |
| 11 | + |
| 12 | +import { useCallback, useEffect, useRef, useState } from 'react' |
| 13 | +import { AlertTriangle, Loader2, Search, X } from 'lucide-react' |
| 14 | +import { themeCls, type Theme } from '../lib/theme' |
| 15 | +import { endOfTodayIso } from '../lib/endOfToday' |
| 16 | + |
| 17 | +export interface ProductPick { |
| 18 | + menuItemId: string |
| 19 | + name: string |
| 20 | + externalId: string | null |
| 21 | + recipeItemId: string | null |
| 22 | + brands: number |
| 23 | +} |
| 24 | + |
| 25 | +export interface ScopePreview { |
| 26 | + brands: number |
| 27 | + channels: number |
| 28 | +} |
| 29 | + |
| 30 | +export interface AgotarProductoAdapter { |
| 31 | + searchProducts: (query: string) => Promise<ProductPick[]> |
| 32 | + previewScope: (menuItemId: string) => Promise<ScopePreview> |
| 33 | + /** Agota el producto (availableUntil=null → indefinido). */ |
| 34 | + agotar: (menuItemId: string, availableUntil: string | null) => Promise<void> |
| 35 | +} |
| 36 | + |
| 37 | +interface Props { |
| 38 | + theme: Theme |
| 39 | + adapter: AgotarProductoAdapter |
| 40 | + locationLabel: string |
| 41 | + /** true = el 86 va a TODOS los locales (web, sin local seleccionado). */ |
| 42 | + allLocations?: boolean |
| 43 | + onClose: () => void |
| 44 | + onDone: () => void |
| 45 | +} |
| 46 | + |
| 47 | +export default function AgotarProductoModal({ theme, adapter, locationLabel, allLocations, onClose, onDone }: Props) { |
| 48 | + const t = themeCls(theme) |
| 49 | + const dark = theme === 'dark' |
| 50 | + |
| 51 | + const [query, setQuery] = useState('') |
| 52 | + const [results, setResults] = useState<ProductPick[]>([]) |
| 53 | + const [searching, setSearching] = useState(false) |
| 54 | + const [picked, setPicked] = useState<ProductPick | null>(null) |
| 55 | + const [scope, setScope] = useState<ScopePreview | null>(null) |
| 56 | + const [until, setUntil] = useState<'indefinido' | 'hoy'>('indefinido') |
| 57 | + const [busy, setBusy] = useState(false) |
| 58 | + const [error, setError] = useState<string | null>(null) |
| 59 | + const debounce = useRef<number | null>(null) |
| 60 | + |
| 61 | + useEffect(() => { |
| 62 | + if (debounce.current) window.clearTimeout(debounce.current) |
| 63 | + if (query.trim().length < 2) { setResults([]); return } |
| 64 | + setSearching(true) |
| 65 | + debounce.current = window.setTimeout(() => { |
| 66 | + adapter.searchProducts(query) |
| 67 | + .then(setResults) |
| 68 | + .catch((e) => setError(e instanceof Error ? e.message : 'Error buscando')) |
| 69 | + .finally(() => setSearching(false)) |
| 70 | + }, 300) |
| 71 | + return () => { if (debounce.current) window.clearTimeout(debounce.current) } |
| 72 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 73 | + }, [query]) |
| 74 | + |
| 75 | + const pick = useCallback(async (p: ProductPick) => { |
| 76 | + setPicked(p); setScope(null); setError(null) |
| 77 | + try { |
| 78 | + setScope(await adapter.previewScope(p.menuItemId)) |
| 79 | + } catch { |
| 80 | + setScope({ brands: p.brands, channels: 0 }) |
| 81 | + } |
| 82 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 83 | + }, []) |
| 84 | + |
| 85 | + const confirm = useCallback(async () => { |
| 86 | + if (!picked) return |
| 87 | + setBusy(true); setError(null) |
| 88 | + try { |
| 89 | + await adapter.agotar(picked.menuItemId, until === 'hoy' ? endOfTodayIso() : null) |
| 90 | + onDone() |
| 91 | + } catch (e) { |
| 92 | + setError(e instanceof Error ? e.message : 'No se pudo agotar') |
| 93 | + setBusy(false) |
| 94 | + } |
| 95 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 96 | + }, [picked, until]) |
| 97 | + |
| 98 | + return ( |
| 99 | + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}> |
| 100 | + <div className={`w-full max-w-md rounded-xl overflow-hidden ${t.panel}`} onClick={(e) => e.stopPropagation()}> |
| 101 | + <div className={`flex items-center justify-between px-5 py-3.5 border-b ${t.border}`}> |
| 102 | + <h2 className={`text-base font-semibold ${t.textPrimary}`}>Agotar producto</h2> |
| 103 | + <button onClick={onClose} className={`p-1.5 rounded-lg ${t.iconButton}`}> |
| 104 | + <X size={20} /> |
| 105 | + </button> |
| 106 | + </div> |
| 107 | + |
| 108 | + <div className="p-5"> |
| 109 | + {!picked ? ( |
| 110 | + <> |
| 111 | + <div className="relative"> |
| 112 | + <Search size={16} className={`absolute left-3 top-1/2 -translate-y-1/2 ${t.textMuted}`} /> |
| 113 | + <input |
| 114 | + autoFocus |
| 115 | + value={query} |
| 116 | + onChange={(e) => setQuery(e.target.value)} |
| 117 | + placeholder="Buscar producto a agotar" |
| 118 | + className={`w-full pl-9 pr-3 py-2.5 rounded-lg text-sm ${t.input} ${t.textPrimary}`} |
| 119 | + /> |
| 120 | + </div> |
| 121 | + <div className="mt-3 max-h-72 overflow-y-auto flex flex-col gap-1"> |
| 122 | + {searching && <div className={`text-sm py-2 px-1 ${t.textMuted}`}>Buscando…</div>} |
| 123 | + {!searching && query.trim().length >= 2 && results.length === 0 && ( |
| 124 | + <div className={`text-sm py-2 px-1 ${t.textMuted}`}>Sin resultados.</div> |
| 125 | + )} |
| 126 | + {results.map((p) => ( |
| 127 | + <button |
| 128 | + key={p.menuItemId} |
| 129 | + onClick={() => void pick(p)} |
| 130 | + className={`text-left px-3 py-2 rounded-lg flex items-center justify-between ${t.hoverBg}`} |
| 131 | + > |
| 132 | + <span className={`text-sm ${t.textPrimary}`}>{p.name}</span> |
| 133 | + <span className={`text-xs ${t.textMuted}`}>{p.brands} marca{p.brands === 1 ? '' : 's'}</span> |
| 134 | + </button> |
| 135 | + ))} |
| 136 | + </div> |
| 137 | + </> |
| 138 | + ) : ( |
| 139 | + <> |
| 140 | + <div className={`rounded-lg p-3 ${dark ? 'bg-amber-500/10 ring-1 ring-amber-500/30' : 'border border-warning bg-warning-bg'}`}> |
| 141 | + <div className="flex items-start gap-2.5"> |
| 142 | + <AlertTriangle size={18} className={`shrink-0 mt-0.5 ${dark ? 'text-amber-400' : 'text-warning'}`} /> |
| 143 | + <div className={`text-sm ${dark ? 'text-amber-100' : 'text-stone-800'}`}> |
| 144 | + ¿Agotar <strong>“{picked.name}”</strong> en {locationLabel}? |
| 145 | + <div className={`mt-1 ${dark ? 'text-amber-200/90' : 'text-stone-600'}`}> |
| 146 | + Se apagará <strong>AHORA, en producción</strong>, en{' '} |
| 147 | + {scope ? ( |
| 148 | + <strong>{scope.brands} marca{scope.brands === 1 ? '' : 's'} · {scope.channels} canal{scope.channels === 1 ? '' : 'es'}</strong> |
| 149 | + ) : 'calculando alcance…'} de Glovo / Uber / JustEat. |
| 150 | + </div> |
| 151 | + {allLocations && ( |
| 152 | + <p className={`mt-1.5 font-semibold ${dark ? 'text-amber-100' : 'text-stone-800'}`}> |
| 153 | + Atención: lo apagas en TODOS los locales. |
| 154 | + </p> |
| 155 | + )} |
| 156 | + </div> |
| 157 | + </div> |
| 158 | + </div> |
| 159 | + |
| 160 | + <div className={`flex gap-4 mt-4 text-sm ${t.textSecondary}`}> |
| 161 | + <label className="inline-flex items-center gap-1.5 cursor-pointer"> |
| 162 | + <input type="radio" checked={until === 'indefinido'} onChange={() => setUntil('indefinido')} /> |
| 163 | + Indefinido |
| 164 | + </label> |
| 165 | + <label className="inline-flex items-center gap-1.5 cursor-pointer"> |
| 166 | + <input type="radio" checked={until === 'hoy'} onChange={() => setUntil('hoy')} /> |
| 167 | + Solo hoy (reactiva a medianoche) |
| 168 | + </label> |
| 169 | + </div> |
| 170 | + |
| 171 | + {error && <div className="mt-3 text-sm text-danger">{error}</div>} |
| 172 | + |
| 173 | + <div className="mt-5 flex gap-2 justify-end"> |
| 174 | + <button |
| 175 | + onClick={() => { setPicked(null); setScope(null) }} |
| 176 | + disabled={busy} |
| 177 | + className={`px-3.5 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 ${t.chipNeutral}`} |
| 178 | + > |
| 179 | + Atrás |
| 180 | + </button> |
| 181 | + <button |
| 182 | + onClick={() => void confirm()} |
| 183 | + disabled={busy} |
| 184 | + className={`px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 inline-flex items-center gap-1.5 ${t.ctaWarning}`} |
| 185 | + > |
| 186 | + {busy && <Loader2 size={16} className="animate-spin" />} |
| 187 | + Sí, agotar en {locationLabel} |
| 188 | + </button> |
| 189 | + </div> |
| 190 | + </> |
| 191 | + )} |
| 192 | + </div> |
| 193 | + </div> |
| 194 | + </div> |
| 195 | + ) |
| 196 | +} |
0 commit comments