Skip to content

Commit ee8a9eb

Browse files
committed
feat: Cap.B pulido — semáforo/alarma de marcas cerradas (ClosedBrandsCard + watchdog) + reorg UX Disponibilidad
1 parent b14283d commit ee8a9eb

6 files changed

Lines changed: 319 additions & 27 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// src/modules/kds/components/ClosedBrandsCard.tsx
2+
//
3+
// FASE B · CAP. B — indicador AMBIENTAL de marcas cerradas (§9-C). Antes el
4+
// estado de una marca solo se veía DENTRO del modal de BrandCloseControl,
5+
// tras buscarla a mano. Esta tarjeta las muestra siempre visibles, con
6+
// reapertura de un toque — mismo espíritu que LocationStatusCard (Cap. C).
7+
//
8+
// closed_brands ya excluye las que tenían resume_at y pasó (HubRise las
9+
// reabrió sola vía expires_at) — por eso no hace falta corregir aquí, a
10+
// diferencia de LocationStatusCard (que sí calcula effectiveMode en cliente:
11+
// aquí la lista entera desaparece de la RPC en cuanto vence, no hay una sola
12+
// entidad que "corregir" en pantalla).
13+
//
14+
// No se muestra nada si no hay ninguna marca cerrada (ambiental de verdad:
15+
// no ocupa sitio cuando no aporta).
16+
17+
import { useCallback, useEffect, useState } from 'react'
18+
import { Store, Unlock, Loader2, AlertTriangle } from 'lucide-react'
19+
import { getClosedBrands, setBrandStatus, setBrandStatusByToken, type ClosedBrand } from '../services/kdsService'
20+
21+
interface Props {
22+
accountId?: string | null
23+
token?: string | null
24+
dark?: boolean
25+
}
26+
27+
export default function ClosedBrandsCard({ accountId, token, dark = false }: Props) {
28+
const [brands, setBrands] = useState<ClosedBrand[]>([])
29+
const [loading, setLoading] = useState(true)
30+
const [busyId, setBusyId] = useState<string | null>(null)
31+
const [error, setError] = useState<string | null>(null)
32+
33+
const refresh = useCallback(async () => {
34+
try {
35+
setBrands(await getClosedBrands(accountId ?? null, token))
36+
setError(null)
37+
} catch (e) {
38+
setError(e instanceof Error ? e.message : 'Error cargando marcas cerradas')
39+
} finally {
40+
setLoading(false)
41+
}
42+
}, [accountId, token])
43+
44+
useEffect(() => {
45+
setLoading(true)
46+
void refresh()
47+
const id = window.setInterval(() => { void refresh() }, 30_000)
48+
return () => window.clearInterval(id)
49+
}, [refresh])
50+
51+
async function reopen(brandId: string) {
52+
setBusyId(brandId); setError(null)
53+
try {
54+
if (token) await setBrandStatusByToken(token, brandId, 'normal')
55+
else await setBrandStatus(brandId, 'normal')
56+
await refresh()
57+
} catch (e) {
58+
setError(e instanceof Error ? e.message : 'No se pudo reabrir')
59+
} finally {
60+
setBusyId(null)
61+
}
62+
}
63+
64+
if (loading || brands.length === 0) return null
65+
66+
const cardCls = dark ? 'bg-zinc-900 ring-1 ring-zinc-800' : 'bg-white border border-stone-200'
67+
68+
return (
69+
<div className={`rounded-xl px-4 py-3 mb-3 ${cardCls}`}>
70+
<div className="flex items-center gap-2 mb-2">
71+
<Store size={15} className={dark ? 'text-zinc-500' : 'text-stone-400'} />
72+
<span className={`text-xs font-semibold uppercase tracking-wide ${dark ? 'text-zinc-400' : 'text-stone-500'}`}>
73+
{brands.length === 1 ? 'Marca cerrada' : `${brands.length} marcas cerradas`}
74+
</span>
75+
</div>
76+
<div className="flex flex-col gap-1.5">
77+
{brands.map((b) => (
78+
<div key={b.brand_id} className="flex items-center justify-between gap-2">
79+
<div className="flex items-center gap-2 min-w-0">
80+
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
81+
<span className={`text-sm truncate ${dark ? 'text-zinc-100' : 'text-stone-800'}`}>{b.brand_name}</span>
82+
<span className={`text-xs shrink-0 ${dark ? 'text-zinc-500' : 'text-stone-400'}`}>
83+
{b.resume_at
84+
? `hasta las ${new Date(b.resume_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}`
85+
: 'indefinido'}
86+
</span>
87+
</div>
88+
<button
89+
onClick={() => void reopen(b.brand_id)}
90+
disabled={busyId === b.brand_id}
91+
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
92+
>
93+
{busyId === b.brand_id ? <Loader2 size={12} className="animate-spin" /> : <Unlock size={12} />} Reabrir
94+
</button>
95+
</div>
96+
))}
97+
</div>
98+
{error && (
99+
<div className="mt-2 flex items-center gap-1.5 text-xs text-red-500">
100+
<AlertTriangle size={13} /> {error}
101+
</div>
102+
)}
103+
</div>
104+
)
105+
}

