From ac5896b1fc25602a2d1407a49e6e37881ceae25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Thu, 17 Sep 2026 19:26:02 +0000 Subject: [PATCH] Oleada UX 3: POS (repetir ultima venta, aviso de paquetes, atajos, caja desde POS), Ventas (cancelacion auditada con reversion de monedero y stock, detalle inline, presets de fecha), Cortes (historial comparativo y export) --- frontend/src/lib/badges.ts | 1 + frontend/src/lib/dateRange.ts | 87 ++++ frontend/src/pages/Cortes.tsx | 158 +++++++- frontend/src/pages/Inventario.tsx | 3 +- frontend/src/pages/Pos.tsx | 304 ++++++++++++-- frontend/src/pages/Ventas.tsx | 277 ++++++++++++- frontend/src/services/odoo.ts | 60 ++- .../skeen_inventario/models/inventario.py | 3 +- odoo-addons/skeen_ventas/models/venta.py | 121 ++++++ .../skeen_whatsapp/controllers/frontend.py | 371 +++++++++++++++++- 10 files changed, 1328 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/dateRange.ts diff --git a/frontend/src/lib/badges.ts b/frontend/src/lib/badges.ts index 313cab4..d02a06b 100644 --- a/frontend/src/lib/badges.ts +++ b/frontend/src/lib/badges.ts @@ -32,6 +32,7 @@ export const badgeForSaleState = (state: string): BadgeVariant => { case 'paid': return 'paid'; case 'cancel': + case 'cancelled': return 'cancelled'; default: return 'pending'; diff --git a/frontend/src/lib/dateRange.ts b/frontend/src/lib/dateRange.ts new file mode 100644 index 0000000..68e99a2 --- /dev/null +++ b/frontend/src/lib/dateRange.ts @@ -0,0 +1,87 @@ +/** + * Presets de rango de fecha compartidos (Ventas, y reutilizables por otras + * vistas). El rango elegido se persiste en localStorage para que al volver a + * la pantalla se restaure, igual que los presets de la agenda. + */ + +export type DatePreset = 'hoy' | 'ayer' | 'semana' | 'mes' | 'mes_pasado' | 'custom'; + +export interface DateRange { + start: string; + end: string; +} + +export const DATE_PRESETS: { value: DatePreset; label: string }[] = [ + { value: 'hoy', label: 'Hoy' }, + { value: 'ayer', label: 'Ayer' }, + { value: 'semana', label: 'Esta semana' }, + { value: 'mes', label: 'Este mes' }, + { value: 'mes_pasado', label: 'Mes pasado' }, + { value: 'custom', label: 'Rango personalizado' }, +]; + +export const isoDate = (d: Date): string => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + +export const rangeForPreset = (preset: DatePreset, today = new Date()): DateRange => { + const t = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + switch (preset) { + case 'hoy': + return { start: isoDate(t), end: isoDate(t) }; + case 'ayer': { + const y = new Date(t); + y.setDate(y.getDate() - 1); + return { start: isoDate(y), end: isoDate(y) }; + } + case 'semana': { + // Semana en curso, de lunes a domingo + const dow = (t.getDay() + 6) % 7; // 0 = lunes + const monday = new Date(t); + monday.setDate(t.getDate() - dow); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + return { start: isoDate(monday), end: isoDate(sunday) }; + } + case 'mes': { + const first = new Date(t.getFullYear(), t.getMonth(), 1); + const last = new Date(t.getFullYear(), t.getMonth() + 1, 0); + return { start: isoDate(first), end: isoDate(last) }; + } + case 'mes_pasado': { + const first = new Date(t.getFullYear(), t.getMonth() - 1, 1); + const last = new Date(t.getFullYear(), t.getMonth(), 0); + return { start: isoDate(first), end: isoDate(last) }; + } + default: + return { start: isoDate(t), end: isoDate(t) }; + } +}; + +export interface DateFilterPrefs extends DateRange { + preset: DatePreset; +} + +export const readDateFilterPrefs = (key: string): DateFilterPrefs | null => { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.start !== 'string' || typeof parsed.end !== 'string') return null; + const preset = (DATE_PRESETS.some((p) => p.value === parsed.preset) ? parsed.preset : 'custom') as DatePreset; + return { preset, start: parsed.start, end: parsed.end }; + } catch { + return null; + } +}; + +export const writeDateFilterPrefs = (key: string, prefs: DateFilterPrefs): void => { + try { + localStorage.setItem(key, JSON.stringify(prefs)); + } catch { + // localStorage no disponible: se mantiene solo en memoria + } +}; diff --git a/frontend/src/pages/Cortes.tsx b/frontend/src/pages/Cortes.tsx index ee25e66..498703e 100644 --- a/frontend/src/pages/Cortes.tsx +++ b/frontend/src/pages/Cortes.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; -import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, @@ -18,10 +18,13 @@ import { } from '../components/ui'; import { CortePrint } from '../components/CortePrint'; import { odooApi, type CashClosing } from '../services/odoo'; +import { exportToExcel } from '../lib/exporter'; const money = (v: number) => '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const MES_ACTUAL = new Date().toISOString().slice(0, 7); + const PASOS = [ { key: 'cash', titulo: 'Efectivo' }, { key: 'card', titulo: 'Tarjeta' }, @@ -45,6 +48,77 @@ const Cortes: FC = () => { const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]); const [openingCash, setOpeningCash] = useState(''); const [submitting, setSubmitting] = useState(false); + // Mes para el resumen exportable + const [mes, setMes] = useState(MES_ACTUAL); + + // --- Historial comparativo: últimos cortes CERRADOS y diferencias por usuario --- + const HISTORIAL_N = 14; + const cerrados = closings.filter((c) => c.state === 'closed'); + const ultimosCerrados = cerrados.slice(0, HISTORIAL_N); + const porUsuario = (() => { + const map = new Map(); + for (const c of ultimosCerrados) { + const usuario = c.user || 'Usuario no disponible'; + const row = map.get(usuario) || { usuario, cortes: 0, faltantes: 0, sobrantes: 0, neto: 0, ultima: c.date }; + row.cortes += 1; + row.neto += c.difference || 0; + if ((c.difference || 0) < 0) row.faltantes += c.difference || 0; + else row.sobrantes += c.difference || 0; + if (c.date > row.ultima) row.ultima = c.date; + map.set(usuario, row); + } + return [...map.values()].sort((a, b) => a.neto - b.neto); + })(); + + const cortesDelMes = closings.filter((c) => (c.date || '').startsWith(mes)); + + const exportarMes = async () => { + if (cortesDelMes.length === 0) { + toast.error('No hay cortes en el mes seleccionado'); + return; + } + try { + await exportToExcel({ + filename: `cortes-skeen-${mes}`, + sheetName: 'Cortes', + title: 'Cortes de caja SKEEN', + subtitle: `Mes ${mes} · ${cortesDelMes.length} cortes`, + columns: [ + { header: 'Referencia', key: 'name' }, + { header: 'Fecha', key: 'date', format: 'date' }, + { header: 'Usuario', key: 'user' }, + { header: 'Estado', key: 'estado' }, + { header: 'Apertura', key: 'opening_cash', format: 'currency' }, + { header: 'Efectivo', key: 'total_cash', format: 'currency' }, + { header: 'Tarjeta', key: 'total_card', format: 'currency' }, + { header: 'Transferencia', key: 'total_transfer', format: 'currency' }, + { header: 'Otros', key: 'total_other', format: 'currency' }, + { header: 'Total ventas', key: 'total_sales', format: 'currency' }, + { header: 'Efectivo contado', key: 'counted_cash', format: 'currency' }, + { header: 'Diferencia', key: 'difference', format: 'currency' }, + { header: 'Notas', key: 'notes' }, + ], + rows: cortesDelMes.map((c) => ({ + ...c, + user: c.user || 'Usuario no disponible', + estado: c.state === 'closed' ? 'Cerrado' : 'Abierto', + })), + totals: { + name: '', + total_cash: cortesDelMes.reduce((a, c) => a + (c.total_cash || 0), 0), + total_card: cortesDelMes.reduce((a, c) => a + (c.total_card || 0), 0), + total_transfer: cortesDelMes.reduce((a, c) => a + (c.total_transfer || 0), 0), + total_other: cortesDelMes.reduce((a, c) => a + (c.total_other || 0), 0), + total_sales: cortesDelMes.reduce((a, c) => a + (c.total_sales || 0), 0), + difference: cortesDelMes.reduce((a, c) => a + (c.difference || 0), 0), + }, + }); + toast.success(`Resumen de ${mes} descargado (${cortesDelMes.length} cortes)`); + } catch (err) { + toast.error('Error al exportar'); + console.error(err); + } + }; const load = async () => { try { @@ -242,6 +316,17 @@ const Cortes: FC = () => { return ( + setMes(e.target.value || MES_ACTUAL)} + aria-label="Mes a exportar" + className="sm:max-w-[160px]" + /> + ) : altaRapida ? (
@@ -564,22 +731,45 @@ const Pos: FC = () => { className="w-full mt-1 !py-3 text-base" onClick={abrirCobro} disabled={lines.length === 0 || !paciente} + title="Cobrar (F9)" > Cobrar {fmtMoney(total)} +

+

); return ( - {!cajaAbierta && ( -
+ {!cajaAbierta ? ( +
Sin corte de caja abierto hoy - Abrir caja + + Ir a Cortes de caja +
+ ) : ( +
+ + Caja abierta + + Cerrar caja
)} @@ -587,10 +777,10 @@ const Pos: FC = () => { {/* Catálogo */} -
+
setSearch(e.target.value)} className="pl-10" @@ -702,24 +892,33 @@ const Pos: FC = () => { - {/* Ticket desktop: panel sticky full-height */} + {/* Ticket desktop: panel sticky full-height. El foco en este panel + habilita +/− para la cantidad de la última línea. */} -
-
-

Ticket

- {lines.length > 0 && ( - - )} +
setTicketFocused(true)} + onBlurCapture={() => setTicketFocused(false)} + title="Atajos: + y − ajustan la cantidad de la última línea cuando el ticket tiene el foco" + > +
+
+

Ticket

+ {lines.length > 0 && ( + + )} +
+ {pacienteBlock} +
+
+ {linesBlock} +
+
+ {totalsBlock}
- {pacienteBlock} -
-
- {linesBlock} -
-
- {totalsBlock}
@@ -737,13 +936,70 @@ const Pos: FC = () => { {/* Ticket móvil */} setTicketMovil(false)} title="Ticket" maxWidth="md"> -
+
setTicketFocused(true)} + onBlurCapture={() => setTicketFocused(false)} + > {pacienteBlock}
{linesBlock}
{totalsBlock}
+ {/* Ayuda discreta de atajos de teclado */} + setShortcutsOpen(false)} + title="Atajos de teclado" + maxWidth="sm" + footer={} + > +
    + {[ + { k: 'F2', d: 'Ir al buscador de servicios/artículos' }, + { k: 'F9', d: 'Cobrar (abre el modal de cobro si hay líneas)' }, + { k: '+ / −', d: 'Cantidad de la última línea (con el ticket enfocado)' }, + { k: 'Esc', d: 'Cerrar el modal abierto' }, + ].map((s) => ( +
  • + {s.k} + {s.d} +
  • + ))} +
+
+ + {/* Abrir caja sin salir del POS */} + setCajaModalOpen(false)} + title="Abrir caja" + maxWidth="sm" + footer={ + <> + + + + } + > +
+

+ Se abre el corte de caja de hoy sin perder el ticket. El cierre guiado (conteo por método) + se hace en Cortes de caja. +

+ setCajaFondo(e.target.value)} + /> +
+
+ {/* Modal de cobro */} { const { openPatient } = usePatientDrawer(); + const { user } = useAuth(); + const puedeCancelar = ROLES_CANCELAN.includes(user?.role || ''); const [sales, setSales] = useState([]); const [services, setServices] = useState([]); const [patients, setPatients] = useState([]); @@ -60,6 +68,12 @@ const Ventas: FC = () => { const [refunding, setRefunding] = useState(false); const [refundAmount, setRefundAmount] = useState(''); const [refundReason, setRefundReason] = useState(''); + // Cancelación con nota de crédito (admin/gerencia) + const [cancelOpen, setCancelOpen] = useState(null); + const [cancelReason, setCancelReason] = useState(''); + const [cancelling, setCancelling] = useState(false); + // Detalle expandido en línea (varias ventas a la vez, sin modales) + const [expandedId, setExpandedId] = useState(null); // Reimprimir ticket de una venta ya registrada const [printSale, setPrintSale] = useState(null); const [search, setSearch] = useState(''); @@ -69,11 +83,36 @@ const Ventas: FC = () => { const [totalPages, setTotalPages] = useState(0); const [patientSearch, setPatientSearch] = useState(''); + // ---- Rango de fechas recordado (presets) ---- + const saved = readDateFilterPrefs(FILTERS_KEY); + const [preset, setPreset] = useState(saved?.preset || 'mes'); + const [start, setStart] = useState(saved?.start || rangeForPreset('mes').start); + const [end, setEnd] = useState(saved?.end || rangeForPreset('mes').end); + + const aplicarPreset = (p: DatePreset) => { + setPreset(p); + if (p !== 'custom') { + const r = rangeForPreset(p); + setStart(r.start); + setEnd(r.end); + writeDateFilterPrefs(FILTERS_KEY, { preset: p, ...r }); + } else { + writeDateFilterPrefs(FILTERS_KEY, { preset: p, start, end }); + } + }; + + useEffect(() => { + writeDateFilterPrefs(FILTERS_KEY, { preset, start, end }); + }, [preset, start, end]); + const loadSales = useCallback(async () => { try { setLoading(true); setError(null); - const res = await odooApi.getSales({ search, page, page_size: pageSize }); + const res = await odooApi.getSales({ + search, page, page_size: pageSize, + ...(preset !== 'custom' || (start && end) ? { date_from: start, date_to: end } : {}), + }); if (res.status === 'success') { setSales(res.sales); setTotal(res.total ?? 0); @@ -86,7 +125,7 @@ const Ventas: FC = () => { } finally { setLoading(false); } - }, [search, page, pageSize]); + }, [search, page, pageSize, start, end, preset]); const loadRefs = useCallback(async () => { try { @@ -251,6 +290,42 @@ const Ventas: FC = () => { } }; + // ---- Cancelación con nota de crédito ---- + const openCancel = (sale: Sale) => { + setCancelOpen(sale); + setCancelReason(''); + }; + + const submitCancel = async () => { + if (!cancelOpen) return; + if (!cancelReason.trim()) { + toast.error('El motivo de cancelación es obligatorio'); + return; + } + try { + setCancelling(true); + const res = await odooApi.cancelSale(cancelOpen.id, { reason: cancelReason.trim() }); + const r = res.result; + const partes = [ + r?.points_reversed ? `${r.points_reversed} pts revertidos` : '', + r?.points_restored ? `${r.points_restored} pts reintegrados` : '', + r?.stock_moves ? `${r.stock_moves} artículos devueltos a inventario` : '', + ].filter(Boolean); + toast.success(`Venta ${cancelOpen.name} cancelada${partes.length ? ` · ${partes.join(' · ')}` : ''}`); + setCancelOpen(null); + setCancelReason(''); + await loadSales(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al cancelar la venta'); + console.error(err); + } finally { + setCancelling(false); + } + }; + + const toggleExpand = (id: number) => setExpandedId((prev) => (prev === id ? null : id)); + const exportSales = async () => { try { await exportToExcel({ @@ -319,7 +394,7 @@ const Ventas: FC = () => { -
+
{
+ {/* Presets de fecha (se recuerdan al volver) */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+ { setStart(e.target.value); setPreset('custom'); }} + className="sm:max-w-[160px]" + /> + { setEnd(e.target.value); setPreset('custom'); }} + className="sm:max-w-[160px]" + /> +
+
+ {error &&

{error}

} {loading ? ( @@ -349,6 +458,7 @@ const Ventas: FC = () => { + @@ -358,48 +468,129 @@ const Ventas: FC = () => { - {sales.map((s) => ( - - + {sales.map((s) => { + const cancelada = s.state === 'cancelled'; + const abierta = expandedId === s.id; + return ( + + toggleExpand(s.id)} + title="Click para ver las líneas de la venta" + > + + - + - ))} + {abierta && ( + + + + )} + + ); + })}
Referencia Paciente Total
{s.name}
+ {abierta ? : } + {s.name} ${s.total}${s.total} ${s.amount_paid} -
- {s.state} +
+ {cancelada ? 'Cancelada' : s.state} {s.refunded && Devuelta}
+ {cancelada && s.cancel_reason && ( +

+ Motivo: {s.cancel_reason} + {s.cancelled_by ? ` · ${s.cancelled_by}` : ''} +

+ )}
- - {s.amount_due > 0 && ( - )} - {s.amount_paid > 0 && s.state !== 'cancelled' && ( - )} + {puedeCancelar && !cancelada && ( + + )}
+ +
+

+ Líneas de {s.name} · {s.date} +

+ {s.lines.length === 0 ? ( +

Sin líneas registradas.

+ ) : ( + + + + + + + + + + + {s.lines.map((l, i) => ( + + + + + + + ))} + +
ConceptoCantPrecioSubtotal
+ {l.service || l.item || l.description} + {l.is_prescribed && l.prescribed_by && ( + · recetado por {l.prescribed_by} + )} + {l.quantity}${l.price_unit}${l.subtotal}
+ )} +
+ Subtotal ${s.subtotal} + Descuento ${s.discount} + Total ${s.total} + Pagado ${s.amount_paid} + Por cobrar ${s.amount_due} +
+ {cancelada && ( +

+ Cancelada{s.cancelled_at ? ` el ${s.cancelled_at}` : ''} + {s.cancelled_by ? ` por ${s.cancelled_by}` : ''} + {s.cancel_reason ? ` · Motivo: ${s.cancel_reason}` : ''} +

+ )} + {s.notes &&

Notas: {s.notes}

} +
+
@@ -426,18 +617,21 @@ const Ventas: FC = () => { label: 'Estado', value: (
- {s.state} + {s.state === 'cancelled' ? 'Cancelada' : s.state} {s.refunded && Devuelta}
), }, + ...(s.state === 'cancelled' && s.cancel_reason + ? [{ label: 'Motivo', value: s.cancel_reason }] + : []), ]} actions={
- {s.amount_due > 0 && ( + {s.amount_due > 0 && s.state !== 'cancelled' && ( @@ -447,6 +641,11 @@ const Ventas: FC = () => { )} + {puedeCancelar && s.state !== 'cancelled' && ( + + )}
} /> @@ -675,6 +874,44 @@ const Ventas: FC = () => { )}
+ setCancelOpen(null)} + title={`Cancelar venta ${cancelOpen?.name || ''}`} + maxWidth="sm" + footer={ + <> + + + + } + > + {cancelOpen && ( +
+

+ Paciente: {cancelOpen.patient} +

+

+ Total: ${cancelOpen.total} + {' · '} + Pagado: ${cancelOpen.amount_paid} +

+
+ Se emite la nota de crédito: la venta se marca como cancelada y deja de contar en + ingresos, comisiones y reportes. Se revierten los puntos del monedero otorgados por + esta venta (y se reintegran los canjeados) y los artículos vuelven al inventario. + La venta no se borra: queda en el historial con su motivo. +
+