Files
SKEEN-Proyecto/frontend/src/components/inventario/AlertasPanel.tsx
Consultoría Alcaraz Salazar 2f7cbe237e white-label: brand de la clinica por configuracion (fase 1 de instancia sin branding SKEEN)
- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter
  skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con
  defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin
  con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador'
- frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el
  contexto de auth (fetch al boot + cache localStorage + document.title)
- Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN:
  Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export),
  Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos),
  useClinicaPrint (fallback nombre), Login hace GET publico al montar
- Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin)
  para editar nombre/pie/logos con preview
- no renombrado (invisible al usuario): rutas API /skeen/*, claves
  localStorage, nombres de modulos, parametros skeen.*

Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido
ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
2026-10-08 00:09:00 +00:00

220 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { CalendarX, ShoppingCart, ArrowUpFromLine } from 'lucide-react';
import { Card, Button, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import MovimientoModal from './MovimientoModal';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo';
import { useAuth } from '../../lib/auth';
import type { BadgeVariant } from '../ui/Badge';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const caducidadBadge = (row: CaducidadRow): { variant: BadgeVariant; label: string } => {
if (row.vencido) return { variant: 'danger', label: `Vencido hace ${Math.abs(row.dias_restantes)} días` };
if (row.dias_restantes <= 30) return { variant: 'warning', label: `${row.dias_restantes} días` };
return { variant: 'info', label: `${row.dias_restantes} días` };
};
const AlertasPanel: FC = () => {
const { brand } = useAuth();
const [caducidades, setCaducidades] = useState<CaducidadRow[]>([]);
const [sugerencia, setSugerencia] = useState<SugerenciaRow[]>([]);
const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 });
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
// Acción directa de compra/baja desde la alerta (item precargado)
const [movModal, setMovModal] = useState<{ tipo: 'compra' | 'baja'; itemId: number; qty: number } | null>(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getInventarioAlertas();
if (res.status === 'success') {
setCaducidades(res.caducidades);
setSugerencia(res.sugerencia_compra);
setTotalSugerencia(res.sugerencia_total);
}
} catch (err) {
toast.error('Error al cargar alertas');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `sugerencia-compra-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Sugerencia de compra',
title: `Sugerencia de compra — inventario ${brand.nombre}`,
subtitle: `${totalSugerencia.items} artículos bajo mínimo · costo estimado ${fmtMoney(totalSugerencia.costo_total)}`,
columns: [
{ header: 'Artículo', key: 'name' },
{ header: 'Categoría', key: 'category' },
{ header: 'Unidad', key: 'unit' },
{ header: 'Existencia', key: 'qty', format: 'number' },
{ header: 'Mínimo', key: 'qty_min', format: 'number' },
{ header: 'Óptimo', key: 'qty_optimal', format: 'number' },
{ header: 'Sugerido', key: 'sugerido', format: 'number' },
{ header: 'Costo estimado', key: 'costo_estimado', format: 'currency' },
],
rows: sugerencia.map((s) => ({ ...s })),
totals: { name: '', costo_estimado: totalSugerencia.costo_total },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4">
{/* Caducidades */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<CalendarX size={20} className="mr-2" /> Caducidades
</h3>
<p className="text-xs text-theme-muted mb-4">Artículos con fecha de caducidad registrada, ordenados por urgencia.</p>
{loading ? (
<Skeleton count={4} className="h-10 w-full" />
) : caducidades.length === 0 ? (
<EmptyState title="Sin caducidades" subtitle="Ningún artículo tiene fecha de caducidad registrada." icon={<CalendarX size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Categoría</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-left px-4 py-3">Caduca</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="text-right px-4 py-3">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{caducidades.map((c) => {
const b = caducidadBadge(c);
return (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.category || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.qty} {c.unit}</td>
<td className="px-4 py-3 text-theme-heading">{c.expiry_date}</td>
<td className="px-4 py-3"><Badge variant={b.variant}>{b.label}</Badge></td>
<td className="px-4 py-3 text-right">
{c.vencido && c.qty > 0 ? (
<Button
size="sm"
variant="outline"
onClick={() => setMovModal({ tipo: 'baja', itemId: c.id, qty: c.qty })}
title="Dar de baja el artículo caducado"
>
<ArrowUpFromLine size={13} className="mr-1.5" />
Dar de baja
</Button>
) : (
<span className="text-xs text-theme-muted">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Sugerencia de compra */}
<Card>
<Card.Body>
<div className="flex items-start justify-between gap-3 mb-1">
<h3 className="font-heading text-xl text-theme-heading flex items-center">
<ShoppingCart size={20} className="mr-2" /> Sugerencia de compra
</h3>
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-xs text-theme-muted mb-4">
Artículos bajo su stock mínimo (o sin existencia). Sugerido = óptimo − existencia (o 2× mínimo).
</p>
{loading ? (
<Skeleton count={6} className="h-10 w-full" />
) : sugerencia.length === 0 ? (
<EmptyState title="Sin faltantes" subtitle="Todos los artículos están por encima de su mínimo." icon={<ShoppingCart size={28} />} />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
<th className="text-right px-4 py-3">Sugerido</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Costo est.</th>
<th className="text-right px-4 py-3">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{sugerencia.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{s.name}</td>
<td className="px-4 py-3 text-right text-theme-muted">{s.qty} {s.unit}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{s.qty_min || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{s.qty_optimal || '-'}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">+{s.sugerido} {s.unit}</td>
<td className="px-4 py-3 text-right text-theme-heading hidden md:table-cell">{fmtMoney(s.costo_estimado)}</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="outline"
onClick={() => setMovModal({ tipo: 'compra', itemId: s.id, qty: s.sugerido })}
title="Registrar la compra sugerida de este artículo"
>
<ShoppingCart size={13} className="mr-1.5" />
Registrar compra
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-3 pt-3 border-t border-theme-border">
<p className="text-sm text-theme-muted">
Total estimado: <span className="font-semibold text-theme-heading">{fmtMoney(totalSugerencia.costo_total)}</span>
{' '}({totalSugerencia.items} artículos)
</p>
</div>
</>
)}
</Card.Body>
</Card>
<MovimientoModal
isOpen={!!movModal}
tipo={movModal?.tipo || 'compra'}
initialItemId={movModal?.itemId ?? null}
initialQty={movModal?.qty ?? null}
onClose={() => setMovModal(null)}
onSaved={() => { load(); }}
/>
</div>
);
};
export default AlertasPanel;