src/modules/kds/services/kdsService.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,27 @@ export function listBrandsForClosure(accountId: string | null, token?: string |
427427
})
428428
}
429429

430+
export interface ClosedBrand {
431+
brand_id: string
432+
brand_name: string
433+
mode: BrandStatusMode
434+
resume_at: string | null
435+
reason: string | null
436+
set_at: string | null
437+
}
438+
439+
/**
440+
* Marcas EFECTIVAMENTE cerradas ahora mismo (indicador ambiental, §9-C) — la
441+
* RPC ya oculta las que tenían resume_at y ya pasó (HubRise las reabrió sola
442+
* vía expires_at, aunque brand.closure_mode en Folvy no se reescriba solo).
443+
*/
444+
export function getClosedBrands(accountId: string | null, token?: string | null): Promise<ClosedBrand[]> {
445+
return rpc<ClosedBrand[]>('closed_brands', {
446+
p_account_id: accountId,
447+
p_token: token ?? null,
448+
})
449+
}
450+
430451
// ─────────────────────────────────────────────────────────────────────────────
431452
// AJUSTES DE COCINA (lectura/escritura por servicio, RLS de SESIÓN — sin token)
432453
// ─────────────────────────────────────────────────────────────────────────────

src/modules/kitchen/pages/KitchenAvailabilityPage.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/modules/kitchen/services/availabilityService'
1515
import LocationStatusCard from '@/modules/kds/components/LocationStatusCard'
1616
import BrandCloseControl from '@/modules/kds/components/BrandCloseControl'
17+
import ClosedBrandsCard from '@/modules/kds/components/ClosedBrandsCard'
1718

1819
const ACCENT = '#15171A'
1920

