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)
This commit is contained in:
@@ -32,6 +32,7 @@ export const badgeForSaleState = (state: string): BadgeVariant => {
|
|||||||
case 'paid':
|
case 'paid':
|
||||||
return 'paid';
|
return 'paid';
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
|
case 'cancelled':
|
||||||
return 'cancelled';
|
return 'cancelled';
|
||||||
default:
|
default:
|
||||||
return 'pending';
|
return 'pending';
|
||||||
|
|||||||
87
frontend/src/lib/dateRange.ts
Normal file
87
frontend/src/lib/dateRange.ts
Normal file
@@ -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<DateFilterPrefs>;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState } 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 Layout from '../components/Layout';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -18,10 +18,13 @@ import {
|
|||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { CortePrint } from '../components/CortePrint';
|
import { CortePrint } from '../components/CortePrint';
|
||||||
import { odooApi, type CashClosing } from '../services/odoo';
|
import { odooApi, type CashClosing } from '../services/odoo';
|
||||||
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
|
||||||
const money = (v: number) =>
|
const money = (v: number) =>
|
||||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const MES_ACTUAL = new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
const PASOS = [
|
const PASOS = [
|
||||||
{ key: 'cash', titulo: 'Efectivo' },
|
{ key: 'cash', titulo: 'Efectivo' },
|
||||||
{ key: 'card', titulo: 'Tarjeta' },
|
{ key: 'card', titulo: 'Tarjeta' },
|
||||||
@@ -45,6 +48,77 @@ const Cortes: FC = () => {
|
|||||||
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
|
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
const [openingCash, setOpeningCash] = useState('');
|
const [openingCash, setOpeningCash] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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<string, { usuario: string; cortes: number; faltantes: number; sobrantes: number; neto: number; ultima: string }>();
|
||||||
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -242,6 +316,17 @@ const Cortes: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
|
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
|
||||||
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
|
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
|
||||||
|
<Input
|
||||||
|
type="month"
|
||||||
|
value={mes}
|
||||||
|
onChange={(e) => setMes(e.target.value || MES_ACTUAL)}
|
||||||
|
aria-label="Mes a exportar"
|
||||||
|
className="sm:max-w-[160px]"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={exportarMes} disabled={cortesDelMes.length === 0}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Exportar resumen
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Abrir caja
|
Abrir caja
|
||||||
@@ -426,6 +511,77 @@ const Cortes: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Historial comparativo de diferencias (para detectar patrones por usuario) */}
|
||||||
|
<Card className="mt-4">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading">Historial comparativo de diferencias</h3>
|
||||||
|
<p className="text-xs text-theme-muted">Últimos {HISTORIAL_N} cortes cerrados. Negativo = faltante, positivo = sobrante.</p>
|
||||||
|
</div>
|
||||||
|
{ultimosCerrados.length > 0 && (
|
||||||
|
<span className="text-xs text-theme-muted">
|
||||||
|
Diferencia acumulada: <span className={`font-semibold ${differenceColor(ultimosCerrados.reduce((a, c) => a + (c.difference || 0), 0))}`}>
|
||||||
|
{money(ultimosCerrados.reduce((a, c) => a + (c.difference || 0), 0))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ultimosCerrados.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Aún no hay cortes cerrados.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="lg:col-span-2 overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Fecha</th>
|
||||||
|
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Usuario</th>
|
||||||
|
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Contado</th>
|
||||||
|
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Diferencia</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{ultimosCerrados.map((c) => (
|
||||||
|
<tr key={c.id} className="hover:bg-theme-bg">
|
||||||
|
<td className="p-2 text-sm text-theme-muted">{c.date}</td>
|
||||||
|
<td className="p-2 text-sm text-theme-muted">{c.user || 'Usuario no disponible'}</td>
|
||||||
|
<td className="p-2 text-sm text-right text-theme-heading">{money(c.counted_cash ?? 0)}</td>
|
||||||
|
<td className={`p-2 text-sm text-right font-semibold ${differenceColor(c.difference)}`}>
|
||||||
|
{c.difference > 0 ? '+' : ''}{money(c.difference)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Resumen por usuario</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{porUsuario.map((u) => (
|
||||||
|
<div key={u.usuario} className="rounded-xl border border-theme-border bg-theme-bg p-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-theme-heading truncate">{u.usuario}</span>
|
||||||
|
<span className={`text-sm font-semibold ${differenceColor(u.neto)}`}>
|
||||||
|
{u.neto > 0 ? '+' : ''}{money(u.neto)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-theme-muted">
|
||||||
|
{u.cortes} {u.cortes === 1 ? 'corte' : 'cortes'}
|
||||||
|
{u.faltantes !== 0 ? ` · faltantes ${money(u.faltantes)}` : ''}
|
||||||
|
{u.sobrantes !== 0 ? ` · sobrantes ${money(u.sobrantes)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<CortePrint corte={printCorte} onClose={() => setPrintCorte(null)} />
|
<CortePrint corte={printCorte} onClose={() => setPrintCorte(null)} />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const MOVE_META: Record<InventoryMoveType, { label: string; sign: string; cls: s
|
|||||||
venta: { label: 'Venta', sign: '−', cls: 'text-theme-muted' },
|
venta: { label: 'Venta', sign: '−', cls: 'text-theme-muted' },
|
||||||
baja: { label: 'Baja', sign: '−', cls: 'text-rose-600' },
|
baja: { label: 'Baja', sign: '−', cls: 'text-rose-600' },
|
||||||
ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' },
|
ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' },
|
||||||
|
cancelacion: { label: 'Cancelación venta', sign: '+', cls: 'text-emerald-700' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const KIND_OPTIONS = [
|
const KIND_OPTIONS = [
|
||||||
@@ -357,7 +358,7 @@ const Inventario: FC = () => {
|
|||||||
{moves.map((m) => (
|
{moves.map((m) => (
|
||||||
<div key={m.id} className="flex items-center justify-between p-3 bg-theme-bg rounded-xl text-sm">
|
<div key={m.id} className="flex items-center justify-between p-3 bg-theme-bg rounded-xl text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{m.type === 'compra' ? <ArrowDownToLine size={16} className="text-theme-heading" /> : <ArrowUpFromLine size={16} className="text-theme-muted" />}
|
{m.type === 'compra' || m.type === 'cancelacion' ? <ArrowDownToLine size={16} className="text-theme-heading" /> : <ArrowUpFromLine size={16} className="text-theme-muted" />}
|
||||||
<div>
|
<div>
|
||||||
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
|
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
|
||||||
<p className="text-xs text-theme-muted">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
|
<p className="text-xs text-theme-muted">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
||||||
|
RotateCcw, Keyboard, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor } from '../services/odoo';
|
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor } from '../services/odoo';
|
||||||
|
import type { PatientPackage } from '../services/odoo';
|
||||||
|
|
||||||
const fmtMoney = (n: number) =>
|
const fmtMoney = (n: number) =>
|
||||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||||
@@ -86,6 +88,19 @@ const Pos: FC = () => {
|
|||||||
|
|
||||||
// Corte de caja
|
// Corte de caja
|
||||||
const [cajaAbierta, setCajaAbierta] = useState(true);
|
const [cajaAbierta, setCajaAbierta] = useState(true);
|
||||||
|
const [cajaModalOpen, setCajaModalOpen] = useState(false);
|
||||||
|
const [cajaFondo, setCajaFondo] = useState('');
|
||||||
|
const [cajaAbriendo, setCajaAbriendo] = useState(false);
|
||||||
|
|
||||||
|
// Repetir última venta
|
||||||
|
const [lastSaleLoading, setLastSaleLoading] = useState(false);
|
||||||
|
// Paquetes del paciente con sesiones pendientes
|
||||||
|
const [paquetes, setPaquetes] = useState<PatientPackage[]>([]);
|
||||||
|
|
||||||
|
// Atajos de teclado
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
const [ticketFocused, setTicketFocused] = useState(false);
|
||||||
|
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Mobile
|
// Mobile
|
||||||
const [ticketMovil, setTicketMovil] = useState(false);
|
const [ticketMovil, setTicketMovil] = useState(false);
|
||||||
@@ -358,16 +373,139 @@ const Pos: FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Al seleccionar paciente: traer su adeudo para el badge de alerta en caja
|
// Al seleccionar paciente: traer su adeudo y sus paquetes con sesiones pendientes
|
||||||
const selectPaciente = (p: Patient) => {
|
const selectPaciente = (p: Patient) => {
|
||||||
setPaciente(p);
|
setPaciente(p);
|
||||||
setPacienteResults([]);
|
setPacienteResults([]);
|
||||||
setPacienteAdeudo(0);
|
setPacienteAdeudo(0);
|
||||||
|
setPaquetes([]);
|
||||||
odooApi.getPatientHistory(p.id)
|
odooApi.getPatientHistory(p.id)
|
||||||
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
|
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
odooApi.getPatientPackages(p.id)
|
||||||
|
.then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); })
|
||||||
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Repetir última venta (caso típico: toxina cada 3 meses) ----
|
||||||
|
const repetirUltimaVenta = async () => {
|
||||||
|
if (!paciente) return;
|
||||||
|
if (lines.length > 0 && !window.confirm('El ticket ya tiene líneas. ¿Reemplazarlas por las de la última venta?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLastSaleLoading(true);
|
||||||
|
const res = await odooApi.getLastSale(paciente.id);
|
||||||
|
if (res.status !== 'success' || !res.sale) {
|
||||||
|
toast.error('Este paciente no tiene ventas previas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Se rearma con el precio VIGENTE del catálogo y se omiten los artículos
|
||||||
|
// que ya no existen o están inactivos (se avisan).
|
||||||
|
const nuevas: TicketLine[] = [];
|
||||||
|
const omitidas: string[] = [];
|
||||||
|
for (const l of res.lines) {
|
||||||
|
if (l.missing) { omitidas.push(l.name); continue; }
|
||||||
|
const key = l.kind === 'service' ? `s-${l.service_id}` : `i-${l.item_id}`;
|
||||||
|
const found = nuevas.find((x) => x.key === key);
|
||||||
|
if (found) { found.qty += l.quantity; continue; }
|
||||||
|
nuevas.push({
|
||||||
|
key,
|
||||||
|
name: l.name,
|
||||||
|
qty: l.quantity,
|
||||||
|
price: l.price_current ?? l.price_last,
|
||||||
|
...(l.kind === 'service' ? { service_id: l.service_id ?? undefined } : {}),
|
||||||
|
...(l.kind === 'item' ? { item_id: l.item_id ?? undefined, stock: l.stock ?? undefined, unit: l.unit ?? undefined } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLines(nuevas);
|
||||||
|
const cambiados = res.lines.filter((l) => l.price_changed && !l.missing).length;
|
||||||
|
toast.success(`Ticket cargado desde ${res.sale.name} (${res.sale.date})`);
|
||||||
|
res.warnings.forEach((w) => toast.warning(w));
|
||||||
|
if (omitidas.length > 0 && cambiados === 0) {
|
||||||
|
toast.warning(`No se agregaron: ${omitidas.join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('No se pudo cargar la última venta');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLastSaleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Caja: abrir desde el POS sin perder el ticket ----
|
||||||
|
const abrirCajaDesdePos = async () => {
|
||||||
|
const fondo = parseFloat(cajaFondo || '0');
|
||||||
|
if (Number.isNaN(fondo) || fondo < 0) {
|
||||||
|
toast.error('Ingresa un fondo de caja válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setCajaAbriendo(true);
|
||||||
|
await odooApi.createCashClosing({ date: new Date().toISOString().split('T')[0], opening_cash: fondo });
|
||||||
|
setCajaAbierta(true);
|
||||||
|
setCajaModalOpen(false);
|
||||||
|
setCajaFondo('');
|
||||||
|
toast.success('Caja abierta');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('No se pudo abrir la caja');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setCajaAbriendo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Atajos de teclado ----
|
||||||
|
// F2 buscador de servicios · F9 cobrar · +/- cantidad de la última línea
|
||||||
|
// (con el ticket enfocado) · Esc cierra el modal abierto (lo maneja Modal).
|
||||||
|
const ajustarUltimaLinea = (delta: number) => {
|
||||||
|
setLines((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
const qty = Math.max(1, last.qty + delta);
|
||||||
|
return prev.map((l, i) => (i === prev.length - 1 ? { ...l, qty } : l));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cobrarConAtajo = () => {
|
||||||
|
if (lines.length === 0 || !paciente) {
|
||||||
|
toast.error(lines.length === 0 ? 'El ticket está vacío' : 'Selecciona un paciente');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abrirCobro();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotkeyRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||||||
|
hotkeyRef.current = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchWrapRef.current?.querySelector('input')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'F9') {
|
||||||
|
e.preventDefault();
|
||||||
|
cobrarConAtajo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
const tag = target?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
||||||
|
if (!ticketFocused) return;
|
||||||
|
if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
ajustarUltimaLinea(1);
|
||||||
|
} else if (e.key === '-' || e.key === '_') {
|
||||||
|
e.preventDefault();
|
||||||
|
ajustarUltimaLinea(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => hotkeyRef.current(e);
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const nuevaVenta = () => {
|
const nuevaVenta = () => {
|
||||||
setResultado(null);
|
setResultado(null);
|
||||||
setLines([]);
|
setLines([]);
|
||||||
@@ -376,6 +514,7 @@ const Pos: FC = () => {
|
|||||||
setPacienteAdeudo(0);
|
setPacienteAdeudo(0);
|
||||||
setPacienteQuery('');
|
setPacienteQuery('');
|
||||||
setDoctorId('');
|
setDoctorId('');
|
||||||
|
setPaquetes([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
||||||
@@ -423,6 +562,34 @@ const Pos: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Paquetes con sesiones pendientes: aviso informativo para caja.
|
||||||
|
No hay consumo automático de sesión (el sistema no guarda contador):
|
||||||
|
las sesiones restantes se derivan de las citas del paquete. */}
|
||||||
|
{paquetes.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-violet-300 bg-violet-50 px-2.5 py-1.5 space-y-0.5">
|
||||||
|
{paquetes.map((k) => (
|
||||||
|
<p key={k.service_id} className="text-[11px] text-violet-800 flex items-center gap-1.5">
|
||||||
|
<Package size={11} className="shrink-0" />
|
||||||
|
<span>
|
||||||
|
Paquete <span className="font-medium">{k.name}</span>: {k.remaining} de {k.total} sesiones restantes
|
||||||
|
{k.next_date ? ` · próxima ${k.next_date}` : ''}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Repetir la última venta del paciente (toxina cada 3 meses, etc.) */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={repetirUltimaVenta}
|
||||||
|
loading={lastSaleLoading}
|
||||||
|
title="Carga las líneas de la última venta con los precios actuales del catálogo"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} className="mr-1.5" />
|
||||||
|
Repetir última venta
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : altaRapida ? (
|
) : altaRapida ? (
|
||||||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
||||||
@@ -564,22 +731,45 @@ const Pos: FC = () => {
|
|||||||
className="w-full mt-1 !py-3 text-base"
|
className="w-full mt-1 !py-3 text-base"
|
||||||
onClick={abrirCobro}
|
onClick={abrirCobro}
|
||||||
disabled={lines.length === 0 || !paciente}
|
disabled={lines.length === 0 || !paciente}
|
||||||
|
title="Cobrar (F9)"
|
||||||
>
|
>
|
||||||
<ShoppingBag size={18} className="mr-2" />
|
<ShoppingBag size={18} className="mr-2" />
|
||||||
Cobrar {fmtMoney(total)}
|
Cobrar {fmtMoney(total)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
||||||
|
<Keyboard size={11} aria-hidden="true" />
|
||||||
|
F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShortcutsOpen(true)}
|
||||||
|
className="underline hover:text-theme-heading"
|
||||||
|
>
|
||||||
|
Ver atajos
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
|
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
|
||||||
{!cajaAbierta && (
|
{!cajaAbierta ? (
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
|
||||||
<AlertTriangle size={13} className="shrink-0" />
|
<AlertTriangle size={13} className="shrink-0" />
|
||||||
Sin corte de caja abierto hoy
|
Sin corte de caja abierto hoy
|
||||||
<Link to="/cortes" className="font-semibold underline">Abrir caja</Link>
|
|
||||||
</span>
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCajaModalOpen(true)}>
|
||||||
|
<Lock size={13} className="mr-1.5" />
|
||||||
|
Abrir caja aquí
|
||||||
|
</Button>
|
||||||
|
<Link to="/cortes" className="text-xs font-semibold underline text-theme-heading">Ir a Cortes de caja</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-xs text-theme-muted">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-bg text-xs">
|
||||||
|
Caja abierta
|
||||||
|
</span>
|
||||||
|
<Link to="/cortes" className="underline hover:text-theme-heading">Cerrar caja</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -587,10 +777,10 @@ const Pos: FC = () => {
|
|||||||
{/* Catálogo */}
|
{/* Catálogo */}
|
||||||
<Card className="flex-1 min-w-0 w-full">
|
<Card className="flex-1 min-w-0 w-full">
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3" ref={searchWrapRef}>
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={catMode === 'servicios' ? 'Buscar servicio...' : 'Buscar artículo...'}
|
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : 'Buscar artículo... (F2)'}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
@@ -702,24 +892,33 @@ const Pos: FC = () => {
|
|||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
|
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
|
||||||
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
|
<div
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
className="flex flex-col flex-1 min-h-0 outline-none"
|
||||||
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
tabIndex={0}
|
||||||
{lines.length > 0 && (
|
onFocusCapture={() => setTicketFocused(true)}
|
||||||
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
|
onBlurCapture={() => setTicketFocused(false)}
|
||||||
<Trash2 size={15} className="text-theme-muted" />
|
title="Atajos: + y − ajustan la cantidad de la última línea cuando el ticket tiene el foco"
|
||||||
</Button>
|
>
|
||||||
)}
|
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
||||||
|
{lines.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
|
||||||
|
<Trash2 size={15} className="text-theme-muted" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pacienteBlock}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
|
||||||
|
{linesBlock}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
|
||||||
|
{totalsBlock}
|
||||||
</div>
|
</div>
|
||||||
{pacienteBlock}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
|
|
||||||
{linesBlock}
|
|
||||||
</div>
|
|
||||||
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
|
|
||||||
{totalsBlock}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -737,13 +936,70 @@ const Pos: FC = () => {
|
|||||||
|
|
||||||
{/* Ticket móvil */}
|
{/* Ticket móvil */}
|
||||||
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
|
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
|
||||||
<div className="space-y-3">
|
<div
|
||||||
|
className="space-y-3"
|
||||||
|
tabIndex={-1}
|
||||||
|
onFocusCapture={() => setTicketFocused(true)}
|
||||||
|
onBlurCapture={() => setTicketFocused(false)}
|
||||||
|
>
|
||||||
{pacienteBlock}
|
{pacienteBlock}
|
||||||
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
|
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
|
||||||
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
|
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Ayuda discreta de atajos de teclado */}
|
||||||
|
<Modal
|
||||||
|
isOpen={shortcutsOpen}
|
||||||
|
onClose={() => setShortcutsOpen(false)}
|
||||||
|
title="Atajos de teclado"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={<Button variant="outline" onClick={() => setShortcutsOpen(false)}>Cerrar</Button>}
|
||||||
|
>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<li key={s.k} className="flex items-start gap-3">
|
||||||
|
<span className="shrink-0 rounded-md border border-theme-border-strong bg-theme-bg px-2 py-0.5 font-mono text-xs text-theme-heading">{s.k}</span>
|
||||||
|
<span className="text-theme-muted">{s.d}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Abrir caja sin salir del POS */}
|
||||||
|
<Modal
|
||||||
|
isOpen={cajaModalOpen}
|
||||||
|
onClose={() => setCajaModalOpen(false)}
|
||||||
|
title="Abrir caja"
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCajaModalOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={abrirCajaDesdePos} loading={cajaAbriendo}>Abrir caja</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Se abre el corte de caja de hoy sin perder el ticket. El cierre guiado (conteo por método)
|
||||||
|
se hace en <Link to="/cortes" className="underline font-medium text-theme-heading">Cortes de caja</Link>.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Efectivo inicial (fondo de caja)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={cajaFondo}
|
||||||
|
onChange={(e) => setCajaFondo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Modal de cobro */}
|
{/* Modal de cobro */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={cobroOpen}
|
isOpen={cobroOpen}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { Fragment, useEffect, useState, useCallback } from 'react';
|
||||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer } from 'lucide-react';
|
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer, ChevronDown, ChevronRight, Ban } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import TicketPrint from '../components/TicketPrint';
|
import TicketPrint from '../components/TicketPrint';
|
||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||||
import { exportToExcel } from '../lib/exporter';
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange';
|
||||||
|
|
||||||
|
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
|
||||||
|
const FILTERS_KEY = 'skeen_ventas_filtros';
|
||||||
|
const ROLES_CANCELAN = ['admin', 'gerencia'];
|
||||||
|
|
||||||
interface LineForm {
|
interface LineForm {
|
||||||
service_id: string;
|
service_id: string;
|
||||||
@@ -40,6 +46,8 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
|
|||||||
|
|
||||||
const Ventas: FC = () => {
|
const Ventas: FC = () => {
|
||||||
const { openPatient } = usePatientDrawer();
|
const { openPatient } = usePatientDrawer();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||||
const [sales, setSales] = useState<Sale[]>([]);
|
const [sales, setSales] = useState<Sale[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
@@ -60,6 +68,12 @@ const Ventas: FC = () => {
|
|||||||
const [refunding, setRefunding] = useState(false);
|
const [refunding, setRefunding] = useState(false);
|
||||||
const [refundAmount, setRefundAmount] = useState('');
|
const [refundAmount, setRefundAmount] = useState('');
|
||||||
const [refundReason, setRefundReason] = useState('');
|
const [refundReason, setRefundReason] = useState('');
|
||||||
|
// Cancelación con nota de crédito (admin/gerencia)
|
||||||
|
const [cancelOpen, setCancelOpen] = useState<Sale | null>(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<number | null>(null);
|
||||||
// Reimprimir ticket de una venta ya registrada
|
// Reimprimir ticket de una venta ya registrada
|
||||||
const [printSale, setPrintSale] = useState<Sale | null>(null);
|
const [printSale, setPrintSale] = useState<Sale | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -69,11 +83,36 @@ const Ventas: FC = () => {
|
|||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [patientSearch, setPatientSearch] = useState('');
|
const [patientSearch, setPatientSearch] = useState('');
|
||||||
|
|
||||||
|
// ---- Rango de fechas recordado (presets) ----
|
||||||
|
const saved = readDateFilterPrefs(FILTERS_KEY);
|
||||||
|
const [preset, setPreset] = useState<DatePreset>(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 () => {
|
const loadSales = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
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') {
|
if (res.status === 'success') {
|
||||||
setSales(res.sales);
|
setSales(res.sales);
|
||||||
setTotal(res.total ?? 0);
|
setTotal(res.total ?? 0);
|
||||||
@@ -86,7 +125,7 @@ const Ventas: FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, page, pageSize]);
|
}, [search, page, pageSize, start, end, preset]);
|
||||||
|
|
||||||
const loadRefs = useCallback(async () => {
|
const loadRefs = useCallback(async () => {
|
||||||
try {
|
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 () => {
|
const exportSales = async () => {
|
||||||
try {
|
try {
|
||||||
await exportToExcel({
|
await exportToExcel({
|
||||||
@@ -319,7 +394,7 @@ const Ventas: FC = () => {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-3">
|
||||||
<div className="relative w-full sm:max-w-sm">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
<Input
|
<Input
|
||||||
@@ -331,6 +406,40 @@ const Ventas: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Presets de fecha (se recuerdan al volver) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => aplicarPreset(p.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||||
|
preset === p.value
|
||||||
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
||||||
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 sm:ml-auto">
|
||||||
|
<Input
|
||||||
|
label="Desde"
|
||||||
|
type="date"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => { setStart(e.target.value); setPreset('custom'); }}
|
||||||
|
className="sm:max-w-[160px]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Hasta"
|
||||||
|
type="date"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => { setEnd(e.target.value); setPreset('custom'); }}
|
||||||
|
className="sm:max-w-[160px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -349,6 +458,7 @@ const Ventas: FC = () => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-theme-bg">
|
<thead className="bg-theme-bg">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="w-8 p-3" />
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Total</th>
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Total</th>
|
||||||
@@ -358,48 +468,129 @@ const Ventas: FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{sales.map((s) => (
|
{sales.map((s) => {
|
||||||
<tr key={s.id} className="hover:bg-theme-bg">
|
const cancelada = s.state === 'cancelled';
|
||||||
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
|
const abierta = expandedId === s.id;
|
||||||
|
return (
|
||||||
|
<Fragment key={s.id}>
|
||||||
|
<tr
|
||||||
|
className={`hover:bg-theme-bg cursor-pointer ${abierta ? 'bg-theme-bg' : ''}`}
|
||||||
|
onClick={() => toggleExpand(s.id)}
|
||||||
|
title="Click para ver las líneas de la venta"
|
||||||
|
>
|
||||||
|
<td className="p-3 text-theme-muted">
|
||||||
|
{abierta ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
|
</td>
|
||||||
|
<td className={`p-3 text-sm text-theme-muted ${cancelada ? 'line-through' : ''}`}>{s.name}</td>
|
||||||
<td className="p-3 text-sm font-medium text-theme-heading">
|
<td className="p-3 text-sm font-medium text-theme-heading">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openPatient(s.patient_id)}
|
onClick={(e) => { e.stopPropagation(); openPatient(s.patient_id); }}
|
||||||
className="hover:underline hover:text-theme-accent text-left"
|
className="hover:underline hover:text-theme-accent text-left"
|
||||||
title="Abrir ficha del paciente"
|
title="Abrir ficha del paciente"
|
||||||
>
|
>
|
||||||
{s.patient}
|
{s.patient}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm font-semibold text-theme-heading">${s.total}</td>
|
<td className={`p-3 text-sm font-semibold text-theme-heading ${cancelada ? 'line-through text-theme-muted' : ''}`}>${s.total}</td>
|
||||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
<Badge variant={badgeForSaleState(s.state)}>{cancelada ? 'Cancelada' : s.state}</Badge>
|
||||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
{cancelada && s.cancel_reason && (
|
||||||
|
<p className="text-[11px] text-rose-700 mt-1 max-w-[220px]" title={s.cancel_reason}>
|
||||||
|
Motivo: {s.cancel_reason}
|
||||||
|
{s.cancelled_by ? ` · ${s.cancelled_by}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setPrintSale(s); }} title="Reimprimir ticket">
|
||||||
<Printer size={16} className="text-theme-heading" />
|
<Printer size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
{s.amount_due > 0 && (
|
{s.amount_due > 0 && !cancelada && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)} title="Pagar">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openPay(s); }} title="Pagar">
|
||||||
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
<CreditCard size={16} className="text-theme-heading mr-1.5" />
|
||||||
Pagar
|
Pagar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{s.amount_paid > 0 && s.state !== 'cancelled' && (
|
{s.amount_paid > 0 && !cancelada && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openRefund(s); }} title="Devolución">
|
||||||
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
<Undo2 size={16} className="text-amber-600 mr-1.5" />
|
||||||
Devolución
|
Devolución
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{puedeCancelar && !cancelada && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); openCancel(s); }} title="Cancelar venta (nota de crédito)">
|
||||||
|
<Ban size={16} className="text-rose-600 mr-1.5" />
|
||||||
|
Cancelar venta
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{abierta && (
|
||||||
|
<tr className="bg-theme-bg">
|
||||||
|
<td />
|
||||||
|
<td colSpan={6} className="p-3 pt-0">
|
||||||
|
<div className="rounded-xl border border-theme-border bg-theme-surface p-3">
|
||||||
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">
|
||||||
|
Líneas de {s.name} · {s.date}
|
||||||
|
</p>
|
||||||
|
{s.lines.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Sin líneas registradas.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] uppercase text-theme-muted">
|
||||||
|
<th className="text-left py-1">Concepto</th>
|
||||||
|
<th className="text-right py-1 w-16">Cant</th>
|
||||||
|
<th className="text-right py-1 w-24">Precio</th>
|
||||||
|
<th className="text-right py-1 w-24">Subtotal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{s.lines.map((l, i) => (
|
||||||
|
<tr key={l.id ?? i}>
|
||||||
|
<td className="py-1 text-theme-heading">
|
||||||
|
{l.service || l.item || l.description}
|
||||||
|
{l.is_prescribed && l.prescribed_by && (
|
||||||
|
<span className="text-[11px] text-theme-muted"> · recetado por {l.prescribed_by}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right text-theme-muted">{l.quantity}</td>
|
||||||
|
<td className="py-1 text-right text-theme-muted">${l.price_unit}</td>
|
||||||
|
<td className="py-1 text-right font-medium text-theme-heading">${l.subtotal}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-2 text-xs text-theme-muted">
|
||||||
|
<span>Subtotal ${s.subtotal}</span>
|
||||||
|
<span>Descuento ${s.discount}</span>
|
||||||
|
<span>Total ${s.total}</span>
|
||||||
|
<span>Pagado ${s.amount_paid}</span>
|
||||||
|
<span>Por cobrar ${s.amount_due}</span>
|
||||||
|
</div>
|
||||||
|
{cancelada && (
|
||||||
|
<p className="mt-2 text-xs text-rose-700">
|
||||||
|
Cancelada{s.cancelled_at ? ` el ${s.cancelled_at}` : ''}
|
||||||
|
{s.cancelled_by ? ` por ${s.cancelled_by}` : ''}
|
||||||
|
{s.cancel_reason ? ` · Motivo: ${s.cancel_reason}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{s.notes && <p className="mt-1 text-xs text-theme-muted">Notas: {s.notes}</p>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,18 +617,21 @@ const Ventas: FC = () => {
|
|||||||
label: 'Estado',
|
label: 'Estado',
|
||||||
value: (
|
value: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
|
<Badge variant={badgeForSaleState(s.state)}>{s.state === 'cancelled' ? 'Cancelada' : s.state}</Badge>
|
||||||
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(s.state === 'cancelled' && s.cancel_reason
|
||||||
|
? [{ label: 'Motivo', value: s.cancel_reason }]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
<Button variant="ghost" size="sm" onClick={() => setPrintSale(s)} title="Reimprimir ticket">
|
||||||
<Printer size={16} className="text-theme-heading" />
|
<Printer size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
{s.amount_due > 0 && (
|
{s.amount_due > 0 && s.state !== 'cancelled' && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
|
||||||
<CreditCard size={16} className="text-theme-heading" />
|
<CreditCard size={16} className="text-theme-heading" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -447,6 +641,11 @@ const Ventas: FC = () => {
|
|||||||
<Undo2 size={16} className="text-amber-600" />
|
<Undo2 size={16} className="text-amber-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{puedeCancelar && s.state !== 'cancelled' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openCancel(s)} title="Cancelar venta">
|
||||||
|
<Ban size={16} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -675,6 +874,44 @@ const Ventas: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!cancelOpen}
|
||||||
|
onClose={() => setCancelOpen(null)}
|
||||||
|
title={`Cancelar venta ${cancelOpen?.name || ''}`}
|
||||||
|
maxWidth="sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setCancelOpen(null)}>Volver</Button>
|
||||||
|
<Button variant="danger" onClick={submitCancel} loading={cancelling}>Cancelar venta</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cancelOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Paciente: <span className="font-medium text-theme-heading">{cancelOpen.patient}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Total: <span className="font-medium text-theme-heading">${cancelOpen.total}</span>
|
||||||
|
{' · '}
|
||||||
|
Pagado: <span className="font-medium text-theme-heading">${cancelOpen.amount_paid}</span>
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
label="Motivo de cancelación *"
|
||||||
|
placeholder="Ej. cobro duplicado, error de captura, servicio no realizado..."
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Reimprimir ticket de la venta (formato térmico ~80mm) */}
|
{/* Reimprimir ticket de la venta (formato térmico ~80mm) */}
|
||||||
<TicketPrint
|
<TicketPrint
|
||||||
isOpen={!!printSale}
|
isOpen={!!printSale}
|
||||||
|
|||||||
@@ -388,6 +388,46 @@ export interface Sale {
|
|||||||
refund_amount?: number;
|
refund_amount?: number;
|
||||||
refund_reason?: string;
|
refund_reason?: string;
|
||||||
refunded_at?: string | null;
|
refunded_at?: string | null;
|
||||||
|
notes?: string;
|
||||||
|
cancel_reason?: string;
|
||||||
|
cancelled_by?: string;
|
||||||
|
cancelled_at?: string | null;
|
||||||
|
cancel_points_reversed?: number;
|
||||||
|
cancel_points_restored?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Repetir última venta" (POS): línea de la última venta con el precio vigente
|
||||||
|
export interface LastSaleLine {
|
||||||
|
kind: 'service' | 'item';
|
||||||
|
service_id: number | null;
|
||||||
|
item_id: number | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
price_last: number;
|
||||||
|
price_current: number | null;
|
||||||
|
price_changed: boolean;
|
||||||
|
missing: boolean;
|
||||||
|
active: boolean;
|
||||||
|
stock: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastSaleResponse {
|
||||||
|
status: string;
|
||||||
|
sale: Sale | null;
|
||||||
|
lines: LastSaleLine[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientPackage {
|
||||||
|
service_id: number;
|
||||||
|
name: string;
|
||||||
|
package_sessions: number;
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
next_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MySaleItem {
|
export interface MySaleItem {
|
||||||
@@ -464,7 +504,7 @@ export interface Birthday {
|
|||||||
|
|
||||||
export type InventoryKind = 'producto' | 'consumible';
|
export type InventoryKind = 'producto' | 'consumible';
|
||||||
export type InventoryLevel = 'out' | 'critical' | 'low' | 'optimal';
|
export type InventoryLevel = 'out' | 'critical' | 'low' | 'optimal';
|
||||||
export type InventoryMoveType = 'compra' | 'venta' | 'baja' | 'ajuste';
|
export type InventoryMoveType = 'compra' | 'venta' | 'baja' | 'ajuste' | 'cancelacion';
|
||||||
|
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -1350,6 +1390,24 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Cancelación con nota de crédito (permiso admin/gerencia)
|
||||||
|
async cancelSale(id: number, payload: { reason: string; restore_stock?: boolean }): Promise<{ status: string; sale: Sale; result: { points_reversed: number; points_restored: number; stock_moves: number } }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/cancel`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Última venta del paciente con precios actuales del catálogo (POS: repetir venta)
|
||||||
|
async getLastSale(patientId: number): Promise<LastSaleResponse> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales/last`, { params: { patient_id: patientId } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Paquetes del paciente con sesiones restantes (POS: aviso en el ticket)
|
||||||
|
async getPatientPackages(patientId: number): Promise<{ status: string; packages: PatientPackage[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/packages`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// Cortes de caja
|
// Cortes de caja
|
||||||
async getCashClosings(date?: string): Promise<{ status: string; cash_closings: CashClosing[] }> {
|
async getCashClosings(date?: string): Promise<{ status: string; cash_closings: CashClosing[] }> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, { params: date ? { date } : {} });
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, { params: date ? { date } : {} });
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class SkeenInventarioMove(models.Model):
|
|||||||
('venta', 'Venta (salida)'),
|
('venta', 'Venta (salida)'),
|
||||||
('baja', 'Baja (merma)'),
|
('baja', 'Baja (merma)'),
|
||||||
('ajuste', 'Ajuste (conteo físico)'),
|
('ajuste', 'Ajuste (conteo físico)'),
|
||||||
|
('cancelacion', 'Cancelación de venta (entrada)'),
|
||||||
], string='Tipo', required=True)
|
], string='Tipo', required=True)
|
||||||
qty = fields.Float(string='Cantidad', required=True, default=0.0)
|
qty = fields.Float(string='Cantidad', required=True, default=0.0)
|
||||||
before_qty = fields.Float(string='Existencia anterior', readonly=True)
|
before_qty = fields.Float(string='Existencia anterior', readonly=True)
|
||||||
@@ -113,7 +114,7 @@ class SkeenInventarioMove(models.Model):
|
|||||||
continue
|
continue
|
||||||
before = item.qty or 0.0
|
before = item.qty or 0.0
|
||||||
q = m.qty or 0.0
|
q = m.qty or 0.0
|
||||||
if m.type == 'compra':
|
if m.type in ('compra', 'cancelacion'):
|
||||||
after = before + q
|
after = before + q
|
||||||
elif m.type in ('venta', 'baja'):
|
elif m.type in ('venta', 'baja'):
|
||||||
after = before - q
|
after = before - q
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from odoo import models, fields, api, _
|
from odoo import models, fields, api, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
|
||||||
class SkeenVenta(models.Model):
|
class SkeenVenta(models.Model):
|
||||||
_name = 'skeen.venta'
|
_name = 'skeen.venta'
|
||||||
@@ -55,6 +57,13 @@ class SkeenVenta(models.Model):
|
|||||||
refund_reason = fields.Text(string='Motivo Devolución')
|
refund_reason = fields.Text(string='Motivo Devolución')
|
||||||
refunded_at = fields.Datetime(string='Fecha Devolución')
|
refunded_at = fields.Datetime(string='Fecha Devolución')
|
||||||
|
|
||||||
|
# Cancelación (nota de crédito): motivo obligatorio y auditoría
|
||||||
|
cancel_reason = fields.Text(string='Motivo Cancelación')
|
||||||
|
cancelled_by = fields.Char(string='Cancelada por', readonly=True, copy=False)
|
||||||
|
cancelled_at = fields.Datetime(string='Fecha Cancelación', readonly=True, copy=False)
|
||||||
|
cancel_points_reversed = fields.Integer(string='Puntos Revertidos', default=0, readonly=True, copy=False)
|
||||||
|
cancel_points_restored = fields.Integer(string='Puntos Restaurados', default=0, readonly=True, copy=False)
|
||||||
|
|
||||||
# Fechas
|
# Fechas
|
||||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
||||||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||||||
@@ -107,6 +116,118 @@ class SkeenVenta(models.Model):
|
|||||||
def action_cancel(self):
|
def action_cancel(self):
|
||||||
self.write({'state': 'cancelled'})
|
self.write({'state': 'cancelled'})
|
||||||
|
|
||||||
|
def _reverse_wallet_for_cancel(self):
|
||||||
|
"""Revierte los puntos que ESTA venta otorgó y devuelve los que canjeó.
|
||||||
|
|
||||||
|
Se identifican por `reference == venta.name` (misma convención que usa
|
||||||
|
action_mark_paid). Los ajustes negativos hacen piso en 0 para no dejar
|
||||||
|
saldo negativo si el paciente ya gastó los puntos.
|
||||||
|
Devuelve (puntos_revertidos, puntos_restaurados).
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||||
|
if not monedero:
|
||||||
|
return 0, 0
|
||||||
|
txs = self.env['skeen.monedero.transaction'].search([
|
||||||
|
('monedero_id', '=', monedero.id),
|
||||||
|
('reference', '=', self.name),
|
||||||
|
])
|
||||||
|
revertidos = 0
|
||||||
|
restaurados = 0
|
||||||
|
for tx in txs:
|
||||||
|
if tx.type == 'accrual' and tx.points > 0:
|
||||||
|
applied = monedero.adjust_points(
|
||||||
|
-tx.points,
|
||||||
|
description=f'Cancelación venta {self.name}',
|
||||||
|
reference=self.name,
|
||||||
|
)
|
||||||
|
revertidos += -applied
|
||||||
|
elif tx.type == 'redemption' and tx.points < 0:
|
||||||
|
monedero.adjust_points(
|
||||||
|
-tx.points,
|
||||||
|
description=f'Reintegro de puntos por cancelación {self.name}',
|
||||||
|
reference=self.name,
|
||||||
|
)
|
||||||
|
restaurados += -tx.points
|
||||||
|
return revertidos, restaurados
|
||||||
|
|
||||||
|
def _restore_stock_for_cancel(self):
|
||||||
|
"""Devuelve al inventario lo que esta venta descontó.
|
||||||
|
|
||||||
|
Determinista: el cobro POS crea un `skeen.inventario.move` type='venta'
|
||||||
|
con reference = venta.name por cada línea de artículo. Se registra la
|
||||||
|
entrada inversa (type='cancelacion') con la misma referencia.
|
||||||
|
Devuelve la lista de movimientos creados.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Move = self.env['skeen.inventario.move']
|
||||||
|
salidas = Move.search([('reference', '=', self.name), ('type', '=', 'venta')])
|
||||||
|
creados = Move
|
||||||
|
for m in salidas:
|
||||||
|
creados |= Move.create({
|
||||||
|
'item_id': m.item_id.id,
|
||||||
|
'type': 'cancelacion',
|
||||||
|
'qty': m.qty,
|
||||||
|
'reference': self.name,
|
||||||
|
'notes': f'Cancelación de venta {self.name}',
|
||||||
|
})
|
||||||
|
return creados
|
||||||
|
|
||||||
|
def action_cancel_with_reason(self, reason='', cancelled_by='', restore_stock=True):
|
||||||
|
"""Cancela la venta (nota de crédito) con motivo obligatorio.
|
||||||
|
|
||||||
|
- Revierte los puntos otorgados y reintegra los canjeados por la venta.
|
||||||
|
- Devuelve al inventario los artículos descontados por la venta.
|
||||||
|
- Registra auditoría en el chatter y en los campos cancelled_*.
|
||||||
|
La venta NO se borra: queda en state='cancelled' y los reportes la excluyen.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'cancelled':
|
||||||
|
raise ValidationError(_('La venta ya está cancelada'))
|
||||||
|
reason = (reason or '').strip()
|
||||||
|
if not reason:
|
||||||
|
raise ValidationError(_('El motivo de cancelación es obligatorio'))
|
||||||
|
|
||||||
|
revertidos, restaurados = self._reverse_wallet_for_cancel()
|
||||||
|
movimientos = self._restore_stock_for_cancel() if restore_stock else self.env['skeen.inventario.move']
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'cancelled',
|
||||||
|
'cancel_reason': reason,
|
||||||
|
'cancelled_by': cancelled_by or '',
|
||||||
|
'cancelled_at': fields.Datetime.now(),
|
||||||
|
'cancel_points_reversed': revertidos,
|
||||||
|
'cancel_points_restored': restaurados,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Auditoría en el chatter. En peticiones auth='none' (frontend) el uid
|
||||||
|
# de Odoo es nulo y message_post necesita un autor: se publica como el
|
||||||
|
# usuario administrador y el login real queda en el cuerpo y en
|
||||||
|
# cancelled_by.
|
||||||
|
author = self.env.ref('base.user_admin', raise_if_not_found=False)
|
||||||
|
if not author:
|
||||||
|
author = self.env['res.users'].sudo().search([('login', '=', 'admin')], limit=1)
|
||||||
|
auditor = self.with_user(author) if author else self
|
||||||
|
auditor.message_post(body=Markup(
|
||||||
|
'<b>Venta cancelada (nota de crédito)</b><br/>'
|
||||||
|
'Motivo: {}<br/>'
|
||||||
|
'Cancelada por: {}<br/>'
|
||||||
|
'Puntos revertidos: {} · Puntos reintegrados: {}<br/>'
|
||||||
|
'Artículos devueltos al inventario: {}'
|
||||||
|
).format(
|
||||||
|
reason,
|
||||||
|
cancelled_by or 'sistema',
|
||||||
|
revertidos,
|
||||||
|
restaurados,
|
||||||
|
len(movimientos),
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'points_reversed': revertidos,
|
||||||
|
'points_restored': restaurados,
|
||||||
|
'stock_moves': len(movimientos),
|
||||||
|
}
|
||||||
|
|
||||||
def action_refund(self, amount=None, reason=''):
|
def action_refund(self, amount=None, reason=''):
|
||||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
from odoo.http import request, Response
|
from odoo.http import request, Response
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
@@ -64,6 +65,21 @@ class SkeenFrontendController(http.Controller):
|
|||||||
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
|
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
|
||||||
return emp
|
return emp
|
||||||
|
|
||||||
|
def _frontend_res_user(self):
|
||||||
|
"""res.users equivalente al usuario del frontend que hace la petición.
|
||||||
|
|
||||||
|
Los controladores con auth='none' crean registros sin uid de Odoo; para
|
||||||
|
que el usuario quede atribuido (p. ej. `user_id` del corte de caja) se
|
||||||
|
busca el res.users con el mismo login y, si no existe, se usa el admin.
|
||||||
|
"""
|
||||||
|
login = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||||
|
User = request.env['res.users'].sudo()
|
||||||
|
if login:
|
||||||
|
u = User.search([('login', '=', login)], limit=1)
|
||||||
|
if u:
|
||||||
|
return u
|
||||||
|
return request.env.ref('base.user_admin', raise_if_not_found=False) or User.browse()
|
||||||
|
|
||||||
def _frontend_user_dict(self, user):
|
def _frontend_user_dict(self, user):
|
||||||
"""to_public_dict + datos del empleado vinculado (rol medico)."""
|
"""to_public_dict + datos del empleado vinculado (rol medico)."""
|
||||||
d = user.to_public_dict()
|
d = user.to_public_dict()
|
||||||
@@ -409,7 +425,12 @@ class SkeenFrontendController(http.Controller):
|
|||||||
"""Crear una o más citas (soporta paquetes de sesiones)"""
|
"""Crear una o más citas (soporta paquetes de sesiones)"""
|
||||||
try:
|
try:
|
||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
servicio = request.env['skeen.servicio'].sudo().browse(int(data.get('servicio_id', 0)))
|
# Acepta ambos estilos de clave (el frontend usa patient_id/service_id)
|
||||||
|
partner_id = data.get('partner_id', data.get('patient_id'))
|
||||||
|
servicio_id = data.get('servicio_id', data.get('service_id'))
|
||||||
|
if not partner_id or not servicio_id:
|
||||||
|
return json_response({'status': 'error', 'message': 'Paciente y servicio son obligatorios'}, 400)
|
||||||
|
servicio = request.env['skeen.servicio'].sudo().browse(int(servicio_id))
|
||||||
sessions = int(data.get('sessions', 1) or 1)
|
sessions = int(data.get('sessions', 1) or 1)
|
||||||
if sessions < 1:
|
if sessions < 1:
|
||||||
sessions = 1
|
sessions = 1
|
||||||
@@ -417,8 +438,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
# Si el servicio tiene paquete y no se especificó sessions, default al paquete
|
# Si el servicio tiene paquete y no se especificó sessions, default al paquete
|
||||||
sessions = sessions if data.get('sessions') else servicio.package_sessions
|
sessions = sessions if data.get('sessions') else servicio.package_sessions
|
||||||
base_vals = {
|
base_vals = {
|
||||||
'partner_id': data.get('partner_id'),
|
'partner_id': partner_id,
|
||||||
'servicio_id': data.get('servicio_id'),
|
'servicio_id': servicio_id,
|
||||||
'doctor_id': data.get('doctor_id'),
|
'doctor_id': data.get('doctor_id'),
|
||||||
'branch': data.get('branch', 'rosarito'),
|
'branch': data.get('branch', 'rosarito'),
|
||||||
'medium': data.get('medium', 'onsite'),
|
'medium': data.get('medium', 'onsite'),
|
||||||
@@ -446,6 +467,19 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'appointments': [self._appointment_to_dict(c) for c in created],
|
'appointments': [self._appointment_to_dict(c) for c in created],
|
||||||
'sessions': sessions,
|
'sessions': sessions,
|
||||||
}, 201)
|
}, 201)
|
||||||
|
except ValidationError as e:
|
||||||
|
# Conflicto de agenda (solape o bloqueo): responder 409 con la cita
|
||||||
|
# en conflicto cuando se pueda identificar.
|
||||||
|
request.env.cr.rollback()
|
||||||
|
resp = {'status': 'error', 'message': str(e)}
|
||||||
|
resp.update(self._agenda_conflict_payload({
|
||||||
|
'date': data.get('date'),
|
||||||
|
'time': data.get('time'),
|
||||||
|
'doctor_id': data.get('doctor_id'),
|
||||||
|
'servicio_id': servicio_id,
|
||||||
|
'state': data.get('state', 'pending'),
|
||||||
|
}))
|
||||||
|
return json_response(resp, 409)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Rollback explícito: evita persistir citas cuyas validaciones fallaron
|
# Rollback explícito: evita persistir citas cuyas validaciones fallaron
|
||||||
request.env.cr.rollback()
|
request.env.cr.rollback()
|
||||||
@@ -534,6 +568,17 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'patient': self._patient_to_dict(patient),
|
'patient': self._patient_to_dict(patient),
|
||||||
'patient_created': patient_created,
|
'patient_created': patient_created,
|
||||||
}, 201)
|
}, 201)
|
||||||
|
except ValidationError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
resp = {'status': 'error', 'message': str(e)}
|
||||||
|
resp.update(self._agenda_conflict_payload({
|
||||||
|
'date': data.get('date'),
|
||||||
|
'time': data.get('time'),
|
||||||
|
'doctor_id': data.get('doctor_id'),
|
||||||
|
'servicio_id': data.get('servicio_id'),
|
||||||
|
'state': data.get('state', 'pending'),
|
||||||
|
}))
|
||||||
|
return json_response(resp, 409)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Rollback explícito: evita persistir paciente/cita con validación rechazada
|
# Rollback explícito: evita persistir paciente/cita con validación rechazada
|
||||||
request.env.cr.rollback()
|
request.env.cr.rollback()
|
||||||
@@ -563,7 +608,21 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if 'time' in vals and not self._valid_agenda_time(vals['time']):
|
if 'time' in vals and not self._valid_agenda_time(vals['time']):
|
||||||
return json_response(
|
return json_response(
|
||||||
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
|
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
|
||||||
cita.write(vals)
|
try:
|
||||||
|
cita.write(vals)
|
||||||
|
except ValidationError as e:
|
||||||
|
# Conflicto de agenda: valores efectivos = cita actual + cambios
|
||||||
|
request.env.cr.rollback()
|
||||||
|
effective = {
|
||||||
|
'date': vals.get('date') or (cita.date.strftime('%Y-%m-%d') if cita.date else None),
|
||||||
|
'time': vals.get('time', cita.time),
|
||||||
|
'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False),
|
||||||
|
'servicio_id': vals.get('servicio_id', cita.servicio_id.id),
|
||||||
|
'state': vals.get('state', cita.state),
|
||||||
|
}
|
||||||
|
resp = {'status': 'error', 'message': str(e)}
|
||||||
|
resp.update(self._agenda_conflict_payload(effective, exclude_id=cita.id))
|
||||||
|
return json_response(resp, 409)
|
||||||
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
|
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Rollback explícito: evita persistir writes parciales (validación rechazada)
|
# Rollback explícito: evita persistir writes parciales (validación rechazada)
|
||||||
@@ -660,6 +719,88 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
def bulk_phone_confirm(self, **kw):
|
||||||
|
"""Confirmación telefónica en lote.
|
||||||
|
|
||||||
|
Body: {"ids": [..], "confirmed": true|false}. Valida que todas las
|
||||||
|
citas existan y no estén canceladas / no_show / done; confirma el resto
|
||||||
|
y devuelve conteos. Permiso: recepción+ (igual que phone_confirm).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _parse_json_body()
|
||||||
|
raw_ids = data.get('ids') or []
|
||||||
|
confirmed = bool(data.get('confirmed', True))
|
||||||
|
if not isinstance(raw_ids, list) or not raw_ids:
|
||||||
|
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
|
||||||
|
try:
|
||||||
|
ids = [int(i) for i in raw_ids]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
|
||||||
|
Citas = request.env['skeen.cita'].sudo()
|
||||||
|
citas = Citas.browse(ids).exists()
|
||||||
|
found = set(citas.ids)
|
||||||
|
not_found = [i for i in dict.fromkeys(ids) if i not in found]
|
||||||
|
elegibles = citas.filtered(lambda c: c.state not in ('cancelled', 'no_show', 'done'))
|
||||||
|
skipped_invalid_state = [c.id for c in citas if c.state in ('cancelled', 'no_show', 'done')]
|
||||||
|
if confirmed:
|
||||||
|
elegibles.write({
|
||||||
|
'phone_confirmed': True,
|
||||||
|
'phone_confirmed_at': fields.Datetime.now(),
|
||||||
|
'phone_confirmed_by': request.frontend_user.login,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
elegibles.write({'phone_confirmed': False, 'phone_confirmed_at': False, 'phone_confirmed_by': False})
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'confirmed_count': len(elegibles),
|
||||||
|
'skipped_invalid_state': skipped_invalid_state,
|
||||||
|
'not_found': not_found,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
def _agenda_conflict_payload(self, vals, exclude_id=None):
|
||||||
|
"""Detecta el origen del conflicto de agenda para los valores intentados.
|
||||||
|
|
||||||
|
Devuelve (payload_extra, http_status): {'conflicting_appointment': ...}
|
||||||
|
cuando hay solapamiento con otra cita, o {} si no se pudo identificar.
|
||||||
|
Replica la lógica de _check_availability de skeen.cita.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
date_str = vals.get('date')
|
||||||
|
time_f = float(vals.get('time'))
|
||||||
|
state = vals.get('state') or 'pending'
|
||||||
|
if not date_str or state in ('cancelled', 'no_show'):
|
||||||
|
return {}
|
||||||
|
servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0))
|
||||||
|
dur = (servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
|
||||||
|
hh, mm = int(time_f), int(round((float(time_f) % 1) * 60))
|
||||||
|
start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M')
|
||||||
|
end_dt = start_dt + timedelta(minutes=dur)
|
||||||
|
doctor_id = int(vals.get('doctor_id') or 0) or False
|
||||||
|
domain = [
|
||||||
|
('id', '!=', exclude_id),
|
||||||
|
('date', '=', date_str),
|
||||||
|
('state', 'not in', ('cancelled', 'no_show')),
|
||||||
|
('doctor_id', '=', doctor_id),
|
||||||
|
('datetime_start', '<', end_dt),
|
||||||
|
('datetime_end', '>', start_dt),
|
||||||
|
]
|
||||||
|
other = request.env['skeen.cita'].sudo().search(domain, order='time asc', limit=1)
|
||||||
|
if not other:
|
||||||
|
return {}
|
||||||
|
return {'conflicting_appointment': {
|
||||||
|
'id': other.id,
|
||||||
|
'time': other.time_str,
|
||||||
|
'patient': other.partner_id.name,
|
||||||
|
'doctor': other.doctor_id.name if other.doctor_id else 'Sin médico',
|
||||||
|
}}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
def available_slots(self, **kw):
|
def available_slots(self, **kw):
|
||||||
try:
|
try:
|
||||||
@@ -1267,6 +1408,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
|
citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
|
||||||
ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
|
ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
|
||||||
visitas = request.env['skeen.visita'].sudo().search([('partner_id', '=', patient_id)], order='date_start desc')
|
visitas = request.env['skeen.visita'].sudo().search([('partner_id', '=', patient_id)], order='date_start desc')
|
||||||
|
pagos = request.env['skeen.pago'].sudo().search(
|
||||||
|
[('partner_id', '=', patient_id)], order='payment_date desc, id desc', limit=200)
|
||||||
# Estado de cuenta: monedero, gasto total, adeudo y visitas
|
# Estado de cuenta: monedero, gasto total, adeudo y visitas
|
||||||
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', patient_id)], limit=1)
|
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', patient_id)], limit=1)
|
||||||
adeudo_rows = request.env['skeen.venta'].sudo().read_group(
|
adeudo_rows = request.env['skeen.venta'].sudo().read_group(
|
||||||
@@ -1283,12 +1426,106 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'appointments': [self._appointment_to_dict(c) for c in citas],
|
'appointments': [self._appointment_to_dict(c) for c in citas],
|
||||||
'sales': [self._sale_to_dict(v) for v in ventas],
|
'sales': [self._sale_to_dict(v) for v in ventas],
|
||||||
'visitas': [self._visita_to_dict(v) for v in visitas],
|
'visitas': [self._visita_to_dict(v) for v in visitas],
|
||||||
|
'pagos': [self._payment_to_dict(p) for p in pagos],
|
||||||
'estado_cuenta': estado_cuenta,
|
'estado_cuenta': estado_cuenta,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/patients/check_phone', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def patients_check_phone(self, **kw):
|
||||||
|
"""¿Ya existe un paciente activo con este teléfono (phone o mobile)?
|
||||||
|
|
||||||
|
Body: {"phone": "...", "exclude_id": 123 opcional}. Comparación sobre
|
||||||
|
dígitos normalizados (los últimos N dígitos coinciden), misma convención
|
||||||
|
que la cita rápida: tolera '+52 ', espacios y guiones.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _parse_json_body()
|
||||||
|
phone = (data.get('phone') or '').strip()
|
||||||
|
try:
|
||||||
|
exclude_id = int(data.get('exclude_id') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
exclude_id = 0
|
||||||
|
digits = re.sub(r'\D', '', phone)
|
||||||
|
if len(digits) < 7:
|
||||||
|
return json_response({'status': 'success', 'exists': False, 'patient': None})
|
||||||
|
candidates = request.env['res.partner'].sudo().search_read(
|
||||||
|
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
|
||||||
|
['phone', 'mobile'], limit=10000)
|
||||||
|
match_id = None
|
||||||
|
for c in candidates:
|
||||||
|
if exclude_id and c['id'] == exclude_id:
|
||||||
|
continue
|
||||||
|
for f in ('phone', 'mobile'):
|
||||||
|
c_digits = re.sub(r'\D', '', c.get(f) or '')
|
||||||
|
n = min(len(c_digits), len(digits))
|
||||||
|
if n >= 7 and c_digits[-n:] == digits[-n:]:
|
||||||
|
match_id = c['id']
|
||||||
|
break
|
||||||
|
if match_id:
|
||||||
|
break
|
||||||
|
if not match_id:
|
||||||
|
return json_response({'status': 'success', 'exists': False, 'patient': None})
|
||||||
|
patient = request.env['res.partner'].sudo().browse(match_id)
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'exists': True,
|
||||||
|
'patient': {
|
||||||
|
'id': patient.id,
|
||||||
|
'name': patient.name,
|
||||||
|
'patient_id': patient.patient_id or '',
|
||||||
|
'phone': patient.phone or '',
|
||||||
|
'mobile': patient.mobile or '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/packages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def patient_packages(self, patient_id, **kw):
|
||||||
|
"""Paquetes del paciente con sesiones restantes.
|
||||||
|
|
||||||
|
El sistema no guarda un contador de sesiones: el paquete se materializa
|
||||||
|
como las citas creadas para un servicio con `package_sessions > 0`
|
||||||
|
(create_appointment genera una cita por sesión). Por eso:
|
||||||
|
total = citas no canceladas del paciente con ese servicio
|
||||||
|
usadas = citas ya atendidas (llegó / en curso / completada)
|
||||||
|
restantes = total - usadas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
Citas = request.env['skeen.cita'].sudo()
|
||||||
|
citas = Citas.search([
|
||||||
|
('partner_id', '=', patient_id),
|
||||||
|
('state', 'not in', ('cancelled', 'no_show')),
|
||||||
|
('servicio_id.package_sessions', '>', 0),
|
||||||
|
])
|
||||||
|
grupos = {}
|
||||||
|
for c in citas:
|
||||||
|
g = grupos.setdefault(c.servicio_id.id, {
|
||||||
|
'service_id': c.servicio_id.id,
|
||||||
|
'name': c.servicio_id.name,
|
||||||
|
'package_sessions': c.servicio_id.package_sessions,
|
||||||
|
'total': 0,
|
||||||
|
'used': 0,
|
||||||
|
'next_date': None,
|
||||||
|
})
|
||||||
|
g['total'] += 1
|
||||||
|
if c.state in ('arrived', 'in_progress', 'done'):
|
||||||
|
g['used'] += 1
|
||||||
|
elif c.date and (g['next_date'] is None or c.date.strftime('%Y-%m-%d') < g['next_date']):
|
||||||
|
g['next_date'] = c.date.strftime('%Y-%m-%d')
|
||||||
|
rows = []
|
||||||
|
for g in grupos.values():
|
||||||
|
g['remaining'] = max(0, g['total'] - g['used'])
|
||||||
|
rows.append(g)
|
||||||
|
rows.sort(key=lambda r: (-r['remaining'], r['name']))
|
||||||
|
return json_response({'status': 'success', 'packages': rows})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
def expedientes_recientes(self, **kw):
|
def expedientes_recientes(self, **kw):
|
||||||
"""Pacientes con visitas recientes (últimos 30 días por default)"""
|
"""Pacientes con visitas recientes (últimos 30 días por default)"""
|
||||||
@@ -2525,6 +2762,109 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
def cancel_sale(self, sale_id, **kw):
|
||||||
|
"""Cancelación de venta con nota de crédito (motivo obligatorio).
|
||||||
|
|
||||||
|
Permiso: admin y gerencia (el rango de 'gerencia' no alcanza a
|
||||||
|
require_role('admin'), por eso el chequeo explícito).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||||
|
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
sale = request.env['skeen.venta'].sudo().browse(sale_id)
|
||||||
|
if not sale.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
|
||||||
|
data = _parse_json_body()
|
||||||
|
reason = (data.get('reason') or '').strip()
|
||||||
|
if not reason:
|
||||||
|
return json_response({'status': 'error', 'message': 'El motivo de cancelación es obligatorio'}, 400)
|
||||||
|
result = sale.action_cancel_with_reason(
|
||||||
|
reason=reason,
|
||||||
|
cancelled_by=request.frontend_user.login,
|
||||||
|
restore_stock=bool(data.get('restore_stock', True)),
|
||||||
|
)
|
||||||
|
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/sales/last', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def last_sale(self, **kw):
|
||||||
|
"""Última venta del paciente, con precios ACTUALES del catálogo.
|
||||||
|
|
||||||
|
Pensado para "Repetir última venta" en el POS: devuelve las líneas con
|
||||||
|
la cantidad vendida la última vez y el precio vigente hoy, marcando
|
||||||
|
precio cambiado / artículo faltante o inactivo.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
patient_id = int(kw.get('patient_id', 0) or 0)
|
||||||
|
if not patient_id:
|
||||||
|
return json_response({'status': 'error', 'message': 'Falta patient_id'}, 400)
|
||||||
|
Ventas = request.env['skeen.venta'].sudo()
|
||||||
|
last = Ventas.search(
|
||||||
|
[('partner_id', '=', patient_id), ('state', '!=', 'cancelled')],
|
||||||
|
order='create_date desc, id desc', limit=1)
|
||||||
|
if not last:
|
||||||
|
return json_response({'status': 'success', 'sale': None, 'lines': [], 'warnings': []})
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
warnings = []
|
||||||
|
for line in last.line_ids:
|
||||||
|
row = {
|
||||||
|
'service_id': line.service_id.id if line.service_id else None,
|
||||||
|
'item_id': line.item_id.id if line.item_id else None,
|
||||||
|
'name': line.service_id.name if line.service_id else (line.item_id.name if line.item_id else line.description),
|
||||||
|
'description': line.description or '',
|
||||||
|
'kind': 'service' if line.service_id else 'item',
|
||||||
|
'quantity': line.quantity,
|
||||||
|
'price_last': line.price_unit,
|
||||||
|
'price_current': None,
|
||||||
|
'price_changed': False,
|
||||||
|
'missing': False,
|
||||||
|
'stock': None,
|
||||||
|
'unit': None,
|
||||||
|
'active': True,
|
||||||
|
}
|
||||||
|
if line.service_id:
|
||||||
|
svc = line.service_id
|
||||||
|
row.update({
|
||||||
|
'price_current': svc.price,
|
||||||
|
'price_changed': round(svc.price, 2) != round(line.price_unit, 2),
|
||||||
|
'active': svc.active,
|
||||||
|
'missing': not svc.active,
|
||||||
|
})
|
||||||
|
if not svc.active:
|
||||||
|
warnings.append(f'{svc.name}: el servicio ya no está activo')
|
||||||
|
elif row['price_changed']:
|
||||||
|
warnings.append(f'{svc.name}: el precio cambió de ${line.price_unit:g} a ${svc.price:g}')
|
||||||
|
else:
|
||||||
|
item = line.item_id
|
||||||
|
row.update({
|
||||||
|
'price_current': item.price,
|
||||||
|
'price_changed': round((item.price or 0), 2) != round(line.price_unit, 2),
|
||||||
|
'stock': item.qty,
|
||||||
|
'unit': item.unit,
|
||||||
|
'active': item.active,
|
||||||
|
'missing': not item.active,
|
||||||
|
})
|
||||||
|
if not item.active:
|
||||||
|
warnings.append(f'{item.name}: el artículo ya no está activo')
|
||||||
|
elif row['price_changed']:
|
||||||
|
warnings.append(f'{item.name}: el precio cambió de ${line.price_unit:g} a ${(item.price or 0):g}')
|
||||||
|
lines.append(row)
|
||||||
|
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'sale': self._sale_to_dict(last),
|
||||||
|
'lines': lines,
|
||||||
|
'warnings': warnings,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
def _sale_to_dict(self, v):
|
def _sale_to_dict(self, v):
|
||||||
return {
|
return {
|
||||||
'id': v.id,
|
'id': v.id,
|
||||||
@@ -2543,6 +2883,12 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'refund_amount': v.refund_amount,
|
'refund_amount': v.refund_amount,
|
||||||
'refund_reason': v.refund_reason or '',
|
'refund_reason': v.refund_reason or '',
|
||||||
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
|
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
|
||||||
|
'notes': v.notes or '',
|
||||||
|
'cancel_reason': v.cancel_reason or '',
|
||||||
|
'cancelled_by': v.cancelled_by or '',
|
||||||
|
'cancelled_at': v.cancelled_at.strftime('%Y-%m-%d %H:%M') if v.cancelled_at else None,
|
||||||
|
'cancel_points_reversed': v.cancel_points_reversed,
|
||||||
|
'cancel_points_restored': v.cancel_points_restored,
|
||||||
'lines': [{
|
'lines': [{
|
||||||
'id': line.id,
|
'id': line.id,
|
||||||
'service_id': line.service_id.id if line.service_id else None,
|
'service_id': line.service_id.id if line.service_id else None,
|
||||||
@@ -2763,6 +3109,8 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'date': data.get('date', fields.Date.today()),
|
'date': data.get('date', fields.Date.today()),
|
||||||
'opening_cash': data.get('opening_cash', 0.0),
|
'opening_cash': data.get('opening_cash', 0.0),
|
||||||
'notes': data.get('notes', ''),
|
'notes': data.get('notes', ''),
|
||||||
|
# Atribución explícita: con auth='none' el uid de Odoo es nulo
|
||||||
|
'user_id': self._frontend_res_user().id,
|
||||||
}
|
}
|
||||||
closing = request.env['skeen.corte.caja'].sudo().create(vals)
|
closing = request.env['skeen.corte.caja'].sudo().create(vals)
|
||||||
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}, 201)
|
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}, 201)
|
||||||
@@ -2835,7 +3183,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
|
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
|
||||||
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
|
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
|
||||||
Ventas = request.env['skeen.venta'].sudo()
|
Ventas = request.env['skeen.venta'].sudo()
|
||||||
ventas = Ventas.search([('date', '>=', start), ('date', '<=', end)])
|
# Las ventas canceladas (nota de crédito) no cuentan como ingreso
|
||||||
|
ventas = Ventas.search([
|
||||||
|
('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled'),
|
||||||
|
])
|
||||||
# Ingresos agrupados por día (clave nueva; lo demás no cambia)
|
# Ingresos agrupados por día (clave nueva; lo demás no cambia)
|
||||||
by_day_map = {}
|
by_day_map = {}
|
||||||
for v in ventas:
|
for v in ventas:
|
||||||
@@ -2901,8 +3252,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
citas_by_state = {}
|
citas_by_state = {}
|
||||||
for c in citas:
|
for c in citas:
|
||||||
citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
|
citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
|
||||||
# Ventas del día
|
# Ventas del día (excluye canceladas: no son ingreso)
|
||||||
ventas = request.env['skeen.venta'].sudo().search([('date', '=', date_str)])
|
ventas = request.env['skeen.venta'].sudo().search([
|
||||||
|
('date', '=', date_str), ('state', '!=', 'cancelled'),
|
||||||
|
])
|
||||||
# Cobros del día por método (misma convención que report_cash)
|
# Cobros del día por método (misma convención que report_cash)
|
||||||
pagos = request.env['skeen.pago'].sudo().search([
|
pagos = request.env['skeen.pago'].sudo().search([
|
||||||
('payment_date', '>=', f'{date_str} 00:00:00'),
|
('payment_date', '>=', f'{date_str} 00:00:00'),
|
||||||
@@ -4098,7 +4451,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
"""Movimientos de inventario (filtros: tipo, rango de fechas, paginado)"""
|
"""Movimientos de inventario (filtros: tipo, rango de fechas, paginado)"""
|
||||||
try:
|
try:
|
||||||
domain = []
|
domain = []
|
||||||
if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste'):
|
if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste', 'cancelacion'):
|
||||||
domain.append(('type', '=', kw['type']))
|
domain.append(('type', '=', kw['type']))
|
||||||
if kw.get('date_from'):
|
if kw.get('date_from'):
|
||||||
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
|
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
|
||||||
|
|||||||
Reference in New Issue
Block a user