Skip to content

Commit c231302

Browse files
committed
feat: 86 Fase 0 — separar Folvy/Last, scope fix, vigía, interruptor y aviso multi-integrador
1 parent dfa536c commit c231302

23 files changed

Lines changed: 1296 additions & 124 deletions

package-lock.json

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@stripe/react-stripe-js": "^6.6.0",
2626
"@stripe/stripe-js": "^9.8.0",
2727
"@supabase/supabase-js": "^2.45.0",
28+
"@tanstack/react-query": "^5.101.4",
2829
"@turf/turf": "^7.3.5",
2930
"jspdf": "^4.2.1",
3031
"lucide-react": "^1.14.0",

src/main.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
33
import { BrowserRouter } from 'react-router-dom'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
45
import { AppProvider } from './context/AppContext'
56
import App from './App'
67
import UpdateGate from './components/UpdateGate'
@@ -59,18 +60,22 @@ window.addEventListener('appinstalled', () => {
5960
window.dispatchEvent(new Event('folvy:installed'))
6061
})
6162

63+
const queryClient = new QueryClient()
64+
6265
ReactDOM.createRoot(document.getElementById('root')!).render(
6366
<React.StrictMode>
6467
{/* Cortafuegos global: un crash de render en cualquier punto muestra un
6568
fallback con "Recargar" en vez de dejar la pantalla en blanco. */}
6669
<RootErrorBoundary>
67-
<BrowserRouter>
68-
<AppProvider>
69-
<App />
70-
{/* Auto-actualización forzada (sólo app nativa; no-op en web). */}
71-
<UpdateGate />
72-
</AppProvider>
73-
</BrowserRouter>
70+
<QueryClientProvider client={queryClient}>
71+
<BrowserRouter>
72+
<AppProvider>
73+
<App />
74+
{/* Auto-actualización forzada (sólo app nativa; no-op en web). */}
75+
<UpdateGate />
76+
</AppProvider>
77+
</BrowserRouter>
78+
</QueryClientProvider>
7479
</RootErrorBoundary>
7580
</React.StrictMode>
7681
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// src/modules/integrations/components/AvailabilityConfigSection.tsx
2+
//
3+
// Ajustes de DISPONIBILIDAD (86) de un local: interruptor automático/manual
4+
// (prepara el futuro auto-86 por stock — el gatillo real aún no existe, esto
5+
// solo deja el interruptor listo) + qué otros integradores usa este local
6+
// (Last, Otter…), para que set_product_availability(_by_token) sepa cuándo
7+
// avisar "desconéctalo también ahí". Escribe en locations. Espeja
8+
// DispatchConfigSection.
9+
10+
import { useEffect, useState } from 'react'
11+
import { CircleOff, Loader2, AlertCircle } from 'lucide-react'
12+
import {
13+
getLocationAvailabilityConfig, setLocationAvailabilityConfig, OTHER_INTEGRATORS,
14+
type AvailabilityAutoMode,
15+
} from '@/modules/integrations/services/locationAvailabilityService'
16+
17+
export default function AvailabilityConfigSection({ locationId }: { locationId: string }) {
18+
const [mode, setMode] = useState<AvailabilityAutoMode>('manual')
19+
const [others, setOthers] = useState<string[]>([])
20+
const [loading, setLoading] = useState(true)
21+
const [saving, setSaving] = useState(false)
22+
const [error, setError] = useState<string | null>(null)
23+
24+
useEffect(() => {
25+
let alive = true
26+
setLoading(true)
27+
getLocationAvailabilityConfig(locationId)
28+
.then(c => { if (alive) { setMode(c.mode); setOthers(c.otherIntegrators) } })
29+
.catch(e => { if (alive) setError(e instanceof Error ? e.message : 'Error') })
30+
.finally(() => { if (alive) setLoading(false) })
31+
return () => { alive = false }
32+
}, [locationId])
33+
34+
async function persist(patch: { mode?: AvailabilityAutoMode; otherIntegrators?: string[] }) {
35+
setSaving(true); setError(null)
36+
try { await setLocationAvailabilityConfig(locationId, patch) }
37+
catch (e) { setError(e instanceof Error ? e.message : 'No se pudo guardar.') }
38+
finally { setSaving(false) }
39+
}
40+
41+
function chooseMode(m: AvailabilityAutoMode) {
42+
if (m === mode) return
43+
setMode(m); void persist({ mode: m })
44+
}
45+
46+
function toggleOther(code: string) {
47+
const next = others.includes(code) ? others.filter(c => c !== code) : [...others, code]
48+
setOthers(next); void persist({ otherIntegrators: next })
49+
}
50+
51+
return (
52+
<div className="rounded-xl border border-border-default bg-card">
53+
<div className="flex items-center gap-2 px-4 py-3 border-b border-border-default">
54+
<CircleOff size={18} className="text-text-secondary" />
55+
<h2 className="text-sm font-semibold text-text-primary">Disponibilidad (86) de este local</h2>
56+
{saving && <Loader2 size={14} className="animate-spin text-text-secondary ml-auto" />}
57+
</div>
58+
59+
<div className="px-4 py-4 space-y-4">
60+
{loading ? (
61+
<div className="text-sm text-text-secondary flex items-center gap-2">
62+
<Loader2 size={14} className="animate-spin" /> Cargando…
63+
</div>
64+
) : (
65+
<>
66+
<div>
67+
<div className="inline-flex bg-page border border-border-default rounded-lg p-1 gap-1">
68+
{(['manual', 'auto'] as AvailabilityAutoMode[]).map(m => (
69+
<button
70+
key={m}
71+
type="button"
72+
onClick={() => chooseMode(m)}
73+
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-base ${
74+
mode === m ? 'bg-accent text-text-on-accent' : 'text-text-secondary hover:text-text-primary'
75+
}`}
76+
>
77+
{m === 'auto' ? 'Automático' : 'Manual'}
78+
</button>
79+
))}
80+
</div>
81+
<p className="text-xs text-text-secondary mt-2">
82+
{mode === 'auto'
83+
? 'Preparado para el futuro 86 automático por stock (aún no existe el gatillo real: hoy no cambia nada).'
84+
: 'El 86 lo hace siempre el operario, a mano. Comportamiento actual.'}
85+
</p>
86+
</div>
87+
88+
<div>
89+
<label className="block text-xs font-medium text-text-secondary mb-1.5">
90+
Otros integradores que usa este local
91+
</label>
92+
<div className="space-y-1.5">
93+
{OTHER_INTEGRATORS.map(i => {
94+
const checked = others.includes(i.code)
95+
return (
96+
<label
97+
key={i.code}
98+
className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-border-default bg-page cursor-pointer hover:border-border-strong"
99+
>
100+
<input
101+
type="checkbox"
102+
checked={checked}
103+
onChange={() => toggleOther(i.code)}
104+
className="accent-accent"
105+
/>
106+
<span className="text-sm text-text-primary">{i.name}</span>
107+
</label>
108+
)
109+
})}
110+
</div>
111+
<p className="text-xs text-text-secondary mt-1.5">
112+
Folvy NO escribe en ellos. Al agotar un producto aquí, avisa con acuse "desconéctalo también en {others.length > 0 ? OTHER_INTEGRATORS.filter(i => others.includes(i.code)).map(i => i.name).join('/') : '…'}".
113+
</p>
114+
</div>
115+
116+
{error && (
117+
<div className="flex items-center gap-2 p-2 rounded-md bg-danger-bg text-danger border border-danger/20 text-xs">
118+
<AlertCircle size={13} /> {error}
119+
</div>
120+
)}
121+
</>
122+
)}
123+
</div>
124+
</div>
125+
)
126+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// src/modules/integrations/services/locationAvailabilityService.ts
2+
//
3+
// Config de DISPONIBILIDAD (86) por local: interruptor auto/manual (prepara el
4+
// futuro gatillo de auto-86 por stock, que aún no existe) + qué otros
5+
// integradores usa el local (Last, Otter…) para el aviso multi-integrador que
6+
// dispara set_product_availability(_by_token) al agotar. Vive en la tabla
7+
// locations; se edita desde la ficha de reparto/integraciones del local
8+
// (mismo sitio que dispatch_mode/dispatch_broker).
9+
10+
import { supabase, isSupabaseEnabled } from '../../../lib/supabase'
11+
12+
export type AvailabilityAutoMode = 'auto' | 'manual'
13+
14+
export const OTHER_INTEGRATORS: { code: string; name: string }[] = [
15+
{ code: 'last', name: 'Last' },
16+
{ code: 'otter', name: 'Otter' },
17+
{ code: 'deliverect', name: 'Deliverect' },
18+
]
19+
20+
export interface LocationAvailabilityConfig {
21+
mode: AvailabilityAutoMode
22+
otherIntegrators: string[]
23+
}
24+
25+
function db() {
26+
if (!isSupabaseEnabled || !supabase) throw new Error('Supabase no está configurado.')
27+
return supabase!
28+
}
29+
30+
export async function getLocationAvailabilityConfig(locationId: string): Promise<LocationAvailabilityConfig> {
31+
const { data, error } = await db()
32+
.from('locations')
33+
.select('availability_auto_mode, availability_other_integrators')
34+
.eq('id', locationId)
35+
.single()
36+
if (error) throw new Error(error.message)
37+
const row = data as { availability_auto_mode?: string; availability_other_integrators?: string[] } | null
38+
return {
39+
mode: ((row?.availability_auto_mode ?? 'manual') as AvailabilityAutoMode),
40+
otherIntegrators: row?.availability_other_integrators ?? [],
41+
}
42+
}
43+
44+
export async function setLocationAvailabilityConfig(
45+
locationId: string,
46+
patch: Partial<LocationAvailabilityConfig>,
47+
): Promise<void> {
48+
const row: { availability_auto_mode?: AvailabilityAutoMode; availability_other_integrators?: string[] } = {}
49+
if (patch.mode) row.availability_auto_mode = patch.mode
50+
if (patch.otherIntegrators) row.availability_other_integrators = patch.otherIntegrators
51+
if (Object.keys(row).length === 0) return
52+
const { error } = await db().from('locations').update(row as never).eq('id', locationId)
53+
if (error) throw new Error(error.message)
54+
}

src/modules/kds/KdsKioskRoute.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react'
1414
import { MonitorPlay, LogOut, Loader2 } from 'lucide-react'
1515
import KdsBoard from './components/KdsBoard'
1616
import KdsAlarmOverlay from './components/KdsAlarmOverlay'
17+
import AvailabilityNoticeOverlay from './components/AvailabilityNoticeOverlay'
1718
import { getBoard } from './services/kdsService'
1819

1920
const TOKEN_KEY = 'kds_device_token'
@@ -150,6 +151,8 @@ export default function KdsKioskRoute() {
150151
</div>
151152
{/* Alarma de reparto: banner+sonido global, sobre el tablero. */}
152153
<KdsAlarmOverlay locationId={null} token={token} />
154+
{/* Aviso multi-integrador: recuerda desconectar en Last/Otter tras un 86. */}
155+
<AvailabilityNoticeOverlay locationId={null} token={token} />
153156
</div>
154157
)
155158
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// src/modules/kds/components/AvailabilityNoticeOverlay.tsx
2+
//
3+
// AVISO MULTI-INTEGRADOR — banner (no alarma sonora) que recuerda desconectar
4+
// un producto también en los otros integradores del local (Last, Otter…) tras
5+
// un 86 de Folvy. Se monta en las MISMAS rutas que KdsAlarmOverlay (mismo
6+
// patrón de props: locationId | token, variant), justo debajo de la alarma de
7+
// reparto si ambas coinciden. Solo aparece cuando el local declara
8+
// availability_other_integrators (si no, availability_notices no devuelve nada).
9+
//
10+
// Poll cada 20 s (no es tan urgente como la alarma de reparto: no lleva sonido
11+
// ni Realtime, para no competir con ella). "Hecho" sella el acuse en BBDD
12+
// (sobrevive recargas y kiosk↔tablet del mismo local).
13+
14+
import { useCallback, useEffect, useState } from 'react'
15+
import { PlugZap, Check } from 'lucide-react'
16+
import { getAvailabilityNotices, ackAvailabilityNotice, type AvailabilityNotice } from '../services/kdsService'
17+
18+
const POLL_MS = 20_000
19+
20+
interface AvailabilityNoticeOverlayProps {
21+
/** Local (sesión). En kiosco/tablet va null: la RPC deriva el local del token. */
22+
locationId: string | null
23+
token?: string | null
24+
/** 'fixed' (flota, pantalla completa) | 'inline' (fluye bajo el menú del Shell). */
25+
variant?: 'fixed' | 'inline'
26+
}
27+
28+
const INTEGRATOR_LABELS: Record<string, string> = {
29+
last: 'Last', lastapp: 'Last', otter: 'Otter', deliverect: 'Deliverect',
30+
}
31+
function integratorLabel(code: string): string {
32+
return INTEGRATOR_LABELS[code.toLowerCase()] ?? code
33+
}
34+
35+
export default function AvailabilityNoticeOverlay({ locationId, token, variant = 'fixed' }: AvailabilityNoticeOverlayProps) {
36+
const [notices, setNotices] = useState<AvailabilityNotice[]>([])
37+
const [ackingId, setAckingId] = useState<string | null>(null)
38+
39+
const refresh = useCallback(async () => {
40+
try {
41+
const res = await getAvailabilityNotices(locationId, token)
42+
setNotices(res.notices ?? [])
43+
} catch {
44+
/* Silencioso: un fallo del aviso NUNCA debe romper la pantalla de cocina. */
45+
}
46+
}, [locationId, token])
47+
48+
useEffect(() => {
49+
void refresh()
50+
const id = window.setInterval(() => { void refresh() }, POLL_MS)
51+
return () => window.clearInterval(id)
52+
}, [refresh])
53+
54+
const handleAck = useCallback(async (noticeId: string) => {
55+
setAckingId(noticeId)
56+
setNotices(prev => prev.filter(n => n.id !== noticeId)) // optimista
57+
try { await ackAvailabilityNotice(noticeId, token) }
58+
catch { void refresh() }
59+
finally { setAckingId(null) }
60+
}, [token, refresh])
61+
62+
if (notices.length === 0) return null
63+
64+
const wrapperCls = variant === 'inline'
65+
? 'relative z-20 px-2 sm:px-3 pt-2 sm:pt-3'
66+
: 'fixed top-0 inset-x-0 z-[55] p-2 sm:p-3'
67+
68+
return (
69+
<div className={wrapperCls}>
70+
<div className="mx-auto max-w-4xl rounded-xl bg-amber-500 text-amber-950 shadow-[0_10px_30px_rgba(217,119,6,0.4)] ring-2 ring-amber-300/60">
71+
<div className="flex items-center gap-3 px-4 py-2 border-b border-amber-600/30">
72+
<PlugZap size={19} className="shrink-0" />
73+
<span className="font-extrabold text-[13px] tracking-wide uppercase">
74+
{notices.length === 1 ? 'Desconecta también en otro integrador' : `${notices.length} productos por desconectar en otro integrador`}
75+
</span>
76+
</div>
77+
78+
<ul className="max-h-[40vh] overflow-y-auto divide-y divide-amber-600/25">
79+
{notices.map(n => (
80+
<li key={n.id} className="flex items-center gap-3 px-4 py-2">
81+
<div className="min-w-0 flex-1 text-[13px]">
82+
<span className="font-bold">{n.product_name}</span>
83+
<span className="text-amber-900/80"> agotado → desconéctalo en </span>
84+
<span className="font-semibold">{n.integrators.map(integratorLabel).join(' / ')}</span>
85+
</div>
86+
<button
87+
onClick={() => handleAck(n.id)}
88+
disabled={ackingId === n.id}
89+
className="shrink-0 inline-flex items-center gap-1.5 bg-amber-950/10 hover:bg-amber-950/20 font-bold rounded-lg px-3 py-1.5 text-[12.5px] disabled:opacity-60"
90+
>
91+
<Check size={14} strokeWidth={3} /> Hecho
92+
</button>
93+
</li>
94+
))}
95+
</ul>
96+
</div>
97+
</div>
98+
)
99+
}

0 commit comments

Comments
 (0)