Skip to content

Commit 11aa05a

Browse files
authored
Merge pull request #36 from Llorente29/feature/auditoria-fixes
Feature/auditoria fixes
2 parents 517697c + 46a65c5 commit 11aa05a

12 files changed

Lines changed: 529 additions & 45 deletions

File tree

src/lib/format.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ export function fmtPct(x: unknown, dp = 1): string {
4545
return isNum(x) ? `${Number(x).toFixed(dp).replace('.', ',')} %` : DASH
4646
}
4747

48+
/** Horas trabajadas/contratadas en formato español: `4,5h` (sin decimales
49+
* sobrantes: `8h`, no `8,00h`). Ausente → '—'. `0` → '0h'. */
50+
export function fmtHours(x: unknown, dp = 2): string {
51+
if (!isNum(x)) return DASH
52+
const n = Number(x)
53+
// Recorta ceros sobrantes (4.50 -> "4,5", 8.00 -> "8") pero conserva los
54+
// decimales que sí aportan info (8.75 -> "8,75") -- parseFloat sobre el
55+
// fijo a dp quita los ceros de cola sin reintroducir el punto.
56+
return `${parseFloat(n.toFixed(dp))}`.replace('.', ',') + 'h'
57+
}
58+
4859
/** Entero con separador de miles local (`1.234`). Null-safe: reemplaza a
4960
* `n.toLocaleString('es-ES')` sobre conteos del servidor (que petan con null).
5061
* Ausente → '—'. `0` → '0'. */

src/modules/kds/components/CookModePanel.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from '
1111
import { X, AlertTriangle, ImageOff, Loader2 } from 'lucide-react'
1212
import { getRecipe, type KdsRecipe, type AllergenState } from '../services/kdsService'
1313
import { roundQty } from '../kdsUtils'
14+
import { allergenLabel, type AllergenCode } from '@/modules/kitchen/lib/allergens'
1415

