Skip to content

Commit 4e5afee

Browse files
Llorente29claude
andcommitted
feat: Disponibilidad C2 — pulido UI web+tablet (tokens + componentes compartidos)
Cero cambio de comportamiento: mismas RPC, mismos flujos. Unifica los 4 idiomas de color en los tokens de tailwind.config (elimina ACCENT/#b45309 en web y #D67442/#e0824f/#1a1208 en tablet); centraliza el fork claro/oscuro repetido en un helper (kds/lib/theme.ts) en vez de ternarios inline por elemento; funde los dos modales "Agotar producto" casi idénticos en AgotarProductoModal; extrae el layout "Local y marcas + Productos" repetido en KitchenAvailabilityPage/TabletAvailabilityTab a AvailabilityBoard; unifica las dos cabeceras de sección dispares en SectionHeader; quita el emoji de ClosuresChip; añade acceso directo a Horarios desde Disponibilidad (web, antes enterrado en Ajustes); iguala el scope-preview (N marcas · N canales) de CatalogProductDetailPage con el de los modales de Agotar, que antes solo lo revelaba después de agotar; fija endOfToday a 23:59:59 (antes 23:59:00 en web vs 23:59:59 en tablet). Verificado: tsc -p tsconfig.app.json --noEmit limpio, vite build limpio, git grep de hex ≈ vacío en la familia, sin emojis, probado en vivo en /kitchen/disponibilidad (buscar/agotar con scope-preview real, cerrar marca, horarios). Tablet sin verificar en dispositivo (pendiente, sin acceso a hardware ni token de estación en este entorno). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 116e76e commit 4e5afee

13 files changed

Lines changed: 669 additions & 506 deletions
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// src/modules/kds/components/AvailabilityBoard.tsx
2+
//
3+
// DISPONIBILIDAD · C2 — layout ÚNICO compartido por KitchenAvailabilityPage
4+
// (web) y TabletAvailabilityTab (tablet): panel "Local y marcas" (Cap. C +
5+
// Cap. B: LocationStatusCard + BrandCloseControl + ClosedBrandsCard) seguido
6+
// de la cabecera de "Productos". Antes cada pantalla repetía este bloque a
7+
// mano con jerarquía/envoltorio distintos (web sin panel real, tablet sin
8+
// panel en absoluto). Orden estable Local → Marcas → Productos en las dos
9+
// superficies.
10+
//
11+
// La rejilla de tarjetas de producto (visualmente distinta: compacta en web,
12+
// grande y táctil en tablet) NO se fusiona aquí — se pasa como children.
13+
14+
import type { ReactNode } from 'react'
15+
import { CircleOff, Store } from 'lucide-react'
16+
import { themeCls, type Theme } from '../lib/theme'
17+
import SectionHeader from './SectionHeader'
18+
import LocationStatusCard from './LocationStatusCard'
19+
import ClosedBrandsCard from './ClosedBrandsCard'
20+
import BrandCloseControl from './BrandCloseControl'
21+
22+
interface Props {
23+
theme: Theme
24+
accountId?: string | null
25+
token?: string | null
26+
/** Local seleccionado (web) o null (tablet: el token ya fija el local). */
27+
locationId: string | null
28+
productsTitle: string
29+
/** null = cargando ("…"). */
30+
productsCount: number | null
31+
/** Acción del header de Productos (web: botón "Agotar producto"). Tablet lo
32+
* deja vacío — sus controles de acción viven en su propia barra superior. */
33+
productsAction?: ReactNode
34+
children: ReactNode
35+
}
36+
37+
export default function AvailabilityBoard({
38+
theme, accountId, token, locationId, productsTitle, productsCount, productsAction, children,
39+
}: Props) {
40+
const t = themeCls(theme)
41+
const dark = theme === 'dark'
42+
43+
return (
44+
<>
45+
<div className={`rounded-xl p-4 mb-5 ${t.card}`}>
46+
<SectionHeader
47+
icon={Store}
48+
title="Local y marcas"
49+
theme={theme}
50+
className="mb-3"
51+
action={<BrandCloseControl accountId={accountId} token={token} dark={dark} />}
52+
/>
53+
{(locationId || token) && <LocationStatusCard locationId={locationId} token={token} dark={dark} />}
54+
<ClosedBrandsCard accountId={accountId} token={token} dark={dark} />
55+
</div>
56+
57+
<SectionHeader
58+
icon={CircleOff}
59+
title={productsTitle}
60+
count={productsCount}
61+
dot="danger"
62+
theme={theme}
63+
className="mb-2.5"
64+
action={productsAction}
65+
/>
66+
67+
{children}
68+
</>
69+
)
70+
}

0 commit comments

Comments
 (0)