POS vista lista, modulo Tickets para gerencia con cancelacion auditada, y saldo a favor (recargas de anticipo desde expediente y agenda, canje en POS)

This commit is contained in:
2026-09-18 04:25:33 +00:00
parent cebf8d663d
commit d75411f0d9
19 changed files with 1278 additions and 30 deletions

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
RotateCcw, Keyboard, Lock, TrendingUp,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
@@ -58,6 +58,28 @@ const Pos: FC = () => {
const [categoria, setCategoria] = useState('');
const [soloFavoritos, setSoloFavoritos] = useState(false);
const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual');
// Vista del catálogo: cuadrícula (tarjetas) o lista compacta, para catálogos
// largos. Se recuerda entre sesiones como el resto de preferencias.
const [vista, setVista] = useState<'grid' | 'lista'>(() => {
try {
return localStorage.getItem('skeen_pos_vista') === 'lista' ? 'lista' : 'grid';
} catch {
return 'grid';
}
});
const cambiarVista = (v: 'grid' | 'lista') => {
setVista(v);
try {
localStorage.setItem('skeen_pos_vista', v);
} catch {
// localStorage no disponible: queda solo en memoria
}
};
// Orden de la lista: por nombre o por precio (click en el encabezado)
const [ordenLista, setOrdenLista] = useState<{ campo: 'nombre' | 'precio'; dir: 1 | -1 }>({ campo: 'nombre', dir: 1 });
const alternarOrdenLista = (campo: 'nombre' | 'precio') =>
setOrdenLista((prev) => (prev.campo === campo ? { campo, dir: prev.dir === 1 ? -1 : 1 } : { campo, dir: 1 }));
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
// Ticket
@@ -247,6 +269,29 @@ const Pos: FC = () => {
// Los artículos ya vienen filtrados del server-side (search)
const visiblesItems = invItems;
// Lista ordenable por nombre o precio (click en el encabezado)
const serviciosLista = useMemo(() => {
const arr = [...visibles];
arr.sort((a, b) => {
const v = ordenLista.campo === 'nombre'
? a.name.localeCompare(b.name, 'es')
: (a.price || 0) - (b.price || 0);
return v * ordenLista.dir;
});
return arr;
}, [visibles, ordenLista]);
const itemsLista = useMemo(() => {
const arr = [...visiblesItems];
arr.sort((a, b) => {
const v = ordenLista.campo === 'nombre'
? a.name.localeCompare(b.name, 'es')
: (a.price || 0) - (b.price || 0);
return v * ordenLista.dir;
});
return arr;
}, [visiblesItems, ordenLista]);
// ---- Ticket ----
const addLine = (service: Service) => {
setLines((prev) => {
@@ -871,8 +916,103 @@ const Pos: FC = () => {
</div>
)}
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
<button
type="button"
onClick={() => cambiarVista('grid')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'grid' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de cuadrícula"
>
<LayoutGrid size={12} /> Cuadrícula
</button>
<button
type="button"
onClick={() => cambiarVista('lista')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'lista' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de lista (catálogos largos)"
>
<List size={12} /> Lista
</button>
</div>
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : vista === 'lista' ? (
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg sticky top-0 z-10 text-theme-muted text-[11px] uppercase">
<tr>
<th
className="text-left px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('nombre')}
title="Ordenar por nombre"
>
{catMode === 'servicios' ? 'Servicio' : 'Artículo'}
{ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="text-left px-3 py-2 hidden sm:table-cell">Categoría</th>
{catMode === 'servicios' && <th className="text-left px-3 py-2 hidden md:table-cell">Duración</th>}
<th
className="text-right px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('precio')}
title="Ordenar por precio"
>
Precio
{ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{catMode === 'servicios' ? (
serviciosLista.length === 0 ? (
<tr><td colSpan={5} className="px-3 py-6 text-center text-theme-muted">Sin servicios con estos filtros.</td></tr>
) : serviciosLista.map((sv) => (
<tr key={sv.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{sv.name}</p>
{sv.code && <p className="text-[11px] text-theme-muted">{sv.code}</p>}
</td>
<td className="px-3 py-2 text-theme-muted capitalize hidden sm:table-cell">{sv.category || '—'}</td>
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
) : (
itemsLista.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-6 text-center text-theme-muted">Sin artículos con esta búsqueda.</td></tr>
) : itemsLista.map((it) => (
<tr key={it.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{it.name}</p>
<p className={`text-[11px] ${it.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
{it.qty} {it.unit || 'pzas'} en existencia
</p>
</td>
<td className="px-3 py-2 text-theme-muted hidden sm:table-cell">{it.category || '—'}</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : catMode === 'articulos' ? (
visiblesItems.length === 0 ? (
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
@@ -1115,7 +1255,7 @@ const Pos: FC = () => {
/>
<span className="text-sm text-theme-heading flex items-center gap-1.5">
<Wallet size={15} className="text-theme-muted" />
Pagar con puntos ({puntosPaciente} pts disponibles)
Pagar con saldo a favor ({puntosPaciente} pts = ${puntosPaciente})
</span>
</label>
)}