@@ -120,21 +121,9 @@ export default function KitchenAvailabilityPage() {
120121

121122
return (
122123
<div className="max-w-3xl mx-auto px-4 py-6">
123-
<div className="flex items-center justify-between gap-3 mb-6 flex-wrap">
124-
<div>
125-
<h1 className="text-lg font-medium text-stone-800">Disponibilidad</h1>
126-
<p className="text-[13px] text-stone-500 mt-0.5">Lo que está agotado ahora mismo</p>
127-
</div>
128-
<div className="flex items-center gap-2">
129-
{activeAccountId && <BrandCloseControl accountId={activeAccountId} />}
130-
<button
131-
onClick={() => setShowAgotar(true)}
132-
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-white text-sm font-medium"
133-
style={{ backgroundColor: ACCENT }}
134-
>
135-
<Plus size={18} /> Agotar producto
136-
</button>
137-
</div>
124+
<div className="mb-6">
125+
<h1 className="text-lg font-medium text-stone-800">Disponibilidad</h1>
126+
<p className="text-[13px] text-stone-500 mt-0.5">Lo que está cerrado o agotado ahora mismo</p>
138127
</div>
139128

140129
<div className="flex gap-2.5 mb-5 flex-wrap items-center">
@@ -183,13 +172,31 @@ export default function KitchenAvailabilityPage() {
183172
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-[13px] text-red-700">{error}</div>
184173
)}
185174

186-
{locationId && <LocationStatusCard locationId={locationId} />}
175+
{/* ── Local y marcas: los dos cierres de alcance amplio ─────────────── */}
176+
<div className="mb-5">
177+
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
178+
<p className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">Local y marcas</p>
179+
{activeAccountId && <BrandCloseControl accountId={activeAccountId} />}
180+
</div>
181+
{locationId && <LocationStatusCard locationId={locationId} />}
182+
{activeAccountId && <ClosedBrandsCard accountId={activeAccountId} />}
183+
</div>
187184

188-
<div className="flex items-center gap-2 mb-2.5">
189-
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" />
190-
<span className="text-[13px] font-medium text-stone-500">
191-
Agotados en {locName} · {loading ? '…' : visible.length}
192-
</span>
185+
{/* ── Productos: el cierre de alcance más fino ──────────────────────── */}
186+
<div className="flex items-center justify-between gap-2 mb-2.5 flex-wrap">
187+
<div className="flex items-center gap-2">
188+
<span className="w-2 h-2 rounded-full bg-red-500 inline-block" />
189+
<span className="text-[13px] font-medium text-stone-500">
190+
Productos agotados en {locName} · {loading ? '…' : visible.length}
191+
</span>
192+
</div>
193+
<button
194+
onClick={() => setShowAgotar(true)}
195+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-white text-sm font-medium"
196+
style={{ backgroundColor: ACCENT }}
197+
>
198+
<Plus size={18} /> Agotar producto
199+
</button>
193200
</div>
194201

195202
{loading ? (

src/modules/tablet/TabletAvailabilityTab.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from './services/tabletAvailabilityService'
1717
import LocationStatusCard from '@/modules/kds/components/LocationStatusCard'
1818
import BrandCloseControl from '@/modules/kds/components/BrandCloseControl'
19+
import ClosedBrandsCard from '@/modules/kds/components/ClosedBrandsCard'
1920

2021
interface Props {
2122
token: string
@@ -91,11 +92,14 @@ export default function TabletAvailabilityTab({ token, locationName }: Props) {
9192

9293
{/* Lista de agotados */}
9394
<div className="flex-1 overflow-y-auto p-5">
95+
{/* ── Local y marcas: los dos cierres de alcance amplio ─────────────── */}
9496
<LocationStatusCard locationId={null} token={token} dark />
97+
<ClosedBrandsCard token={token} dark />
9598

99+
{/* ── Productos: el cierre de alcance más fino ──────────────────────── */}
96100
<p className="text-sm text-zinc-500 mb-3">
97101
<span className="inline-block w-2 h-2 rounded-full bg-red-500 mr-2 align-middle" />
98-
Agotados ahora · {rows.length}
102+
Productos agotados ahora · {rows.length}
99103
</p>
100104

101105
{loading && rows.length === 0 ? (

supabase/functions/availability-watchdog/index.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
// local está en un estado (cerrado / con tal horario) que HubRise nunca
1616
// confirmó -> estado incoherente entre Folvy y la plataforma.
1717
//
18-
// Ventana de revisión de 20 min (solape sobre el cron de 15 min, para no
19-
// perder filas entre corridas). No dedupea entre corridas (deuda menor,
20-
// mismo criterio que el original: más ruidoso que perder un fallo en silencio).
18+
// (C) brand.closure_mode (Fase B, Cap. B) — cierres de marca OLVIDADOS: sin
19+
// resume_at (indefinido) hace más de 24h, o con resume_at ya vencido pero
20+
// closure_mode aún 'paused'. Ver checkStaleBrandClosures.
21+
//
22+
// Ventana de revisión de 20 min para (A)/(B) (solape sobre el cron de 15 min,
23+
// para no perder filas entre corridas). (C) no usa ventana: es estado actual,
24+
// no eventos recientes. No dedupea entre corridas (deuda menor, mismo
25+
// criterio que el original: más ruidoso que perder un fallo en silencio).
2126
//
2227
// Deploy: --no-verify-jwt (inocua; sin params externos, solo lee y alerta).
2328

@@ -103,6 +108,68 @@ async function checkAvailabilityPushLog(
103108
return { failures: rows.length, stuck: stuck.length, soldOutFailed: soldOutFailed.length };
104109
}
105110

111+
// (C) Cap. B — cierres de marca OLVIDADOS. Dos casos, distintos de gravedad:
112+
// · INDEFINIDO (resume_at null) más de INDEFINITE_CLOSURE_ALERT_HOURS: sin
113+
// expires_at en el push a HubRise, esa marca NO se reabre sola — riesgo
114+
// real de quedar cerrada para siempre si nadie se acuerda.
115+
// · VENCIDO (resume_at ya pasado, brand.closure_mode aún 'paused'): HubRise
116+
// ya reabrió esos SKUs solo (expires_at) — closed_brands ya lo oculta en
117+
// el indicador ambiental — pero el semáforo de Folvy sigue "mintiendo"
118+
// hasta que alguien reabra a mano; aviso de limpieza, no de fallo real.
119+
// SIN dedupe entre corridas (mismo criterio que hubrise-callback-ensure: más
120+
// ruidoso que olvidarlo en silencio) — se resuelve solo en cuanto se reabre.
121+
const INDEFINITE_CLOSURE_ALERT_HOURS = 24;
122+
123+
async function checkStaleBrandClosures(
124+
sb: ReturnType<typeof createClient>,
125+
): Promise<{ indefinite: number; expired: number }> {
126+
const { data: closed, error } = await sb
127+
.from("brand")
128+
.select("id, name, account_id, closure_resume_at, closure_set_at, closure_reason")
129+
.eq("closure_mode", "paused")
130+
.limit(200);
131+
132+
if (error) {
133+
console.error("availability-watchdog: error consultando brand (cierres de marca)", error);
134+
return { indefinite: 0, expired: 0 };
135+
}
136+
137+
const rows = closed ?? [];
138+
if (rows.length === 0) return { indefinite: 0, expired: 0 };
139+
140+
const now = Date.now();
141+
const indefiniteCutoff = now - INDEFINITE_CLOSURE_ALERT_HOURS * 60 * 60 * 1000;
142+
143+
const indefinite = rows.filter((b) =>
144+
!b.closure_resume_at && b.closure_set_at && new Date(b.closure_set_at as string).getTime() < indefiniteCutoff);
145+
const expired = rows.filter((b) =>
146+
!!b.closure_resume_at && new Date(b.closure_resume_at as string).getTime() < now);
147+
148+
if (indefinite.length === 0 && expired.length === 0) return { indefinite: 0, expired: 0 };
149+
150+
const lines: string[] = [];
151+
if (indefinite.length > 0) {
152+
lines.push(`⚠️ ${indefinite.length} marca(s) cerrada(s) INDEFINIDAMENTE hace más de ${INDEFINITE_CLOSURE_ALERT_HOURS}h — sin expires_at, NO se reabren solas en HubRise:`);
153+
for (const b of indefinite.slice(0, 15)) {
154+
lines.push(` - ${b.name} · cuenta ${b.account_id} · cerrada desde ${b.closure_set_at} · motivo: ${b.closure_reason ?? "(sin motivo)"}`);
155+
}
156+
lines.push("");
157+
}
158+
if (expired.length > 0) {
159+
lines.push(`${expired.length} marca(s) con cierre YA VENCIDO pero brand.closure_mode sigue 'paused' — HubRise ya las reabrió sola(s), es limpieza de Folvy, no fallo de plataforma:`);
160+
for (const b of expired.slice(0, 15)) {
161+
lines.push(` - ${b.name} · cuenta ${b.account_id} · debía reabrir en ${b.closure_resume_at}`);
162+
}
163+
}
164+
165+
await raiseAlert(
166+
`Cierres de marca sin resolver (${indefinite.length + expired.length})`,
167+
lines.join("\n"),
168+
"brand-closure",
169+
);
170+
return { indefinite: indefinite.length, expired: expired.length };
171+
}
172+
106173
// (B) Cap. C/D — location_status_log (cerrar/reabrir local, horario semanal).
107174
async function checkLocationStatusLog(
108175
sb: ReturnType<typeof createClient>, since: string,
@@ -143,16 +210,19 @@ Deno.serve(async (req: Request) => {
143210
const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
144211
const since = new Date(Date.now() - WINDOW_MINUTES * 60_000).toISOString();
145212

146-
const [availability, locationStatus] = await Promise.all([
213+
const [availability, locationStatus, staleBrandClosures] = await Promise.all([
147214
checkAvailabilityPushLog(sb, since),
148215
checkLocationStatusLog(sb, since),
216+
checkStaleBrandClosures(sb),
149217
]);
150218

151-
const ok = availability.failures === 0 && locationStatus.failures === 0;
219+
const ok = availability.failures === 0 && locationStatus.failures === 0
220+
&& staleBrandClosures.indefinite === 0 && staleBrandClosures.expired === 0;
152221
return json({
153222
ok,
154223
checked_since: since,
155224
availability_push_log: availability,
156225
location_status_log: locationStatus,
226+
brand_closures: staleBrandClosures,
157227
}, ok ? 200 : 207);
158228
});

0 commit comments

Comments
 (0)