1717// limpio no se enseña B. No abrumar con tres problemas mezclados.
1818// · Lenguaje humano. Aquí no existe "menu_item_id null".
1919
20- import { useCallback , useEffect , useMemo , useState } from 'react'
20+ import { useEffect , useMemo , useState } from 'react'
2121import {
2222 AlertTriangle , ArrowLeft , Check , ChefHat , Clock , Euro , Loader2 , RefreshCw , Search , X ,
2323} from 'lucide-react'
2424import { fmtInt , fmtMoney } from '@/lib/format'
2525import KitchenItemDetailPage from '@/modules/kitchen/pages/KitchenItemDetailPage'
2626import {
27- getReliabilityQueue , mapProductToDish , recostProduct ,
27+ useReliabilityQueue , useInvalidateReliabilityQueue , mapProductToDish , recostProduct ,
2828 getReliability , suggestMatch , createDishFromUnmapped , resolveUnmapped ,
2929 type QueueItem , type Carril , type MatchSuggestion , type SalesReliability ,
3030} from '@/modules/kitchen/services/warehouseReliabilityService'
@@ -67,35 +67,43 @@ export default function WarehouseReliabilityPage({
6767 // KitchenItemsPage): así "Añadir ingredientes" lleva al sitio de verdad y al
6868 // volver se recarga la lista para comprobar si el fallo ya desapareció.
6969 const [ openItemId , setOpenItemId ] = useState < string | null > ( null )
70- const [ items , setItems ] = useState < QueueItem [ ] > ( [ ] )
7170 const [ rel , setRel ] = useState < SalesReliability | null > ( null )
72- const [ loading , setLoading ] = useState ( true )
73- const [ error , setError ] = useState < string | null > ( null )
71+ const [ errorMsg , setErrorMsg ] = useState < string | null > ( null )
7472 const [ flash , setFlash ] = useState < string | null > ( null )
75- const [ tick , setTick ] = useState ( 0 )
73+ const [ pageSize , setPageSize ] = useState ( 15 )
7674
77- const reload = useCallback ( ( ) => setTick ( t => t + 1 ) , [ ] )
75+ // React Query para caché inteligente (5 min fresco + 10 min en RAM)
76+ const { data : items = [ ] , isLoading, error : queryError } = useReliabilityQueue ( accountId , locationId , DAYS )
77+ const invalidateQueue = useInvalidateReliabilityQueue ( )
7878
79+ // Si hay error en React Query, mostrar mensaje de error
80+ useEffect ( ( ) => {
81+ if ( queryError ) {
82+ setErrorMsg ( queryError instanceof Error ? queryError . message : 'No se pudo cargar.' )
83+ } else {
84+ setErrorMsg ( null )
85+ }
86+ } , [ queryError ] )
87+
88+ // Cargar fiabilidad (usa su propia lógica)
7989 useEffect ( ( ) => {
8090 let vivo = true
8191 const cargar = async ( ) => {
82- setLoading ( true )
83- setError ( null )
8492 try {
85- const [ cola , r ] = await Promise . all ( [
86- getReliabilityQueue ( accountId , locationId ?? null , DAYS ) ,
87- getReliability ( accountId ) . catch ( ( ) => null ) ,
88- ] )
89- if ( vivo ) { setItems ( cola ) ; setRel ( r ) }
93+ const r = await getReliability ( accountId ) . catch ( ( ) => null )
94+ if ( vivo ) setRel ( r )
9095 } catch ( e ) {
91- if ( vivo ) setError ( e instanceof Error ? e . message : 'No se pudo cargar.' )
92- } finally {
93- if ( vivo ) setLoading ( false )
96+ // Silenciar errores en fiabilidad para no bloquear
9497 }
9598 }
9699 void cargar ( )
97100 return ( ) => { vivo = false }
98- } , [ accountId , locationId , tick ] )
101+ } , [ accountId ] )
102+
103+ // Botón "Actualizar" invalida caché y fuerza refetch
104+ const handleRefresh = ( ) => {
105+ invalidateQueue ( accountId , locationId , DAYS )
106+ }
99107
100108 // UN carril a la vez: se enseña el primero que aún tenga trabajo.
101109 const porCarril = useMemo ( ( ) => ( {
@@ -116,7 +124,7 @@ export default function WarehouseReliabilityPage({
116124 < div className = "p-4 sm:p-6 max-w-5xl mx-auto" >
117125 < KitchenItemDetailPage
118126 itemId = { openItemId }
119- onBack = { ( ) => { setOpenItemId ( null ) ; reload ( ) } }
127+ onBack = { ( ) => { setOpenItemId ( null ) ; handleRefresh ( ) } }
120128 />
121129 </ div >
122130 )
@@ -158,21 +166,21 @@ export default function WarehouseReliabilityPage({
158166 < button onClick = { ( ) => setFlash ( null ) } className = "text-emerald-700 hover:text-emerald-900" > < X size = { 14 } /> </ button >
159167 </ div >
160168 ) }
161- { error && (
169+ { errorMsg && (
162170 < div className = "mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 flex items-start gap-2" >
163171 < AlertTriangle size = { 15 } className = "mt-0.5 shrink-0" />
164- < span className = "flex-1" > { error } </ span >
172+ < span className = "flex-1" > { errorMsg } </ span >
165173 </ div >
166174 ) }
167175
168176 < div className = "flex items-center justify-between mb-3" >
169177 < span className = "text-xs text-gray-500" > Últimos { DAYS } días{ locationId ? ' · este local' : ' · todos los locales' } </ span >
170- < button onClick = { reload } className = "inline-flex items-center gap-1.5 text-xs px-2 py-1 border border-gray-200 rounded-md text-gray-600 hover:bg-gray-50" >
171- < RefreshCw size = { 13 } /> Actualizar
178+ < button onClick = { handleRefresh } disabled = { isLoading } className = "inline-flex items-center gap-1.5 text-xs px-2 py-1 border border-gray-200 rounded-md text-gray-600 hover:bg-gray-50 disabled:opacity-60 " >
179+ < RefreshCw size = { 13 } className = { isLoading ? 'animate-spin' : '' } /> Actualizar
172180 </ button >
173181 </ div >
174182
175- { loading ? (
183+ { isLoading ? (
176184 < div className = "flex items-center gap-2 text-gray-500 text-sm p-6" >
177185 < Loader2 size = { 16 } className = "animate-spin" /> Revisando tus ventas…
178186 </ div >
@@ -197,19 +205,30 @@ export default function WarehouseReliabilityPage({
197205 </ div >
198206
199207 < div className = "space-y-3" >
200- { listaActiva . map ( item => (
208+ { listaActiva . slice ( 0 , pageSize ) . map ( item => (
201209 < QueueCard
202210 key = { `${ item . carril } -${ item . productName } ` }
203211 item = { item }
204212 accountId = { accountId }
205213 actorName = { actorName ?? null }
206214 onOpenRecipe = { setOpenItemId }
207- onDone = { ( msg ) => { setFlash ( msg ) ; reload ( ) } }
208- onError = { setError }
215+ onDone = { ( msg ) => { setFlash ( msg ) ; handleRefresh ( ) } }
216+ onError = { setErrorMsg }
209217 />
210218 ) ) }
211219 </ div >
212220
221+ { pageSize < listaActiva . length && (
222+ < div className = "mt-4 flex justify-center" >
223+ < button
224+ onClick = { ( ) => setPageSize ( p => p + 15 ) }
225+ className = "inline-flex items-center gap-1.5 px-4 py-2 rounded-lg border border-gray-300 text-sm text-gray-700 hover:bg-gray-50"
226+ >
227+ Cargar más ({ listaActiva . length - pageSize } restantes)
228+ </ button >
229+ </ div >
230+ ) }
231+
213232 { ( carrilActivo === 'A' && ( porCarril . B . length > 0 || porCarril . C . length > 0 ) ) && (
214233 < p className = "text-xs text-gray-500 mt-5 text-center" >
215234 Después de esto quedan { fmtInt ( porCarril . B . length + porCarril . C . length ) } casos más, de otro tipo. Aparecerán aquí cuando termines con estos.
@@ -241,6 +260,8 @@ function QueueCard({
241260 const [ verOtros , setVerOtros ] = useState ( false )
242261 const [ confirmarIgnorar , setConfirmarIgnorar ] = useState ( false )
243262 const [ recost , setRecost ] = useState < { ventas : number } | null > ( null )
263+ // "Crear plato nuevo" encontró uno ya muy parecido: preguntar antes de duplicar.
264+ const [ duplicado , setDuplicado ] = useState < { recipeItemId : string ; nombre : string ; similitud : number } | null > ( null )
244265
245266 // La sugerencia solo se pide en el carril A, que es donde hay que elegir plato.
246267 useEffect ( ( ) => {
@@ -277,10 +298,14 @@ function QueueCard({
277298 }
278299 }
279300
280- async function crearPlato ( ) {
301+ async function crearPlato ( forzar = false ) {
281302 setBusy ( 'crear' )
282303 try {
283- const r = await createDishFromUnmapped ( accountId , item . productName )
304+ const r = await createDishFromUnmapped ( accountId , item . productName , forzar )
305+ if ( ! r . creado && r . candidato ) {
306+ setDuplicado ( r . candidato )
307+ return
308+ }
284309 onDone ( `Plato «${ item . productName } » creado. Ahora dile de qué ingredientes está hecho.` )
285310 if ( r . recipeItemId && onOpenRecipe ) onOpenRecipe ( r . recipeItemId )
286311 } catch ( e ) {
@@ -345,81 +370,107 @@ function QueueCard({
345370 { /* Carril A: elegir plato */ }
346371 { item . carril === 'A' && (
347372 < div className = "mt-3" >
348- { cargandoSug && (
349- < div className = "text-xs text-gray-500 flex items-center gap-1.5" > < Loader2 size = { 12 } className = "animate-spin" /> Buscando a qué plato se parece…</ div >
350- ) }
351-
352- { mejor && (
353- < >
354- < div className = "text-sm text-gray-700" >
355- Folvy cree que es: < span className = "font-medium text-gray-900" > { mejor . name } </ span > { ' ' }
356- < span className = "text-xs text-gray-500" > ({ Math . round ( mejor . confidence * 100 ) } % seguro)</ span >
357- </ div >
373+ { duplicado ? (
374+ < div className = "rounded-lg border border-amber-300 bg-amber-50 p-3" >
375+ < p className = "text-sm text-amber-900" >
376+ Esto se parece mucho a < span className = "font-medium" > «{ duplicado . nombre } »</ span > , que ya tienes
377+ ({ Math . round ( duplicado . similitud * 100 ) } % parecido). ¿Es el mismo plato?
378+ </ p >
358379 < div className = "flex flex-wrap gap-2 mt-2.5" >
359380 < button
360- onClick = { ( ) => casar ( mejor . recipeItemId , mejor . name ) }
381+ onClick = { ( ) => { void casar ( duplicado . recipeItemId , duplicado . nombre ) . then ( ( ) => setDuplicado ( null ) ) } }
361382 disabled = { busy != null }
362383 className = "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-60"
363384 >
364- { busy === mejor . recipeItemId ? < Loader2 size = { 15 } className = "animate-spin" /> : < Check size = { 15 } /> }
365- Sí, es este plato
385+ { busy === duplicado . recipeItemId ? < Loader2 size = { 15 } className = "animate-spin" /> : < Check size = { 15 } /> }
386+ Sí, casar con « { duplicado . nombre } »
366387 </ button >
367- { otros . length > 0 && (
368- < button onClick = { ( ) => setVerOtros ( v => ! v ) } disabled = { busy != null }
369- className = "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50" >
370- < Search size = { 14 } /> Es otro…
371- </ button >
372- ) }
373- < button onClick = { crearPlato } disabled = { busy != null }
374- className = "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50" >
388+ < button onClick = { ( ) => { setDuplicado ( null ) ; void crearPlato ( true ) } } disabled = { busy != null }
389+ className = "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-amber-300 text-sm text-amber-900 hover:bg-amber-100" >
375390 { busy === 'crear' ? < Loader2 size = { 14 } className = "animate-spin" /> : < ChefHat size = { 14 } /> }
376- Crear plato nuevo
377- </ button >
378- </ div >
379- </ >
380- ) }
381-
382- { sug !== null && sug . length === 0 && ! cargandoSug && (
383- < div className = "text-sm text-gray-700" >
384- No se parece a ningún plato de tu carta.
385- < div className = "mt-2" >
386- < button onClick = { crearPlato } disabled = { busy != null }
387- className = "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-60" >
388- { busy === 'crear' ? < Loader2 size = { 15 } className = "animate-spin" /> : < ChefHat size = { 15 } /> }
389- Crear plato nuevo
391+ No, crear uno nuevo igualmente
390392 </ button >
391393 </ div >
392394 </ div >
393- ) }
394-
395- { verOtros && otros . length > 0 && (
396- < div className = "mt-2.5 rounded-lg border border-gray-200 divide-y" >
397- { otros . map ( o => (
398- < button key = { o . recipeItemId } onClick = { ( ) => casar ( o . recipeItemId , o . name ) } disabled = { busy != null }
399- className = "w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between gap-2" >
400- < span className = "text-gray-900" > { o . name } </ span >
401- < span className = "text-xs text-gray-500" > { Math . round ( o . confidence * 100 ) } %</ span >
402- </ button >
403- ) ) }
404- </ div >
405- ) }
406-
407- { /* Salida honesta para lo que no gasta stock */ }
408- < div className = "mt-2.5" >
409- { ! confirmarIgnorar ? (
410- < button onClick = { ( ) => setConfirmarIgnorar ( true ) } disabled = { busy != null }
411- className = "text-xs text-gray-500 hover:text-gray-800 underline" >
412- No lleva stock (bebida, extra…) → no contarlo
413- </ button >
414- ) : (
415- < div className = "text-xs text-gray-700 flex items-center gap-2 flex-wrap" >
416- < span > ¿Seguro? Dejará de aparecer aquí y no descontará nada.</ span >
417- < button onClick = { ignorar } disabled = { busy != null }
418- className = "px-2.5 py-1 rounded-md bg-gray-900 text-white" > Sí, no contarlo</ button >
419- < button onClick = { ( ) => setConfirmarIgnorar ( false ) } className = "px-2 py-1 text-gray-600" > Cancelar</ button >
395+ ) : (
396+ < >
397+ { cargandoSug && (
398+ < div className = "text-xs text-gray-500 flex items-center gap-1.5" > < Loader2 size = { 12 } className = "animate-spin" /> Buscando a qué plato se parece…</ div >
399+ ) }
400+
401+ { mejor && (
402+ < >
403+ < div className = "text-sm text-gray-700" >
404+ Folvy cree que es: < span className = "font-medium text-gray-900" > { mejor . name } </ span > { ' ' }
405+ < span className = "text-xs text-gray-500" > ({ Math . round ( mejor . confidence * 100 ) } % seguro)</ span >
406+ </ div >
407+ < div className = "flex flex-wrap gap-2 mt-2.5" >
408+ < button
409+ onClick = { ( ) => casar ( mejor . recipeItemId , mejor . name ) }
410+ disabled = { busy != null }
411+ className = "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-60"
412+ >
413+ { busy === mejor . recipeItemId ? < Loader2 size = { 15 } className = "animate-spin" /> : < Check size = { 15 } /> }
414+ Sí, es este plato
415+ </ button >
416+ { otros . length > 0 && (
417+ < button onClick = { ( ) => setVerOtros ( v => ! v ) } disabled = { busy != null }
418+ className = "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50" >
419+ < Search size = { 14 } /> Es otro…
420+ </ button >
421+ ) }
422+ < button onClick = { ( ) => crearPlato ( ) } disabled = { busy != null }
423+ className = "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50" >
424+ { busy === 'crear' ? < Loader2 size = { 14 } className = "animate-spin" /> : < ChefHat size = { 14 } /> }
425+ Crear plato nuevo
426+ </ button >
427+ </ div >
428+ </ >
429+ ) }
430+
431+ { sug !== null && sug . length === 0 && ! cargandoSug && (
432+ < div className = "text-sm text-gray-700" >
433+ No se parece a ningún plato de tu carta.
434+ < div className = "mt-2" >
435+ < button onClick = { ( ) => crearPlato ( ) } disabled = { busy != null }
436+ className = "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-60" >
437+ { busy === 'crear' ? < Loader2 size = { 15 } className = "animate-spin" /> : < ChefHat size = { 15 } /> }
438+ Crear plato nuevo
439+ </ button >
440+ </ div >
441+ </ div >
442+ ) }
443+
444+ { verOtros && otros . length > 0 && (
445+ < div className = "mt-2.5 rounded-lg border border-gray-200 divide-y" >
446+ { otros . map ( o => (
447+ < button key = { o . recipeItemId } onClick = { ( ) => casar ( o . recipeItemId , o . name ) } disabled = { busy != null }
448+ className = "w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between gap-2" >
449+ < span className = "text-gray-900" > { o . name } </ span >
450+ < span className = "text-xs text-gray-500" > { Math . round ( o . confidence * 100 ) } %</ span >
451+ </ button >
452+ ) ) }
453+ </ div >
454+ ) }
455+
456+ { /* Salida honesta para lo que no gasta stock */ }
457+ < div className = "mt-2.5" >
458+ { ! confirmarIgnorar ? (
459+ < button onClick = { ( ) => setConfirmarIgnorar ( true ) } disabled = { busy != null }
460+ className = "text-xs text-gray-500 hover:text-gray-800 underline" >
461+ No lleva stock (bebida, extra…) → no contarlo
462+ </ button >
463+ ) : (
464+ < div className = "text-xs text-gray-700 flex items-center gap-2 flex-wrap" >
465+ < span > ¿Seguro? Dejará de aparecer aquí y no descontará nada.</ span >
466+ < button onClick = { ignorar } disabled = { busy != null }
467+ className = "px-2.5 py-1 rounded-md bg-gray-900 text-white" > Sí, no contarlo</ button >
468+ < button onClick = { ( ) => setConfirmarIgnorar ( false ) } className = "px-2 py-1 text-gray-600" > Cancelar</ button >
469+ </ div >
470+ ) }
420471 </ div >
421- ) }
422- </ div >
472+ </ >
473+ ) }
423474 </ div >
424475 ) }
425476
0 commit comments