Skip to content

Commit 08a5f68

Browse files
authored
Pendientes Fase 1: Almacén y Recepción (#77)
* feat(auth): claves de permiso granulares para Supply/Almacén (F0 Supply) RECON (14/08): show_inventory no gateaba nada en el código real — Supply (Pedidos/Recepciones/Facturas/Almacén/CTB) solo tenía requiredRole:'manager' a nivel de módulo, ningún item con requiredPermission. Proveedores, Platos y Rentabilidad/Ingeniería de menús (Kitchen) igual. Los márgenes reales de Ventas (Economía/Márgenes, Margen final, Food cost/Plato) sí tenían permiso — show_ventas_analisis — pero nada que ver con show_inventory. Alcance corregido con Julio antes de construir. 6 columnas nuevas en manager_permissions (2 migraciones — la segunda corrige a la primera, que había metido show_costes en el backfill por error de alcance; no se edita la migración ya aplicada): show_recepcion, show_pedidos, show_proveedores, show_inventarios, show_facturas — backward-compat: quien tenga show_inventory=true las recibe en true. show_costes — la sensible (costes/escandallos/márgenes). Nace en false para todos, NO entra en el backfill, mismo criterio que show_salaries. Cableado real (antes decorativo, ahora gatea de verdad): - supply/module.tsx: Pedidos→show_pedidos, Recepciones→show_recepcion, Facturas→show_facturas, Almacén→show_inventory ("ver el almacén y el stock", su alcance reducido). Comunicar a CTB→requiredRole:'admin' (decisión de Julio: no es operativa de local). - kitchen/module.tsx: Proveedores→show_proveedores. Platos→show_costes (decisión explícita: el escandallo se ve dentro de la pantalla normal de editar el plato, sin separar datos — "el rol no entra ahí", no se rediseña la pantalla). Rentabilidad e Ingeniería de menús→show_costes. - ventas/module.tsx: Economía/Márgenes, Margen final y Food cost/Plato exigen AHORA show_ventas_analisis Y show_costes (antes bastaba con show_ventas_analisis, que las 2 plantillas ya tenían en true — dejaba el food cost visible por esa vía aunque show_costes fuera false). shell/types.ts: requiredPermission pasa de string a string | string[] (array = AND, todas las claves exigidas) — necesario para el caso de Ventas. Los 4 sitios que lo evalúan (ModuleSidebar, ShellTopBar, ShellBottomNav, MobileModuleTabs) actualizados con el mismo check. managerPermissionsService.ts: las 6 claves nuevas en DEFAULT_PERMISSIONS y en los 4 mappers (rowToManagerPermissions, managerPermissionsToInsertRow, patchToUpdateRow, permissionTemplateValuesToRow). Plantillas "Responsable de local"/"Oficina": las 5 no-sensibles en true, show_costes en false en las dos. Corrección adicional aprobada por Julio sobre lo ya mergeado en #74: show_informes_personal (gatea Nóminas) pasa a true en "Responsable de local" — edita el cuadrante y aprueba vacaciones; no ver las horas de su equipo sería incoherente. Se queda en false en "Oficina". ManagerPermissionsModal.tsx: 6 toggles nuevos, sección "Compras y Almacén" — no quedan decorativos, tienen editor individual desde el primer commit (a diferencia de 6 columnas previas al 14/08 que siguen sin toggle: showTasks/showScheduled/showTemplates/showIncidents/showAudits/ showHistory, deuda previa no tocada aquí). Verificado: has_permission()/get_effective_permissions() reconocen las 6 columnas nuevas sin cambio de función (impersonación real vía MCP contra la única fila existente en manager_permissions). tsc -b limpio (único error restante: DailyCountWizard.tsx, huérfano, ajeno a esta rama). * feat(pendientes): Fase 1 — pantalla de PENDIENTES (Almacén y Recepción) Tramo A (BBDD, verificado en vivo vía MCP con impersonación real): - pending_dismissal: tabla de descartes (posponer/descartar), checks que exigen until en posponer y reason en descartar, RLS por cuenta. - pending_raw_entities(account_id): helper compartido por pending_board (contar) y dismiss_pending (fotografiar al posponer) — una sola definición de "qué es un pendiente de este tipo", para que nunca puedan desincronizarse. - pending_board(account_id): una RPC, alcance por rol/local DENTRO de la función (admin=todos los locales activos, manager=solo manager_locations, sin perfil=cero filas), permiso por pending_kind vía has_permission() existente (sin fila con permiso denegado = fila no calculada, no "0"). Los 8 pendientes de Fase 1 + stock_negativo (salud). - dismiss_pending(...): posponer fotografía las entidades que HOY matchean ese pending_kind+local — si entra una NUEVA mientras está pospuesto (no está en la foto), no tiene descarte y aparece de inmediato: "si el número sube, reaparece hoy" sin contador aparte. Descartar siempre a nivel de tipo (entity_id null). Verificación C1-C9 ejecutada (no diseñada): admin ve los pendientes reales de los dos locales activos con su nombre en cada línea; Responsable de prueba con 1 local ve solo ese local; sin locales, cero filas; sin permiso de un pending_kind, esa fila no aparece ni con 0 (probado en ambos sentidos); cuenta ajena → excepción; posponer desaparece hoy (verificado el `until` real) y reaparece si entra una entidad nueva (probado con una fila temporal insertada y borrada); descartar sin motivo bloqueado tanto por la función como por el check de tabla; descartar con motivo real suprime. Usuario y datos de prueba eliminados por completo, verificado en 0 filas en las 8 tablas tocadas. Dos hallazgos durante la verificación, no corregidos aquí (fuera de alcance de esta rama): - 1 purchase_order (id 6421bfb8-fd9a-4432-ba96-856477461906, borrador desde el 14/06) referencia un location_id que pertenece a OTRA cuenta (la de seed "Folvy Interno"), no a Foodint — bug de datos preexistente, aislado a esa fila (barrido en las otras 4 tablas: 0 casos). - La "línea base" del encargo (2·2·1·2·4·3 + 11) se midió con consultas planas, sin filtrar por local ACTIVO ni por que el location_id pertenezca de verdad a la cuenta — pending_board sí filtra ambas cosas (correcto per C1: "los dos locales activos"), así que los números en vivo no coinciden exactamente con la línea base declarada. Tramo B (pantalla): - src/modules/pendientes/: service (pending_board/dismiss_pending + textos/destino literales de A.2), hook usePendingBoard (un fetch, reutilizado por TopBar/BottomNav/pantalla), PendientesPage (AHORA ámbar → ESTA SEMANA neutro → SALUD solo números; posponer sin motivo, 3 presets; descartar con motivo tipificado; vacío con última vez). - Shell.tsx: /pendientes como pantalla especial (no un módulo del registry), aterrizaje en Home si ahora+semana > 0 tras cargar. - ShellTopBar.tsx / ShellBottomNav.tsx: pestaña permanente "Pendientes" con badge (ahora+semana, nunca salud), junto a "Inicio". 🔴 Deuda declarada: los botones navegan a la pantalla correcta pero NINGUNA pantalla de Supply lee hoy un query param de filtro (verificado: GoodsReceiptsPage no usa useSearchParams) — "filtrada a X" de la columna A.2 no está cumplido, solo la navegación. Cablear el filtro real en cada pantalla de destino queda fuera de esta rama. 🔴 Documentos de lectura obligatoria del encargo (claude/folvy_pendientes_ diseno_20260814.md, claude_folvy_tpv_sistema_diseno_20260811.md) NO están en este repo — no pude leerlos. Construido sobre el propio ENCARGO CODE. tsc -b limpio (único error: DailyCountWizard.tsx, huérfano, ajeno). * fix(pendientes): filtro real en Recepciones/Pedidos + botones honestos (§0.2) Recepciones: ?estado=recibido|confirmado_revision|borrador filtra de verdad la lista (antes solo existía búsqueda por texto, ningún filtro de estado). Pedidos: ?estado=vencido|borrador igual, con las mismas dos condiciones de la RPC pending_board (enviado+vencido, borrador+7 días). Banner "Filtrado desde Pendientes · Quitar filtro" en las dos. linea_sin_coste (filtro por LÍNEA, no por recepción — GoodsReceiptsPage no tiene forma barata de saberlo sin una consulta aparte) y los 3 que van a Almacén (recuento_abierto, recuento_sin_aprobar, stock_negativo — InventoryPage no lee ningún query param) se quedan SIN filtrar: texto corregido a "Ir a Recepciones"/"Ir a Inventarios", ya no prometen un filtro que no cumplen. reactivate_on (jsonb): decisión de Julio, NO se añade — el mecanismo de fotografiar entidades al posponer ya cubre el caso real, y los descartes no aplican a la capa SALUD (no se pospone un número). Disparador para reconsiderarlo: el día que un pendiente de Ahora/Esta semana tenga magnitud además de existencia. Registrado aquí, no en el esquema.
1 parent 885e4c9 commit 08a5f68

10 files changed

Lines changed: 993 additions & 21 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
// src/modules/pendientes/PendientesPage.tsx
2+
//
3+
// ENCARGO CODE (14/08) Pantalla de PENDIENTES, Fase 1 (Almacén y Recepción).
4+
// AHORA (ámbar) → ESTA SEMANA (neutro) → SALUD (solo números). Una línea
5+
// por causa, nunca por caso — "2 albaranes a medio registrar" es una línea.
6+
//
7+
// Contraste (B.6): nunca text-warning/bg-warning-bg ni border-border-default
8+
// para texto — mismo patrón que ReceiptOfficeReview (text-text-primary
9+
// sobre el fondo de color).
10+
11+
import { useEffect, useMemo, useState } from 'react'
12+
import { useNavigate } from 'react-router-dom'
13+
import { MoreHorizontal, MapPin, AlertTriangle, Inbox } from 'lucide-react'
14+
import { useApp } from '../../context/AppContext'
15+
import { usePendingBoard } from './hooks/usePendingBoard'
16+
import {
17+
pendingKindMeta,
18+
postponePending,
19+
dismissPending,
20+
DISMISS_REASONS,
21+
type PendingItem,
22+
type PendingPreset,
23+
type DismissReason,
24+
} from './pendientesService'
25+
26+
const LAST_NONEMPTY_KEY = 'folvy_pendientes_last_nonempty'
27+
28+
const PRESET_LABEL: Record<PendingPreset, string> = {
29+
manana: 'Mañana',
30+
semana: 'La semana que viene',
31+
mes: 'El mes que viene',
32+
}
33+
34+
export default function PendientesPage() {
35+
const { activeAccountId } = useApp()
36+
const navigate = useNavigate()
37+
const { items, loading, error, refetch } = usePendingBoard()
38+
const [busyKey, setBusyKey] = useState<string | null>(null)
39+
const [openMenuKey, setOpenMenuKey] = useState<string | null>(null)
40+
const [reasonModalKey, setReasonModalKey] = useState<string | null>(null)
41+
42+
const ahora = items.filter(i => i.layer === 'ahora')
43+
const semana = items.filter(i => i.layer === 'semana')
44+
const salud = items.filter(i => i.layer === 'salud')
45+
const isEmptyActionable = !loading && ahora.length === 0 && semana.length === 0
46+
47+
// Client-side, no hay tabla de historial: recuerda en este navegador la
48+
// última vez que el tablero no estuvo vacío. Deliberadamente simple — no
49+
// es verdad de servidor, es memoria local para que el vacío no sea mudo.
50+
const [lastNonEmpty, setLastNonEmpty] = useState<string | null>(() => {
51+
try { return localStorage.getItem(LAST_NONEMPTY_KEY) } catch { return null }
52+
})
53+
useEffect(() => {
54+
if (!loading && (ahora.length > 0 || semana.length > 0)) {
55+
const now = new Date().toISOString()
56+
try { localStorage.setItem(LAST_NONEMPTY_KEY, now) } catch { /* noop */ }
57+
setLastNonEmpty(now)
58+
}
59+
}, [loading, ahora.length, semana.length])
60+
61+
function lineKey(i: PendingItem): string {
62+
return `${i.pendingKind}::${i.locationId}`
63+
}
64+
65+
async function handlePostpone(item: PendingItem, preset: PendingPreset) {
66+
if (!activeAccountId) return
67+
setOpenMenuKey(null)
68+
setBusyKey(lineKey(item))
69+
try {
70+
await postponePending(activeAccountId, item.pendingKind, item.locationId, preset)
71+
refetch()
72+
} finally {
73+
setBusyKey(null)
74+
}
75+
}
76+
77+
async function handleDismiss(item: PendingItem, reason: DismissReason) {
78+
if (!activeAccountId) return
79+
setReasonModalKey(null)
80+
setBusyKey(lineKey(item))
81+
try {
82+
await dismissPending(activeAccountId, item.pendingKind, item.locationId, reason)
83+
refetch()
84+
} finally {
85+
setBusyKey(null)
86+
}
87+
}
88+
89+
return (
90+
<div className="space-y-6">
91+
<h1 className="text-lg font-display font-semibold tracking-tight text-text-primary">Pendientes</h1>
92+
93+
{error && (
94+
<div className="rounded-lg border border-danger/30 bg-danger-bg px-4 py-3 text-sm text-text-primary">
95+
{error}
96+
</div>
97+
)}
98+
99+
{loading && (
100+
<div className="text-sm text-text-secondary">Cargando…</div>
101+
)}
102+
103+
{isEmptyActionable && (
104+
<div className="rounded-lg border border-border-default bg-card px-5 py-6 text-center">
105+
<div className="text-sm font-medium text-text-primary">No tienes nada pendiente en Almacén.</div>
106+
{lastNonEmpty && (
107+
<div className="text-xs text-text-secondary mt-1">
108+
Última vez con pendientes: {new Date(lastNonEmpty).toLocaleString('es-ES')}
109+
</div>
110+
)}
111+
</div>
112+
)}
113+
114+
{ahora.length > 0 && (
115+
<Section title="AHORA" tone="ahora">
116+
{ahora.map(item => (
117+
<PendingLine
118+
key={lineKey(item)}
119+
item={item}
120+
busy={busyKey === lineKey(item)}
121+
menuOpen={openMenuKey === lineKey(item)}
122+
onToggleMenu={() => setOpenMenuKey(openMenuKey === lineKey(item) ? null : lineKey(item))}
123+
onGo={() => navigate(pendingKindMeta(item.pendingKind).destination(item.locationId))}
124+
onPostpone={preset => handlePostpone(item, preset)}
125+
onAskDismiss={() => { setOpenMenuKey(null); setReasonModalKey(lineKey(item)) }}
126+
/>
127+
))}
128+
</Section>
129+
)}
130+
131+
{semana.length > 0 && (
132+
<Section title="ESTA SEMANA" tone="semana">
133+
{semana.map(item => (
134+
<PendingLine
135+
key={lineKey(item)}
136+
item={item}
137+
busy={busyKey === lineKey(item)}
138+
menuOpen={openMenuKey === lineKey(item)}
139+
onToggleMenu={() => setOpenMenuKey(openMenuKey === lineKey(item) ? null : lineKey(item))}
140+
onGo={() => navigate(pendingKindMeta(item.pendingKind).destination(item.locationId))}
141+
onPostpone={preset => handlePostpone(item, preset)}
142+
onAskDismiss={() => { setOpenMenuKey(null); setReasonModalKey(lineKey(item)) }}
143+
/>
144+
))}
145+
</Section>
146+
)}
147+
148+
{salud.length > 0 && (
149+
<Section title="SALUD" tone="salud">
150+
{salud.map(item => (
151+
<div key={lineKey(item)} className="flex items-center justify-between gap-3 px-4 py-3">
152+
<div className="flex items-center gap-2.5 min-w-0">
153+
<AlertTriangle size={16} className="text-text-secondary shrink-0" />
154+
<span className="text-sm text-text-primary truncate">{pendingKindMeta(item.pendingKind).text(item.items)}</span>
155+
</div>
156+
<span className="inline-flex items-center gap-1 text-xs text-text-secondary shrink-0">
157+
<MapPin size={13} />{item.locationName}
158+
</span>
159+
</div>
160+
))}
161+
</Section>
162+
)}
163+
164+
{reasonModalKey && (
165+
<DismissReasonModal
166+
onCancel={() => setReasonModalKey(null)}
167+
onPick={reason => {
168+
const item = items.find(i => lineKey(i) === reasonModalKey)
169+
if (item) handleDismiss(item, reason)
170+
}}
171+
/>
172+
)}
173+
</div>
174+
)
175+
}
176+
177+
function Section({ title, tone, children }: { title: string; tone: 'ahora' | 'semana' | 'salud'; children: React.ReactNode }) {
178+
const headerClass =
179+
tone === 'ahora' ? 'text-warning' : tone === 'semana' ? 'text-text-secondary' : 'text-text-secondary'
180+
return (
181+
<div className="space-y-2">
182+
<h2 className={`text-xs font-semibold uppercase tracking-wide ${headerClass}`}>{title}</h2>
183+
<div className="rounded-lg border border-border-default bg-card divide-y divide-border-default">
184+
{children}
185+
</div>
186+
</div>
187+
)
188+
}
189+
190+
function PendingLine({
191+
item, busy, menuOpen, onToggleMenu, onGo, onPostpone, onAskDismiss,
192+
}: {
193+
item: PendingItem
194+
busy: boolean
195+
menuOpen: boolean
196+
onToggleMenu: () => void
197+
onGo: () => void
198+
onPostpone: (preset: PendingPreset) => void
199+
onAskDismiss: () => void
200+
}) {
201+
const meta = useMemo(() => pendingKindMeta(item.pendingKind), [item.pendingKind])
202+
const isAhora = item.layer === 'ahora'
203+
return (
204+
<div className={`flex items-center justify-between gap-3 px-4 py-3.5 ${isAhora ? 'bg-warning-bg' : ''}`}>
205+
<div className="min-w-0 flex-1">
206+
<div className="text-sm font-medium text-text-primary">{meta.text(item.items)}</div>
207+
<span className="inline-flex items-center gap-1 text-xs text-text-secondary mt-0.5">
208+
<MapPin size={12} />{item.locationName}
209+
</span>
210+
</div>
211+
<div className="flex items-center gap-2 shrink-0">
212+
<button
213+
type="button"
214+
disabled={busy}
215+
onClick={onGo}
216+
className="px-3 py-2 rounded-md text-sm font-medium bg-accent text-white hover:opacity-90 disabled:opacity-50 transition-base"
217+
style={{ minHeight: 44 }}
218+
>
219+
{meta.buttonText(item.items)}
220+
</button>
221+
<div className="relative">
222+
<button
223+
type="button"
224+
aria-label="Más opciones"
225+
disabled={busy}
226+
onClick={onToggleMenu}
227+
className="p-2 rounded-md text-text-secondary hover:bg-page disabled:opacity-50"
228+
style={{ minHeight: 44, minWidth: 44 }}
229+
>
230+
<MoreHorizontal size={18} />
231+
</button>
232+
{menuOpen && (
233+
<div
234+
role="menu"
235+
className="absolute right-0 rounded-lg overflow-hidden bg-card border border-border-default shadow-lg z-20"
236+
style={{ top: 44, minWidth: 200 }}
237+
>
238+
<div className="px-3 py-1.5 text-xs text-text-secondary">Posponer</div>
239+
{(['manana', 'semana', 'mes'] as PendingPreset[]).map(preset => (
240+
<button
241+
key={preset}
242+
type="button"
243+
role="menuitem"
244+
onClick={() => onPostpone(preset)}
245+
className="w-full text-left px-4 py-2 text-sm text-text-primary hover:bg-page"
246+
>
247+
{PRESET_LABEL[preset]}
248+
</button>
249+
))}
250+
<div className="border-t border-border-default" />
251+
<button
252+
type="button"
253+
role="menuitem"
254+
onClick={onAskDismiss}
255+
className="w-full text-left px-4 py-2 text-sm text-text-primary hover:bg-page"
256+
>
257+
Descartar…
258+
</button>
259+
</div>
260+
)}
261+
</div>
262+
</div>
263+
</div>
264+
)
265+
}
266+
267+
function DismissReasonModal({ onCancel, onPick }: { onCancel: () => void; onPick: (reason: DismissReason) => void }) {
268+
return (
269+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" onClick={onCancel}>
270+
<div
271+
className="bg-card border border-border-default rounded-lg p-5 w-full max-w-sm"
272+
onClick={e => e.stopPropagation()}
273+
>
274+
<div className="flex items-center gap-2 mb-3">
275+
<Inbox size={18} className="text-text-secondary" />
276+
<h3 className="text-sm font-semibold text-text-primary">¿Por qué se descarta?</h3>
277+
</div>
278+
<div className="space-y-2">
279+
{DISMISS_REASONS.map(reason => (
280+
<button
281+
key={reason}
282+
type="button"
283+
onClick={() => onPick(reason)}
284+
className="w-full text-left px-3 py-2.5 rounded-md text-sm text-text-primary border border-border-default hover:bg-page transition-base"
285+
style={{ minHeight: 44 }}
286+
>
287+
{reason}
288+
</button>
289+
))}
290+
</div>
291+
<button
292+
type="button"
293+
onClick={onCancel}
294+
className="mt-3 w-full px-3 py-2 rounded-md text-sm text-text-secondary hover:bg-page"
295+
>
296+
Cancelar
297+
</button>
298+
</div>
299+
</div>
300+
)
301+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// src/modules/pendientes/hooks/usePendingBoard.ts
2+
//
3+
// ENCARGO CODE (14/08) Pantalla de PENDIENTES, Fase 1. Un solo fetch de
4+
// pending_board(); expone las filas crudas y los contadores que necesitan
5+
// tanto la pantalla /pendientes como la pestaña permanente del TopBar.
6+
7+
import { useCallback, useEffect, useState } from 'react'
8+
import { useApp } from '../../../context/AppContext'
9+
import { getPendingBoard, type PendingItem } from '../pendientesService'
10+
11+
export interface UsePendingBoardResult {
12+
items: PendingItem[]
13+
loading: boolean
14+
error: string | null
15+
/** Suma de items en capas 'ahora' + 'semana'. NUNCA 'salud' (B.1). */
16+
actionableCount: number
17+
refetch: () => void
18+
}
19+
20+
export function usePendingBoard(): UsePendingBoardResult {
21+
const { activeAccountId } = useApp()
22+
const [items, setItems] = useState<PendingItem[]>([])
23+
const [loading, setLoading] = useState(false)
24+
const [error, setError] = useState<string | null>(null)
25+
const [tick, setTick] = useState(0)
26+
27+
useEffect(() => {
28+
if (!activeAccountId) { setItems([]); return }
29+
let cancelled = false
30+
setLoading(true)
31+
setError(null)
32+
getPendingBoard(activeAccountId)
33+
.then(rows => { if (!cancelled) setItems(rows) })
34+
.catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) })
35+
.finally(() => { if (!cancelled) setLoading(false) })
36+
return () => { cancelled = true }
37+
}, [activeAccountId, tick])
38+
39+
const refetch = useCallback(() => setTick(t => t + 1), [])
40+
41+
const actionableCount = items
42+
.filter(i => i.layer === 'ahora' || i.layer === 'semana')
43+
.reduce((sum, i) => sum + i.items, 0)
44+
45+
return { items, loading, error, actionableCount, refetch }
46+
}

0 commit comments

Comments
 (0)