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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user