1516
interface CookModeTarget {
1617
menuItemId: string
@@ -25,15 +26,13 @@ interface CookModePanelProps {
2526
locationId?: string | null
2627
}
2728

28-
const ALLERGEN_LABELS: Record<string, string> = {
29-
gluten: 'Gluten', crustaceos: 'Crustáceos', huevo: 'Huevo', pescado: 'Pescado',
30-
cacahuetes: 'Cacahuetes', soja: 'Soja', lacteos: 'Lácteos', frutos_secos: 'Frutos secos',
31-
apio: 'Apio', mostaza: 'Mostaza', sesamo: 'Sésamo', sulfitos: 'Sulfitos',
32-
altramuces: 'Altramuces', moluscos: 'Moluscos',
33-
}
34-
function allergenLabel(code: string): string {
35-
return ALLERGEN_LABELS[code] ?? code
36-
}
29+
// 🔴 Auditoría externa: aquí vivía un ALLERGEN_LABELS local con claves en
30+
// ESPAÑOL (gluten/crustaceos/huevo...) que no casaban con AllergenCode, el
31+
// código estable en inglés-neutro que persiste la BBDD (gluten/crustaceans/
32+
// eggs...) — solo 'gluten' traducía de verdad, los otros 13 caían al
33+
// fallback `?? code` y salían en inglés. Se sustituye por la fuente única
34+
// (src/modules/kitchen/lib/allergens.ts) en vez de mantener una segunda
35+
// traducción que puede volver a divergir.
3736
function allergenChipClasses(state: AllergenState): string {
3837
switch (state) {
3938
case 'contains': return 'bg-red-500/25 text-red-200 ring-1 ring-red-500/50'
@@ -187,7 +186,7 @@ export default function CookModePanel({ target, onClose, token, locationId }: Co
187186
key={a.code}
188187
className={`px-2.5 py-1 rounded-md text-sm font-medium ${allergenChipClasses(a.state)}`}
189188
>
190-
{allergenLabel(a.code)}
189+
{allergenLabel(a.code as AllergenCode)}
191190
{a.state === 'may_contain' && <span className="opacity-70"> (trazas)</span>}
192191
</span>
193192
))}

src/modules/kds/components/KdsTicketCard.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { useEffect, useRef, useState } from 'react'
2525
import { Check, ChefHat, Undo2, AlertTriangle } from 'lucide-react'
2626
import type { KdsTicket, KdsLine, KdsLineChild } from '../services/kdsService'
2727
import { ticketCode, channelBadge, timeLevel, timeChipClasses } from '../kdsUtils'
28+
import { allergenLabel, type AllergenCode } from '@/modules/kitchen/lib/allergens'
2829

2930
const SIN_ESTACION = '__none__'
3031

@@ -151,7 +152,16 @@ export default function KdsTicketCard({
151152
<header className="px-3 py-2.5 bg-zinc-900/60 border-b border-zinc-700">
152153
<div className="flex items-center gap-2">
153154
<BrandAvatar name={ticket.brand} logoUrl={ticket.brand_logo_url} />
154-
<span className="text-lg font-extrabold tabular-nums text-zinc-100 font-display flex-1 min-w-0 truncate">
155+
{/* Auditoría externa (2.5): el auditor propuso ocultar este código
156+
por "ruido" -- descartado por Julio, es lo que pide soporte de
157+
la plataforma para localizar un pedido, no es ruido. Se queda
158+
visible pero deja de competir con el tiempo (que es lo que el
159+
cocinero mira primero): pequeño y gris en vez de el elemento
160+
más grande y brillante de la cabecera. */}
161+
<span
162+
className="text-xs text-zinc-400 tabular-nums font-mono flex-1 min-w-0 truncate"
163+
title="Referencia de la plataforma — para soporte"
164+
>
155165
{ticketCode(ticket.external_tab_ref, ticket.external_ref)}
156166
</span>
157167
<span className={`px-2 py-0.5 rounded-md text-sm font-bold tabular-nums shrink-0 ${timeChipClasses(level)}`}>
@@ -309,7 +319,7 @@ function KdsLineRow({ line, onMarkLine, onOpenCook }: {
309319
<span className="text-[15px] font-medium">{line.name}</span>
310320
{clickable && <ChefHat size={13} className="inline ml-1.5 -mt-0.5 text-zinc-500" />}
311321
{line.allergens.length > 0 && (
312-
<span className="ml-1.5 text-[#E8B84B] text-xs align-middle" title={line.allergens.join(', ')}>
322+
<span className="ml-1.5 text-[#E8B84B] text-xs align-middle" title={line.allergens.map(a => allergenLabel(a as AllergenCode)).join(', ')}>
313323
{line.allergens.length}
314324
</span>
315325
)}

src/modules/kitchen/components/EconomiaTab.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type SalesChannel as SalesChannelType,
2828
} from '@/modules/kitchen/services/channelRateService'
2929
import { supabase } from '@/lib/supabase'
30+
import { fmtPct } from '@/lib/format'
3031
import type { MenuItem } from '@/types/kitchen'
3132

3233
function fmtEur(value: number | null | undefined): string {
@@ -128,17 +129,17 @@ export default function EconomiaTab({ item }: EconomiaTabProps) {
128129
<div className="bg-stone-50 rounded-lg px-4 py-3">
129130
<div className="text-[10px] font-medium text-stone-400 tracking-widest uppercase mb-1">PVP cliente</div>
130131
<div className="font-mono text-lg font-medium">{fmtEur(pvpConIva)}</div>
131-
<div className="text-[11px] text-stone-400">IVA {vatPct}% incluido</div>
132+
<div className="text-[11px] text-stone-400">IVA {fmtPct(vatPct)} incluido</div>
132133
</div>
133134
<div className="bg-stone-50 rounded-lg px-4 py-3">
134-
<div className="text-[10px] font-medium text-stone-400 tracking-widest uppercase mb-1">Food cost</div>
135+
<div className="text-[10px] font-medium text-stone-400 tracking-widest uppercase mb-1">Coste de producto</div>
135136
<div className={`font-mono text-lg font-medium ${hasCost ? 'text-[#BA7517]' : 'text-stone-300'}`}>{hasCost ? fmtEur(recipeCost) : '—'}</div>
136-
<div className="text-[11px] text-stone-400">{hasCost ? `${foodCostPct}% del PVP` : 'Pendiente de escandallo'}</div>
137+
<div className="text-[11px] text-stone-400">{hasCost ? `${fmtPct(foodCostPct, 2)} del PVP` : 'Pendiente de escandallo'}</div>
137138
</div>
138139
<div className="bg-stone-50 rounded-lg px-4 py-3">
139140
<div className="text-[10px] font-medium text-stone-400 tracking-widest uppercase mb-1">Mejor margen</div>
140141
<div className={`font-mono text-lg font-medium ${bestMargin != null ? 'text-success' : 'text-stone-300'}`}>{bestMargin != null ? fmtEur(bestMargin) : '—'}</div>
141-
<div className="text-[11px] text-stone-400">{bestChannel ? `${bestChannel} · ${bestMarginPct}%` : 'Configura un canal'}</div>
142+
<div className="text-[11px] text-stone-400">{bestChannel ? `${bestChannel} · ${fmtPct(bestMarginPct, 2)}` : 'Configura un canal'}</div>
142143
</div>
143144
</div>
144145

@@ -176,12 +177,12 @@ export default function EconomiaTab({ item }: EconomiaTabProps) {
176177
<div className="flex items-center gap-2.5">{badge}</div>
177178
<div className="text-right">
178179
<span className={`font-mono text-xl font-medium ${margin >= 0 ? 'text-success' : 'text-danger'}`}>{fmtEur(margin)}</span>
179-
<div className="text-[12px] text-stone-400">{marginPct}% del PVP{!e.costAvailable ? ' · sin food cost' : ''}</div>
180+
<div className="text-[12px] text-stone-400">{fmtPct(marginPct, 2)} del PVP{!e.costAvailable ? ' · sin coste de producto' : ''}</div>
180181
</div>
181182
</div>
182183
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-2 text-[12px] text-stone-500">
183-
{e.costAvailable && <span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-sm bg-[#A68B6B]" /> Food cost {fmtEur(e.cost)}</span>}
184-
{e.commissionPct != null && <span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-sm bg-[#4A6A8A]" /> Comisión {e.commissionPct}% ({fmtEur(commAmt)})</span>}
184+
{e.costAvailable && <span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-sm bg-[#A68B6B]" /> Coste de producto {fmtEur(e.cost)}</span>}
185+
{e.commissionPct != null && <span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-sm bg-[#4A6A8A]" /> Comisión {fmtPct(e.commissionPct)} ({fmtEur(commAmt)})</span>}
185186
{hasOrderCosts && (
186187
<span className="flex items-center gap-1.5 cursor-help"
187188
title={`Coste de reparto propio por pedido: coste del rider${e.ownCourierCost != null ? ` (${fmtEur(e.ownCourierCost)})` : ''} + comisión fija${e.commissionFixed != null ? ` (${fmtEur(e.commissionFixed)})` : ''} − envío que paga el cliente${e.ownCustomerFee != null ? ` (${fmtEur(e.ownCustomerFee)})` : ''}, sin IVA, repartido entre ~2 platos por pedido. Es una estimación hasta tener ventas reales.`}>
@@ -214,8 +215,8 @@ export default function EconomiaTab({ item }: EconomiaTabProps) {
214215
prop, así que no hay ventana de desincronización). */}
215216
<p className="text-[12px] text-stone-500 mt-4 pt-3 border-t border-stone-200">
216217
{item.targetFoodCostPct != null
217-
? `Target FC: ${item.targetFoodCostPct}% · ${foodCostPct != null ? (foodCostPct <= item.targetFoodCostPct ? 'Dentro del objetivo' : 'Fuera del objetivo') : 'sin food cost para comparar'}`
218-
: 'Sin target de food cost configurado.'}
218+
? `Objetivo de coste: ${fmtPct(item.targetFoodCostPct)} · ${foodCostPct != null ? (foodCostPct <= item.targetFoodCostPct ? 'Dentro del objetivo' : 'Fuera del objetivo') : 'sin coste de producto para comparar'}`
219+
: 'Sin objetivo de coste configurado.'}
219220
</p>
220221
</div>
221222
)

src/modules/kitchen/components/FichaTab.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,11 @@ export default function FichaTab({ item, accountId, onItemChanged }: FichaTabPro
424424
</p>
425425
</div>
426426

427-
{/* Packaging delivery (S9) */}
427+
{/* Envases para reparto (S9) */}
428428
<div>
429-
<h3 className="text-xs font-medium uppercase tracking-wide text-stone-400 mb-3">Packaging delivery</h3>
429+
<h3 className="text-xs font-medium uppercase tracking-wide text-stone-400 mb-3">Envases para reparto</h3>
430430
{!item.packagingDescription && item.packagingCost == null && (
431-
<p className="text-sm text-stone-500 mb-3">Sin información de packaging.</p>
431+
<p className="text-sm text-stone-500 mb-3">Sin información de envases.</p>
432432
)}
433433
<div className="space-y-3">
434434
<div>
@@ -442,7 +442,7 @@ export default function FichaTab({ item, accountId, onItemChanged }: FichaTabPro
442442
/>
443443
</div>
444444
<div>
445-
<label className="block text-xs font-medium text-stone-500 mb-1.5">Coste packaging (€/unidad)</label>
445+
<label className="block text-xs font-medium text-stone-500 mb-1.5">Coste de envases (€/unidad)</label>
446446
<input
447447
type="text" inputMode="decimal" value={packCost} onChange={(e) => setPackCost(e.target.value)}
448448
className="w-40 px-3 py-2.5 text-sm border border-stone-200 rounded-lg bg-stone-50 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent"
@@ -457,7 +457,7 @@ export default function FichaTab({ item, accountId, onItemChanged }: FichaTabPro
457457
disabled={fieldSaving === 'pack'}
458458
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-accent text-text-on-accent hover:opacity-90 disabled:opacity-50 transition-opacity"
459459
>
460-
{fieldSaving === 'pack' ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Guardar packaging
460+
{fieldSaving === 'pack' ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Guardar envases
461461
</button>
462462
)}
463463
</div>
@@ -467,8 +467,8 @@ export default function FichaTab({ item, accountId, onItemChanged }: FichaTabPro
467467
de lógica nueva permitida en esta fase. La comparación "Dentro/Fuera
468468
del objetivo" sigue viviendo en Economía. */}
469469
<div>
470-
<h3 className="text-xs font-medium uppercase tracking-wide text-stone-400 mb-3">Objetivo de food cost</h3>
471-
<label className="block text-xs font-medium text-stone-500 mb-1.5">Target FC (%)</label>
470+
<h3 className="text-xs font-medium uppercase tracking-wide text-stone-400 mb-3">Objetivo de coste de producto</h3>
471+
<label className="block text-xs font-medium text-stone-500 mb-1.5">Objetivo de coste (%)</label>
472472
<input
473473
type="text" inputMode="decimal" value={targetFcVal} onChange={(e) => setTargetFcVal(e.target.value)}
474474
placeholder="Sin definir"

src/modules/kitchen/components/RecipeEscandalloTab.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,7 +1860,7 @@ export default function RecipeEscandalloTab({
18601860
emptyHint: 'Sin sub-recetas.',
18611861
})}
18621862
{Section({
1863-
title: 'Packaging',
1863+
title: 'Envases',
18641864
icon: <ShoppingBag className="w-3.5 h-3.5" />,
18651865
kind: 'packaging',
18661866
sectionLines: packagingLines,
@@ -2134,7 +2134,7 @@ export default function RecipeEscandalloTab({
21342134
Coste en vivo
21352135
</div>
21362136

2137-
<div className="text-xs text-text-secondary">{packagingCost > 0 ? 'Plate cost' : 'Coste total'}</div>
2137+
<div className="text-xs text-text-secondary">{packagingCost > 0 ? 'Coste del plato' : 'Coste total'}</div>
21382138
<div
21392139
className={
21402140
'font-mono font-medium text-text-primary leading-tight text-[34px] origin-left transition-all duration-slow ' +
@@ -2166,7 +2166,7 @@ export default function RecipeEscandalloTab({
21662166
<span className="font-mono text-text-primary">{formatEur(foodCost * prodFactor)}</span>
21672167
</div>
21682168
<div className="flex items-center justify-between text-[12px]">
2169-
<span className="text-text-secondary">Packaging</span>
2169+
<span className="text-text-secondary">Envases</span>
21702170
<span className="font-mono text-text-primary">{formatEur(packagingCost * prodFactor)}</span>
21712171
</div>
21722172
</div>
@@ -2190,15 +2190,15 @@ export default function RecipeEscandalloTab({
21902190
que eso signifique "no está en carta" — en ese caso, aviso
21912191
distinto, sin el CTA de "Añadir a carta" (ya está añadido). */}
21922192
{econLoading || usedByItems === null ? (
2193-
<div className="text-[11px] text-text-secondary">Calculando food cost</div>
2193+
<div className="text-[11px] text-text-secondary">Calculando coste de producto</div>
21942194
) : usedByItems.length === 0 ? (
21952195
<div>
21962196
<div className="text-[11px] font-medium tracking-wide text-text-secondary uppercase mb-2">
2197-
Food cost
2197+
Coste de producto
21982198
</div>
21992199
<div className="flex items-start gap-1.5 text-[11px] text-text-secondary leading-relaxed mb-2.5">
22002200
<AlertTriangle className="w-3.5 h-3.5 mt-px flex-shrink-0 text-warning" />
2201-
<span>Este plato aún no está en ninguna carta. Añádelo para ver su food cost y margen.</span>
2201+
<span>Este plato aún no está en ninguna carta. Añádelo para ver su coste de producto y margen.</span>
22022202
</div>
22032203
<button
22042204
type="button"
@@ -2212,9 +2212,9 @@ export default function RecipeEscandalloTab({
22122212
) : economics.length === 0 ? (
22132213
<div>
22142214
<div className="text-[11px] font-medium tracking-wide text-text-secondary uppercase mb-2">
2215-
Food cost
2215+
Coste de producto
22162216
</div>
2217-
<p className="text-[11px] text-text-secondary">Sin datos de food cost por canal todavía.</p>
2217+
<p className="text-[11px] text-text-secondary">Sin datos de coste de producto por canal todavía.</p>
22182218
</div>
22192219
) : (
22202220
<div className="flex flex-col gap-3">

src/modules/orders/components/OrderCard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import { useState } from 'react'
2727
import { ChefHat, Check, Printer, Bike, Phone, ChevronDown, ChevronUp, RefreshCw, AlertTriangle, ShoppingBag } from 'lucide-react'
2828
import { timeLevel, channelLabel, ticketCode } from '@/modules/kds/kdsUtils'
29+
import { allergenLabel, type AllergenCode } from '@/modules/kitchen/lib/allergens'
2930
import { passCode } from '../lib/passCode'
3031
import ChannelBadge from './ChannelBadge'
3132
import TicketPreviewModal from './TicketPreviewModal'
@@ -214,7 +215,9 @@ function LineRow({
214215
<div className="mt-2 ml-[58px] flex items-center gap-1.5 flex-wrap">
215216
<span className="text-[10.5px] font-extrabold uppercase tracking-wide text-text-secondary">Alérgenos</span>
216217
{line.allergens.map(a => (
217-
<span key={a} className="text-[12px] font-bold px-2 py-0.5 rounded-md bg-warning-bg text-warning border border-warning/30">{a}</span>
218+
<span key={a} className="text-[12px] font-bold px-2 py-0.5 rounded-md bg-warning-bg text-warning border border-warning/30">
219+
{allergenLabel(a as AllergenCode)}
220+
</span>
218221
))}
219222
</div>
220223
)}

src/pages/VentasDashboardPage.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,24 @@ export default function VentasDashboardPage() {
213213
}
214214
}, [activeAccountId, accountsLoading, period, locationId, ownership, channel, brandId])
215215

216+
// Auditoría externa (2.6): un canal "Desconocido" con net negativo (ajustes/
217+
// devoluciones sin canal atribuido) pintaba una barra negativa sin sentido.
218+
// Se agrupan los residuales (net<=0, o sin nombre reconocible) en "Otros" en
219+
// vez de mostrarlos sueltos -- mismo total, sin la barra rota.
220+
const byChannelGrouped = useMemo(() => {
221+
const rows = data?.by_channel ?? []
222+
const positive = rows.filter((c) => c.net > 0 && c.name && c.name.toLowerCase() !== 'desconocido')
223+
const residual = rows.filter((c) => !(c.net > 0 && c.name && c.name.toLowerCase() !== 'desconocido'))
224+
if (residual.length === 0) return positive
225+
const otrosNet = residual.reduce((sum, c) => sum + c.net, 0)
226+
const otrosOrders = residual.reduce((sum, c) => sum + c.orders, 0)
227+
return otrosNet !== 0 || otrosOrders !== 0
228+
? [...positive, { name: 'Otros', net: otrosNet, orders: otrosOrders }]
229+
: positive
230+
}, [data])
216231
const channelMax = useMemo(
217-
() => Math.max(1, ...(data?.by_channel ?? []).map((c) => c.net)),
218-
[data]
232+
() => Math.max(1, ...byChannelGrouped.map((c) => c.net)),
233+
[byChannelGrouped]
219234
)
220235
const locationMax = useMemo(
221236
() => Math.max(1, ...(data?.by_location ?? []).map((l) => l.net)),
@@ -354,13 +369,13 @@ export default function VentasDashboardPage() {
354369
<div className="rounded-xl bg-white border border-stone-200 p-4">
355370
<div className="text-sm font-medium text-stone-800 mb-2.5">Ventas por canal</div>
356371
<div className="flex flex-col gap-2.5">
357-
{data.by_channel.map((c) => (
372+
{byChannelGrouped.map((c) => (
358373
<div key={c.name}>
359374
<div className="flex justify-between text-xs mb-1">
360375
<span className="capitalize">{c.name}</span>
361376
<span className="text-stone-500 tabular-nums">{eur(c.net)} · {c.orders} ped.</span>
362377
</div>
363-
<Bar pct={(c.net / channelMax) * 100} color={TERRA} />
378+
<Bar pct={Math.max(0, (c.net / channelMax) * 100)} color={TERRA} />
364379
</div>
365380
))}
366381
</div>

0 commit comments

Comments
 (0)