Oleada UX 4: Pagos/Adeudos (cobro en 1 click con recibo, plan de pagos), Monedero (movimientos narrados, ajustes auditados, MXN+USD), Inventario (alta rapida, conteo fisico con ajuste e historial, alertas accionables) + fix de folios de pago y cantidades de movimientos
This commit is contained in:
150
frontend/src/components/ReciboPrint.tsx
Normal file
150
frontend/src/components/ReciboPrint.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { FC } from 'react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button, Modal } from './ui';
|
||||
import type { ReceiptData } from '../services/odoo';
|
||||
|
||||
// Recibo de pago imprimible. Sigue el patrón de impresión existente
|
||||
// (TicketPrint / CortePrint / EstadoCuentaPrint): contenido en un
|
||||
// div.print-recibo + window.print(); el CSS de @media print en index.css
|
||||
// oculta el resto de la página.
|
||||
|
||||
const METODO_LABELS: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
|
||||
const money = (v: number) =>
|
||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
const clinicSettings = (): ClinicSettings => {
|
||||
try {
|
||||
const saved = localStorage.getItem('skeen_clinic_settings');
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name: 'SKEEN Derma Experts', phone: '', address: '' };
|
||||
};
|
||||
|
||||
export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void }> = ({ receipt, onClose }) => {
|
||||
if (!receipt) return null;
|
||||
const clinic = clinicSettings();
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!receipt} onClose={onClose} title={`Recibo de pago ${receipt.folio}`} maxWidth="md">
|
||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
||||
{clinic.address && <p className="text-[11px] text-[#555]">{clinic.address}</p>}
|
||||
{clinic.phone && <p className="text-[11px] text-[#555]">{clinic.phone}</p>}
|
||||
<p className="font-semibold mt-2 tracking-wide">RECIBO DE PAGO</p>
|
||||
<p className="text-[11px] text-[#555]">Folio {receipt.folio} · {receipt.date}</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[12px] mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555] w-32">Paciente</td>
|
||||
<td className="py-0.5 font-medium">{receipt.patient}</td>
|
||||
</tr>
|
||||
{receipt.patient_code && (
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Expediente</td>
|
||||
<td className="py-0.5">{receipt.patient_code}</td>
|
||||
</tr>
|
||||
)}
|
||||
{receipt.phone && (
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Teléfono</td>
|
||||
<td className="py-0.5">{receipt.phone}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Método de pago</td>
|
||||
<td className="py-0.5">{METODO_LABELS[receipt.payment_method] || receipt.payment_method}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2 text-[#555]">Registró</td>
|
||||
<td className="py-0.5">{receipt.operator || '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{receipt.aplicado.length > 0 && (
|
||||
<table className="w-full text-[11px] mb-3">
|
||||
<thead>
|
||||
<tr className="border-y border-[#ccc] text-[10px] uppercase text-[#666]">
|
||||
<th className="text-left py-1">Venta</th>
|
||||
<th className="text-left py-1">Fecha</th>
|
||||
<th className="text-right py-1">Aplicado</th>
|
||||
<th className="text-right py-1">Saldo venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receipt.aplicado.map((a) => (
|
||||
<tr key={a.id} className="border-b border-[#eee]">
|
||||
<td className="py-1">{a.name}</td>
|
||||
<td className="py-1 text-[#555]">{a.date || '—'}</td>
|
||||
<td className="py-1 text-right">{money(a.applied)}</td>
|
||||
<td className="py-1 text-right text-[#555]">{money(a.amount_due)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<table className="w-full text-[13px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Adeudo anterior</td>
|
||||
<td className="py-1 text-right">{money(receipt.adeudo_anterior)}</td>
|
||||
</tr>
|
||||
<tr className="font-bold border-y border-[#1A1A1A]">
|
||||
<td className="py-1.5 pr-2">Total recibido</td>
|
||||
<td className="py-1.5 text-right">{money(receipt.amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Saldo restante</td>
|
||||
<td className={`py-1 text-right font-semibold ${receipt.saldo_restante > 0 ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
{money(receipt.saldo_restante)}
|
||||
</td>
|
||||
</tr>
|
||||
{receipt.wallet_points > 0 && (
|
||||
<tr>
|
||||
<td className="py-1 pr-2 text-[#555]">Puntos monedero</td>
|
||||
<td className="py-1 text-right text-[#555]">{receipt.wallet_points} pts</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 text-[11px] text-[#555]">
|
||||
{receipt.saldo_restante > 0
|
||||
? 'El paciente mantiene saldo pendiente. Este recibo ampara el pago parcial recibido.'
|
||||
: 'El adeudo queda liquidado. Gracias por su pago.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-between text-[11px] text-[#555]">
|
||||
<span>_____________________<br />Recibió (caja)</span>
|
||||
<span>_____________________<br />Paciente</span>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] text-[#888]">Impreso el {new Date().toLocaleString('es-MX')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={14} className="mr-1.5" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReciboPrint;
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CalendarX, ShoppingCart } from 'lucide-react';
|
||||
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { CalendarX, ShoppingCart, ArrowUpFromLine } from 'lucide-react';
|
||||
import { Card, Button, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import MovimientoModal from './MovimientoModal';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
@@ -22,6 +23,8 @@ const AlertasPanel: FC = () => {
|
||||
const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// Acción directa de compra/baja desde la alerta (item precargado)
|
||||
const [movModal, setMovModal] = useState<{ tipo: 'compra' | 'baja'; itemId: number; qty: number } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -95,6 +98,7 @@ const AlertasPanel: FC = () => {
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-left px-4 py-3">Caduca</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
<th className="text-right px-4 py-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -107,6 +111,21 @@ const AlertasPanel: FC = () => {
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.qty} {c.unit}</td>
|
||||
<td className="px-4 py-3 text-theme-heading">{c.expiry_date}</td>
|
||||
<td className="px-4 py-3"><Badge variant={b.variant}>{b.label}</Badge></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{c.vencido && c.qty > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMovModal({ tipo: 'baja', itemId: c.id, qty: c.qty })}
|
||||
title="Dar de baja el artículo caducado"
|
||||
>
|
||||
<ArrowUpFromLine size={13} className="mr-1.5" />
|
||||
Dar de baja
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-theme-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -144,7 +163,8 @@ const AlertasPanel: FC = () => {
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
|
||||
<th className="text-right px-4 py-3">Sugerido</th>
|
||||
<th className="text-right px-4 py-3">Costo est.</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Costo est.</th>
|
||||
<th className="text-right px-4 py-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -155,7 +175,18 @@ const AlertasPanel: FC = () => {
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{s.qty_min || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{s.qty_optimal || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">+{s.sugerido} {s.unit}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(s.costo_estimado)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading hidden md:table-cell">{fmtMoney(s.costo_estimado)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMovModal({ tipo: 'compra', itemId: s.id, qty: s.sugerido })}
|
||||
title="Registrar la compra sugerida de este artículo"
|
||||
>
|
||||
<ShoppingCart size={13} className="mr-1.5" />
|
||||
Registrar compra
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -171,6 +202,14 @@ const AlertasPanel: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<MovimientoModal
|
||||
isOpen={!!movModal}
|
||||
tipo={movModal?.tipo || 'compra'}
|
||||
initialItemId={movModal?.itemId ?? null}
|
||||
initialQty={movModal?.qty ?? null}
|
||||
onClose={() => setMovModal(null)}
|
||||
onSaved={() => { load(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, ClipboardCheck } from 'lucide-react';
|
||||
import { Button, Input, Modal, Skeleton, toast } from '../ui';
|
||||
import { odooApi, type InventoryItem, type InventoryConteo } from '../../services/odoo';
|
||||
|
||||
const money = (v: number) =>
|
||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
interface ConteoFisicoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Devuelve el conteo guardado (con diferencias) para imprimir el resumen. */
|
||||
onSaved: (conteo: InventoryConteo) => void;
|
||||
}
|
||||
|
||||
// Conteo físico cíclico: lista los artículos activos con el stock del sistema
|
||||
// prellenado y aplica un ajuste por cada diferencia al guardar.
|
||||
export const ConteoFisicoModal: FC<ConteoFisicoModalProps> = ({ isOpen, onClose, onSaved }) => {
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [contados, setContados] = useState<Record<number, string>>({});
|
||||
const [notes, setNotes] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
setContados({});
|
||||
setNotes('');
|
||||
setSearch('');
|
||||
odooApi.getInventory({})
|
||||
.then((res) => {
|
||||
const activos = (res.items || []).filter((i) => i.active !== false);
|
||||
setItems(activos);
|
||||
setContados(Object.fromEntries(activos.map((i) => [i.id, String(i.qty)])));
|
||||
})
|
||||
.catch(() => toast.error('No se pudo cargar el inventario'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen]);
|
||||
|
||||
const visibles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((i) =>
|
||||
[i.name, i.sku, i.category].filter(Boolean).some((v) => String(v).toLowerCase().includes(q)));
|
||||
}, [items, search]);
|
||||
|
||||
const valorContado = (it: InventoryItem) => {
|
||||
const raw = contados[it.id];
|
||||
if (raw === undefined || raw === '') return it.qty;
|
||||
const n = parseFloat(raw);
|
||||
return Number.isNaN(n) ? it.qty : n;
|
||||
};
|
||||
|
||||
const resumen = useMemo(() => {
|
||||
let dif = 0;
|
||||
let faltante = 0;
|
||||
let sobrante = 0;
|
||||
for (const it of items) {
|
||||
const d = valorContado(it) - it.qty;
|
||||
if (Math.abs(d) < 1e-9) continue;
|
||||
dif += 1;
|
||||
const valor = d * (it.cost || 0);
|
||||
if (valor < 0) faltante += valor;
|
||||
else sobrante += valor;
|
||||
}
|
||||
return { dif, faltante, sobrante, neto: faltante + sobrante };
|
||||
}, [items, contados]);
|
||||
|
||||
const guardar = async () => {
|
||||
if (items.length === 0) {
|
||||
toast.error('No hay artículos activos para contar');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await odooApi.createConteo({
|
||||
notes,
|
||||
lines: items.map((i) => ({ item_id: i.id, qty_contado: valorContado(i) })),
|
||||
});
|
||||
toast.success(
|
||||
`${res.conteo.name} guardado · ${res.ajustes} ${res.ajustes === 1 ? 'ajuste aplicado' : 'ajustes aplicados'}`,
|
||||
);
|
||||
onClose();
|
||||
onSaved(res.conteo);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar el conteo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Conteo físico de inventario"
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<div className="text-xs text-theme-muted">
|
||||
{resumen.dif} con diferencia · faltante <span className="text-rose-600 font-medium">{money(resumen.faltante)}</span> ·
|
||||
sobrante <span className="text-emerald-700 font-medium">{money(resumen.sobrante)}</span> · neto{' '}
|
||||
<span className="font-medium text-theme-heading">{money(resumen.neto)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>
|
||||
<ClipboardCheck size={14} className="mr-1.5" />
|
||||
Guardar conteo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-theme-muted">
|
||||
Captura la existencia real de cada artículo. El campo viene prellenado con la existencia del sistema;
|
||||
solo cambia lo que no coincida. Al guardar se genera un movimiento de <span className="font-medium">ajuste</span> por
|
||||
cada diferencia y queda el historial del conteo.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Filtrar artículos por nombre, SKU o categoría..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-10 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-6 text-center">No hay artículos activos en el inventario.</p>
|
||||
) : (
|
||||
<div className="max-h-[45vh] overflow-y-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Artículo</th>
|
||||
<th className="text-left px-3 py-2 hidden sm:table-cell">Tipo</th>
|
||||
<th className="text-right px-3 py-2">Sistema</th>
|
||||
<th className="text-right px-3 py-2 w-32">Contado</th>
|
||||
<th className="text-right px-3 py-2">Diferencia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{visibles.map((it) => {
|
||||
const diff = valorContado(it) - it.qty;
|
||||
return (
|
||||
<tr key={it.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2">
|
||||
<p className="font-medium text-theme-heading">{it.name}</p>
|
||||
<p className="text-xs text-theme-muted">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
|
||||
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{it.qty} {it.unit}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
aria-label={`Contado ${it.name}`}
|
||||
value={contados[it.id] ?? String(it.qty)}
|
||||
onChange={(e) => setContados({ ...contados, [it.id]: e.target.value })}
|
||||
className={`w-24 border rounded-lg px-2 py-1 text-sm text-right ${
|
||||
Math.abs(diff) > 1e-9 ? 'border-amber-400 bg-amber-50' : 'border-theme-border-strong'
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-medium ${
|
||||
diff < 0 ? 'text-rose-600' : diff > 0 ? 'text-emerald-700' : 'text-theme-muted'
|
||||
}`}>
|
||||
{Math.abs(diff) < 1e-9 ? '—' : `${diff > 0 ? '+' : ''}${Math.round(diff * 100) / 100}`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Notas del conteo"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Ej. conteo mensual de consumibles"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConteoFisicoModal;
|
||||
118
frontend/src/components/inventario/ConteoPrint.tsx
Normal file
118
frontend/src/components/inventario/ConteoPrint.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { FC } from 'react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button, Modal } from '../ui';
|
||||
import type { InventoryConteo } from '../../services/odoo';
|
||||
|
||||
// Resumen imprimible del conteo físico. Sigue el patrón print-* existente
|
||||
// (TicketPrint / CortePrint): div.print-recibo + window.print().
|
||||
|
||||
const money = (v: number) =>
|
||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
const clinicSettings = (): ClinicSettings => {
|
||||
try {
|
||||
const saved = localStorage.getItem('skeen_clinic_settings');
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name: 'SKEEN Derma Experts', phone: '', address: '' };
|
||||
};
|
||||
|
||||
export const ConteoPrint: FC<{ conteo: InventoryConteo | null; onClose: () => void }> = ({ conteo, onClose }) => {
|
||||
if (!conteo) return null;
|
||||
const clinic = clinicSettings();
|
||||
const lineas = conteo.lines || [];
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!conteo} onClose={onClose} title={`Conteo físico ${conteo.name}`} maxWidth="lg">
|
||||
<div className="print-recibo bg-white text-[#222] p-6 rounded-xl border border-theme-border text-[13px] leading-snug">
|
||||
<div className="text-center mb-4 border-b border-[#ccc] pb-3">
|
||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
||||
<p className="font-semibold mt-2 tracking-wide">CONTEO FÍSICO DE INVENTARIO</p>
|
||||
<p className="text-[11px] text-[#555]">
|
||||
{conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-y border-[#ccc] text-[10px] uppercase text-[#666]">
|
||||
<th className="text-left py-1">Artículo</th>
|
||||
<th className="text-right py-1 w-20">Sistema</th>
|
||||
<th className="text-right py-1 w-20">Contado</th>
|
||||
<th className="text-right py-1 w-24">Diferencia</th>
|
||||
<th className="text-right py-1 w-24">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineas.map((l) => (
|
||||
<tr key={l.id} className="border-b border-[#eee]">
|
||||
<td className="py-1">{l.item}</td>
|
||||
<td className="py-1 text-right text-[#555]">{l.qty_sistema} {l.unit}</td>
|
||||
<td className="py-1 text-right">{l.qty_contado} {l.unit}</td>
|
||||
<td className={`py-1 text-right font-semibold ${l.difference < 0 ? 'text-rose-700' : l.difference > 0 ? 'text-emerald-700' : 'text-[#555]'}`}>
|
||||
{l.difference > 0 ? '+' : ''}{l.difference}
|
||||
</td>
|
||||
<td className="py-1 text-right text-[#555]">{money(l.value_diff)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="w-full text-[12px] mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Artículos contados</td>
|
||||
<td className="py-0.5 text-right font-medium">{conteo.total_items}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Artículos con diferencia</td>
|
||||
<td className="py-0.5 text-right font-medium">{conteo.total_diferencias}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Faltante valorizado</td>
|
||||
<td className="py-0.5 text-right font-medium text-rose-700">{money(conteo.total_faltante)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-2">Sobrante valorizado</td>
|
||||
<td className="py-0.5 text-right font-medium text-emerald-700">{money(conteo.total_sobrante)}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-[#1A1A1A] font-bold">
|
||||
<td className="py-1 pr-2">Diferencia neta</td>
|
||||
<td className="py-1 text-right">{money(conteo.total_value_diff)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{conteo.notes && (
|
||||
<div className="mt-3 text-[12px]">
|
||||
<p className="font-semibold">Notas:</p>
|
||||
<p className="whitespace-pre-wrap">{conteo.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-between text-[11px] text-[#555]">
|
||||
<span>_____________________<br />Contó: {conteo.user_name || ''}</span>
|
||||
<span>_____________________<br />Revisó (administración)</span>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-[10px] text-[#888]">Impreso el {new Date().toLocaleString('es-MX')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={14} className="mr-1.5" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConteoPrint;
|
||||
171
frontend/src/components/inventario/MovimientoModal.tsx
Normal file
171
frontend/src/components/inventario/MovimientoModal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Modal, toast } from '../ui';
|
||||
import { odooApi, type InventoryItem } from '../../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
interface MovimientoModalProps {
|
||||
isOpen: boolean;
|
||||
tipo: 'compra' | 'baja';
|
||||
/** Artículo precargado (acciones directas desde alertas). */
|
||||
initialItemId?: number | null;
|
||||
initialQty?: number | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// Modal de registro de compra / baja, reutilizado por el panel de movimientos
|
||||
// y por las acciones directas de la lista de alertas.
|
||||
export const MovimientoModal: FC<MovimientoModalProps> = ({
|
||||
isOpen,
|
||||
tipo,
|
||||
initialItemId = null,
|
||||
initialQty = null,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const esCompra = tipo === 'compra';
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [itemSearch, setItemSearch] = useState('');
|
||||
const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
odooApi.getInventarioItems(itemSearch || undefined)
|
||||
.then((res) => { if (res.status === 'success') setItems(res.items); })
|
||||
.catch(() => {});
|
||||
}, [isOpen, itemSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setForm({
|
||||
item_id: initialItemId ? String(initialItemId) : '',
|
||||
qty: initialQty ? String(initialQty) : '1',
|
||||
cost: '',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
setItemSearch('');
|
||||
}, [isOpen, initialItemId, initialQty, tipo]);
|
||||
|
||||
// El artículo precargado puede no venir en la primera página del catálogo
|
||||
useEffect(() => {
|
||||
if (!isOpen || !initialItemId) return;
|
||||
if (items.some((i) => i.id === initialItemId)) return;
|
||||
odooApi.getInventarioItems()
|
||||
.then((res) => {
|
||||
if (res.status !== 'success') return;
|
||||
setItems((prev) => {
|
||||
const extra = res.items.filter((i) => !prev.some((p) => p.id === i.id));
|
||||
return [...prev, ...extra];
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [isOpen, initialItemId, items]);
|
||||
|
||||
const guardar = async () => {
|
||||
const qty = parseFloat(form.qty);
|
||||
if (!form.item_id) {
|
||||
toast.error('Selecciona un artículo');
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(qty) || qty <= 0) {
|
||||
toast.error('La cantidad debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
item_id: parseInt(form.item_id, 10),
|
||||
qty,
|
||||
reference: form.reference,
|
||||
notes: form.notes,
|
||||
...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}),
|
||||
};
|
||||
const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload);
|
||||
if (res.status === 'success') {
|
||||
toast.success(esCompra ? 'Compra registrada' : 'Baja registrada');
|
||||
onClose();
|
||||
onSaved();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al registrar el movimiento');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Artículo *"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
|
||||
]}
|
||||
value={form.item_id}
|
||||
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad *"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={form.qty}
|
||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
{esCompra && (
|
||||
<Input
|
||||
label="Costo unitario (actualiza el costo del artículo)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onChange={(e) => setForm({ ...form, cost: e.target.value })}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={esCompra ? 'Notas' : 'Motivo'}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder={esCompra ? undefined : 'Ej. caducado, dañado, merma'}
|
||||
/>
|
||||
{esCompra && form.cost !== '' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovimientoModal;
|
||||
@@ -5,16 +5,12 @@ import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../ui';
|
||||
import { odooApi, type MovimientoRow, type InventoryItem } from '../../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
import MovimientoModal from './MovimientoModal';
|
||||
import { odooApi, type MovimientoRow } from '../../services/odoo';
|
||||
|
||||
interface MovimientosPanelProps {
|
||||
tipo: 'compra' | 'baja';
|
||||
@@ -30,10 +26,6 @@ const MovimientosPanel: FC<MovimientosPanelProps> = ({ tipo }) => {
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [itemSearch, setItemSearch] = useState('');
|
||||
const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -57,53 +49,7 @@ const MovimientosPanel: FC<MovimientosPanelProps> = ({ tipo }) => {
|
||||
useEffect(() => { setPage(1); }, [search, tipo]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Catálogo de artículos para el select del modal
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return;
|
||||
odooApi.getInventarioItems(itemSearch || undefined)
|
||||
.then((res) => { if (res.status === 'success') setItems(res.items); })
|
||||
.catch(() => {});
|
||||
}, [modalOpen, itemSearch]);
|
||||
|
||||
const openModal = () => {
|
||||
setForm({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
|
||||
setItemSearch('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const guardar = async () => {
|
||||
const qty = parseFloat(form.qty);
|
||||
if (!form.item_id) {
|
||||
toast.error('Selecciona un artículo');
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(qty) || qty <= 0) {
|
||||
toast.error('La cantidad debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
item_id: parseInt(form.item_id, 10),
|
||||
qty,
|
||||
reference: form.reference,
|
||||
notes: form.notes,
|
||||
...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}),
|
||||
};
|
||||
const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload);
|
||||
if (res.status === 'success') {
|
||||
toast.success(esCompra ? 'Compra registrada' : 'Baja registrada');
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al registrar el movimiento');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const openModal = () => setModalOpen(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -184,70 +130,12 @@ const MovimientosPanel: FC<MovimientosPanelProps> = ({ tipo }) => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal registrar movimiento */}
|
||||
<Modal
|
||||
<MovimientoModal
|
||||
isOpen={modalOpen}
|
||||
tipo={tipo}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Artículo *"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
|
||||
]}
|
||||
value={form.item_id}
|
||||
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad *"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={form.qty}
|
||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
{esCompra && (
|
||||
<Input
|
||||
label="Costo unitario (actualiza el costo del artículo)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onChange={(e) => setForm({ ...form, cost: e.target.value })}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={esCompra ? 'Notas' : 'Motivo'}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
{esCompra && form.cost !== '' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
onSaved={() => { load(); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HandCoins } from 'lucide-react';
|
||||
import { Card, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { HandCoins, CreditCard, CalendarClock, X } from 'lucide-react';
|
||||
import { Card, Button, Input, Select, Modal, TextArea, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type AdeudoRow } from '../../services/odoo';
|
||||
import ReciboPrint from '../ReciboPrint';
|
||||
import { odooApi, type AdeudoRow, type PaymentPlan, type ReceiptData } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const formatCurrency2 = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
|
||||
|
||||
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
|
||||
|
||||
// "2026-10-15" -> "15-oct"
|
||||
const fechaCorta = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
const [y, m, d] = iso.split('-').map((v) => parseInt(v, 10));
|
||||
if (!y || !m || !d) return iso;
|
||||
return `${d}-${MESES_CORTOS[m - 1]}`;
|
||||
};
|
||||
|
||||
const METODOS = [
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'card', label: 'Tarjeta' },
|
||||
{ value: 'transfer', label: 'Transferencia' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'mercadopago', label: 'MercadoPago' },
|
||||
];
|
||||
|
||||
const AdeudosReport: FC = () => {
|
||||
const [adeudos, setAdeudos] = useState<AdeudoRow[]>([]);
|
||||
const [totalCartera, setTotalCartera] = useState(0);
|
||||
const [totalPacientes, setTotalPacientes] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Cobro del adeudo
|
||||
const [pagoAdeudo, setPagoAdeudo] = useState<AdeudoRow | null>(null);
|
||||
const [monto, setMonto] = useState('');
|
||||
const [metodo, setMetodo] = useState('cash');
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
const [recibo, setRecibo] = useState<ReceiptData | null>(null);
|
||||
|
||||
// Plan de pagos
|
||||
const [planAdeudo, setPlanAdeudo] = useState<AdeudoRow | null>(null);
|
||||
const [planCount, setPlanCount] = useState('3');
|
||||
const [planAmount, setPlanAmount] = useState('');
|
||||
const [planNext, setPlanNext] = useState('');
|
||||
const [planNote, setPlanNote] = useState('');
|
||||
const [guardandoPlan, setGuardandoPlan] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -50,8 +87,12 @@ const AdeudosReport: FC = () => {
|
||||
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
|
||||
{ header: 'Adeudo', key: 'total_adeudo', format: 'currency' },
|
||||
{ header: 'Última venta', key: 'ultima_venta', format: 'date' },
|
||||
{ header: 'Plan', key: 'plan_txt' },
|
||||
],
|
||||
rows: adeudos.map((a) => ({ ...a })),
|
||||
rows: adeudos.map((a) => ({
|
||||
...a,
|
||||
plan_txt: a.plan ? `${a.plan.count} pagos de ${formatCurrency(a.plan.amount)}` : '',
|
||||
})),
|
||||
totals: { name: '', total_adeudo: totalCartera, num_ventas: adeudos.reduce((a, x) => a + x.num_ventas, 0) },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
@@ -63,6 +104,105 @@ const AdeudosReport: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const abrirPago = (a: AdeudoRow) => {
|
||||
setPagoAdeudo(a);
|
||||
setMonto(String(a.total_adeudo));
|
||||
setMetodo('cash');
|
||||
};
|
||||
|
||||
const cobrar = async () => {
|
||||
if (!pagoAdeudo) return;
|
||||
const value = parseFloat(monto);
|
||||
if (Number.isNaN(value) || value <= 0) {
|
||||
toast.error('Ingresa un monto válido');
|
||||
return;
|
||||
}
|
||||
if (value > pagoAdeudo.total_adeudo + 0.01) {
|
||||
toast.error(`El monto no puede exceder el adeudo (${formatCurrency(pagoAdeudo.total_adeudo)})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCobrando(true);
|
||||
const res = await odooApi.payAdeudo(pagoAdeudo.id, { amount: value, payment_method: metodo });
|
||||
if (res.status === 'success') {
|
||||
setPagoAdeudo(null);
|
||||
setRecibo(res.receipt);
|
||||
toast.success(`Pago ${res.receipt.folio} registrado · saldo ${formatCurrency(res.receipt.saldo_restante)}`);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al registrar el pago');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCobrando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const abrirPlan = (a: AdeudoRow) => {
|
||||
const plan: PaymentPlan | null = a.plan || null;
|
||||
setPlanAdeudo(a);
|
||||
setPlanCount(String(plan?.count || 3));
|
||||
setPlanAmount(String(plan?.amount || Math.ceil(a.total_adeudo / 3)));
|
||||
setPlanNext(plan?.next_payment_date || '');
|
||||
setPlanNote(plan?.note || '');
|
||||
};
|
||||
|
||||
const recalcularMonto = (count: string) => {
|
||||
setPlanCount(count);
|
||||
if (!planAdeudo) return;
|
||||
const n = parseInt(count, 10);
|
||||
if (n > 0) setPlanAmount(String(Math.ceil(planAdeudo.total_adeudo / n)));
|
||||
};
|
||||
|
||||
const guardarPlan = async () => {
|
||||
if (!planAdeudo) return;
|
||||
const n = parseInt(planCount, 10);
|
||||
const amt = parseFloat(planAmount);
|
||||
if (Number.isNaN(n) || n <= 0) {
|
||||
toast.error('Ingresa el número de pagos acordados');
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(amt) || amt <= 0) {
|
||||
toast.error('Ingresa el monto por pago');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGuardandoPlan(true);
|
||||
await odooApi.setPaymentPlan(planAdeudo.id, {
|
||||
count: n,
|
||||
amount: amt,
|
||||
next_payment_date: planNext || null,
|
||||
note: planNote,
|
||||
});
|
||||
toast.success(`Plan de ${n} pagos registrado`);
|
||||
setPlanAdeudo(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar el plan');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setGuardandoPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quitarPlan = async () => {
|
||||
if (!planAdeudo) return;
|
||||
try {
|
||||
setGuardandoPlan(true);
|
||||
await odooApi.setPaymentPlan(planAdeudo.id, { count: 0, amount: 0 });
|
||||
toast.success('Plan de pagos eliminado');
|
||||
setPlanAdeudo(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al quitar el plan');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setGuardandoPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="flex justify-end">
|
||||
@@ -94,9 +234,10 @@ const AdeudosReport: FC = () => {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-right px-4 py-3"># Ventas</th>
|
||||
<th className="text-right px-4 py-3">Adeudo</th>
|
||||
<th className="text-left px-4 py-3 hidden lg:table-cell">Plan</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Última venta</th>
|
||||
<th className="text-right px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
@@ -112,9 +253,39 @@ const AdeudosReport: FC = () => {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{a.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{a.num_ventas}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-rose-600">{formatCurrency(a.total_adeudo)}</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{a.plan ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-violet-300 bg-violet-50 px-2.5 py-1 text-[11px] font-medium text-violet-800"
|
||||
title={a.plan.note || `${a.plan.paid_count} de ${a.plan.count} pagos cubiertos`}
|
||||
>
|
||||
<CalendarClock size={11} />
|
||||
Plan: {a.plan.count} pagos
|
||||
{a.plan.remaining_count < a.plan.count ? ` · ${a.plan.remaining_count} restantes` : ''}
|
||||
{a.plan.next_payment_date ? ` · siguiente ${fechaCorta(a.plan.next_payment_date)}` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-theme-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{a.ultima_venta || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button size="sm" onClick={() => abrirPago(a)} title="Registrar pago del adeudo">
|
||||
<CreditCard size={14} className="mr-1.5" />
|
||||
Registrar pago
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => abrirPlan(a)}
|
||||
title="Acordar plan de pagos"
|
||||
>
|
||||
<CalendarClock size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -123,6 +294,138 @@ const AdeudosReport: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal registrar pago del adeudo */}
|
||||
<Modal
|
||||
isOpen={!!pagoAdeudo}
|
||||
onClose={() => setPagoAdeudo(null)}
|
||||
title={pagoAdeudo ? `Registrar pago · ${pagoAdeudo.name}` : 'Registrar pago'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPagoAdeudo(null)}>Cancelar</Button>
|
||||
<Button onClick={cobrar} loading={cobrando}>Registrar pago</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{pagoAdeudo && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Adeudo pendiente</p>
|
||||
<p className="text-lg font-semibold text-rose-600">{formatCurrency2(pagoAdeudo.total_adeudo)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Ventas con saldo</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{pagoAdeudo.num_ventas}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Monto a pagar"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
max={pagoAdeudo.total_adeudo}
|
||||
value={monto}
|
||||
onChange={(e) => setMonto(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[0.25, 0.5, 1].map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setMonto(String(Math.round(pagoAdeudo.total_adeudo * f * 100) / 100))}
|
||||
>
|
||||
{f === 1 ? 'Liquidar todo' : `${f * 100}%`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select label="Método de pago" options={METODOS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{pagoAdeudo.plan && (
|
||||
<p className="text-xs text-violet-800 bg-violet-50 border border-violet-200 rounded-lg p-2.5">
|
||||
Plan vigente: {pagoAdeudo.plan.count} pagos de {formatCurrency2(pagoAdeudo.plan.amount)}
|
||||
{pagoAdeudo.plan.next_payment_date ? ` · siguiente ${pagoAdeudo.plan.next_payment_date}` : ''}
|
||||
{` · ${pagoAdeudo.plan.remaining_count} por cubrir`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-theme-muted">
|
||||
El pago se aplica a las ventas con saldo, de la más antigua a la más reciente. Se puede pagar parcial.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal plan de pagos */}
|
||||
<Modal
|
||||
isOpen={!!planAdeudo}
|
||||
onClose={() => setPlanAdeudo(null)}
|
||||
title={planAdeudo ? `Plan de pagos · ${planAdeudo.name}` : 'Plan de pagos'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
{planAdeudo?.plan && (
|
||||
<Button variant="ghost" onClick={quitarPlan} loading={guardandoPlan}>
|
||||
<X size={14} className="mr-1.5" />
|
||||
Quitar plan
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setPlanAdeudo(null)}>Cancelar</Button>
|
||||
<Button onClick={guardarPlan} loading={guardandoPlan}>Guardar plan</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{planAdeudo && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Adeudo actual: <span className="font-semibold text-rose-600">{formatCurrency2(planAdeudo.total_adeudo)}</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Pagos acordados *"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={planCount}
|
||||
onChange={(e) => recalcularMonto(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Monto por pago *"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={planAmount}
|
||||
onChange={(e) => setPlanAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Fecha del siguiente pago"
|
||||
type="date"
|
||||
value={planNext}
|
||||
onChange={(e) => setPlanNext(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Nota del acuerdo"
|
||||
rows={2}
|
||||
value={planNote}
|
||||
onChange={(e) => setPlanNote(e.target.value)}
|
||||
placeholder="Ej. acordado por teléfono, paga los días 15"
|
||||
/>
|
||||
{planAdeudo.plan && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Plan actual: {planAdeudo.plan.count} pagos de {formatCurrency2(planAdeudo.plan.amount)} ·{' '}
|
||||
{planAdeudo.plan.paid_count} cubiertos, {planAdeudo.plan.remaining_count} por cubrir
|
||||
{planAdeudo.plan.by ? ` · registrado por ${planAdeudo.plan.by}` : ''}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-theme-muted">
|
||||
Solo registro y seguimiento: el sistema no envía recordatorios automáticos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ReciboPrint receipt={recibo} onClose={() => setRecibo(null)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -259,12 +259,15 @@ p {
|
||||
.print-receta,
|
||||
.print-receta *,
|
||||
.print-agenda,
|
||||
.print-agenda * {
|
||||
.print-agenda *,
|
||||
.print-recibo,
|
||||
.print-recibo * {
|
||||
visibility: visible;
|
||||
}
|
||||
.print-ticket,
|
||||
.print-receta,
|
||||
.print-agenda {
|
||||
.print-agenda,
|
||||
.print-recibo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -284,7 +287,7 @@ p {
|
||||
modal deja de ser un overlay fijo (los elementos `fixed` se repiten en
|
||||
cada página impresa y anclan el contenido a su caja centrada) y se libera
|
||||
el ancho/alto del diálogo para que el documento fluya desde el inicio. */
|
||||
body:has(.print-agenda) .print-modal-overlay {
|
||||
body:has(.print-agenda, .print-recibo) .print-modal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -294,28 +297,29 @@ p {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
body:has(.print-agenda) .print-modal-content {
|
||||
body:has(.print-agenda, .print-recibo) .print-modal-content {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
body:has(.print-agenda) .print-modal-body {
|
||||
body:has(.print-agenda, .print-recibo) .print-modal-body {
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
body:has(.print-agenda) .print-modal-header,
|
||||
body:has(.print-agenda) .print-modal-footer {
|
||||
body:has(.print-agenda, .print-recibo) .print-modal-header,
|
||||
body:has(.print-agenda, .print-recibo) .print-modal-footer {
|
||||
display: none;
|
||||
}
|
||||
/* El resto de la app queda oculto (visibility) pero seguiría ocupando su
|
||||
altura y generaría páginas en blanco al final: se colapsa su caja. El
|
||||
overlay no se recorta porque su bloque contenedor es la página. */
|
||||
body:has(.print-agenda) > #root {
|
||||
body:has(.print-agenda, .print-recibo) > #root {
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.print-agenda {
|
||||
.print-agenda,
|
||||
.print-recibo {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet } from 'lucide-react';
|
||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet, FlaskConical, ClipboardCheck, Printer } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import MovimientosPanel from '../components/inventario/MovimientosPanel';
|
||||
import AlertasPanel from '../components/inventario/AlertasPanel';
|
||||
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
|
||||
import ConteoFisicoModal from '../components/inventario/ConteoFisicoModal';
|
||||
import ConteoPrint from '../components/inventario/ConteoPrint';
|
||||
import { Card, Button, Input, Select, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
|
||||
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'items', label: 'Items' },
|
||||
{ key: 'compras', label: 'Compras' },
|
||||
{ key: 'bajas', label: 'Bajas' },
|
||||
{ key: 'conteos', label: 'Conteos' },
|
||||
{ key: 'alertas', label: 'Alertas' },
|
||||
] as const;
|
||||
|
||||
@@ -84,6 +87,28 @@ const Inventario: FC = () => {
|
||||
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
|
||||
const [moves, setMoves] = useState<InventoryMove[]>([]);
|
||||
|
||||
// Conteo físico cíclico
|
||||
const [conteoOpen, setConteoOpen] = useState(false);
|
||||
const [conteoPrint, setConteoPrint] = useState<InventoryConteo | null>(null);
|
||||
const [conteos, setConteos] = useState<InventoryConteo[]>([]);
|
||||
const [conteosLoading, setConteosLoading] = useState(false);
|
||||
|
||||
const loadConteos = useCallback(async () => {
|
||||
setConteosLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getConteos(30);
|
||||
setConteos(res.conteos || []);
|
||||
} catch {
|
||||
toast.error('No se pudo cargar el historial de conteos');
|
||||
} finally {
|
||||
setConteosLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'conteos') loadConteos();
|
||||
}, [tab, loadConteos]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (tab !== 'items') return;
|
||||
setLoading(true);
|
||||
@@ -104,14 +129,32 @@ const Inventario: FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Alta rápida: abre el formulario ya en modo consumible con defaults sensatos
|
||||
const nuevoConsumible = () => {
|
||||
setNewForm({
|
||||
...emptyItem,
|
||||
kind: 'consumible',
|
||||
unit: 'pieza',
|
||||
qty_min: 5,
|
||||
qty_optimal: 20,
|
||||
});
|
||||
setNewOpen(true);
|
||||
};
|
||||
|
||||
const createItem = async () => {
|
||||
if (!newForm.name) { toast.error('El nombre es obligatorio'); return; }
|
||||
setSavingNew(true);
|
||||
try {
|
||||
await odooApi.createInventoryItem(newForm);
|
||||
toast.success('Item creado');
|
||||
toast.success(newForm.kind === 'consumible' ? 'Consumible creado' : 'Item creado');
|
||||
setNewOpen(false);
|
||||
setNewForm(emptyItem);
|
||||
// Deja visible el artículo recién creado
|
||||
if (newForm.kind === 'consumible') {
|
||||
setKind('consumible');
|
||||
setLevel('');
|
||||
setSearch(newForm.name || '');
|
||||
}
|
||||
load();
|
||||
} catch {
|
||||
toast.error('Error al crear el item');
|
||||
@@ -172,10 +215,18 @@ const Inventario: FC = () => {
|
||||
title="Inventario y consumibles"
|
||||
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
|
||||
>
|
||||
<Button variant="outline" onClick={() => setConteoOpen(true)}>
|
||||
<ClipboardCheck size={16} className="mr-2" /> Conteo físico
|
||||
</Button>
|
||||
{tab === 'items' && (
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo item
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={nuevoConsumible} title="Alta rápida de consumible">
|
||||
<FlaskConical size={16} className="mr-2" /> Nuevo consumible
|
||||
</Button>
|
||||
<Button onClick={() => { setNewForm(emptyItem); setNewOpen(true); }}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo item
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -201,6 +252,75 @@ const Inventario: FC = () => {
|
||||
<MovimientosPanel tipo="baja" />
|
||||
) : tab === 'alertas' ? (
|
||||
<AlertasPanel />
|
||||
) : tab === 'conteos' ? (
|
||||
<>
|
||||
{conteosLoading ? (
|
||||
<Skeleton count={5} className="h-14 w-full" />
|
||||
) : conteos.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin conteos"
|
||||
subtitle="Aún no se ha registrado ningún conteo físico."
|
||||
actionLabel="Iniciar conteo"
|
||||
onAction={() => setConteoOpen(true)}
|
||||
icon={<ClipboardCheck size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<p className="text-xs text-theme-muted mb-3">
|
||||
Historial de conteos físicos. Cada conteo guarda la existencia del sistema, lo contado y los ajustes aplicados.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Conteo</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Realizó</th>
|
||||
<th className="text-right px-4 py-3">Items</th>
|
||||
<th className="text-right px-4 py-3">Con diferencia</th>
|
||||
<th className="text-right px-4 py-3">Faltante</th>
|
||||
<th className="text-right px-4 py-3">Sobrante</th>
|
||||
<th className="text-right px-4 py-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{conteos.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{c.name}</p>
|
||||
<p className="text-xs text-theme-muted">{c.date || '—'}{c.notes ? ` · ${c.notes}` : ''}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.user_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.total_items}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{c.total_diferencias}</td>
|
||||
<td className="px-4 py-3 text-right text-rose-600">{fmtMoney(c.total_faltante)}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-700">{fmtMoney(c.total_sobrante)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await odooApi.getConteo(c.id);
|
||||
setConteoPrint(res.conteo);
|
||||
} catch {
|
||||
toast.error('No se pudo abrir el conteo');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Printer size={13} className="mr-1.5" />
|
||||
Ver / imprimir
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* KPIs */}
|
||||
@@ -370,6 +490,18 @@ const Inventario: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Conteo físico cíclico */}
|
||||
<ConteoFisicoModal
|
||||
isOpen={conteoOpen}
|
||||
onClose={() => setConteoOpen(false)}
|
||||
onSaved={(c) => {
|
||||
setConteoPrint(c);
|
||||
load();
|
||||
if (tab === 'conteos') loadConteos();
|
||||
}}
|
||||
/>
|
||||
<ConteoPrint conteo={conteoPrint} onClose={() => setConteoPrint(null)} />
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Search, Plus, Minus, Wallet } from 'lucide-react';
|
||||
import { Search, Plus, Minus, Wallet, SlidersHorizontal, History } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
Badge,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Wallet as WalletType, type MonederoPuntosReport } from '../services/odoo';
|
||||
import { odooApi, type Wallet as WalletType, type MonederoPuntosReport, type WalletTransaction } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
rangeForPreset,
|
||||
readDateFilterPrefs,
|
||||
writeDateFilterPrefs,
|
||||
type DatePreset,
|
||||
} from '../lib/dateRange';
|
||||
|
||||
type TxType = 'accrual' | 'redemption';
|
||||
|
||||
@@ -28,6 +38,18 @@ const txTypeLabels: Record<string, string> = {
|
||||
accrual: 'Acumulación', redemption: 'Redención', adjustment: 'Ajuste', expiration: 'Vencimiento',
|
||||
};
|
||||
|
||||
const TX_FILTER_OPTIONS = [
|
||||
{ value: '', label: 'Todos los movimientos' },
|
||||
{ value: 'accrual', label: 'Acumulaciones' },
|
||||
{ value: 'redemption', label: 'Redenciones' },
|
||||
{ value: 'adjustment', label: 'Ajustes' },
|
||||
{ value: 'expiration', label: 'Vencimientos' },
|
||||
];
|
||||
|
||||
// Rango recordado de los movimientos del monedero (misma convención que la agenda)
|
||||
const MOV_FILTERS_KEY = 'skeen_monedero_mov_filtros';
|
||||
const ROLES_AJUSTAN = ['admin', 'gerencia'];
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
@@ -39,6 +61,8 @@ const fmtMoney = (n: number) =>
|
||||
|
||||
const Monedero: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const puedeAjustar = ROLES_AJUSTAN.includes(user?.role || '');
|
||||
const tab = searchParams.get('tab') || 'cuentas';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [wallets, setWallets] = useState<WalletType[]>([]);
|
||||
@@ -60,6 +84,24 @@ const Monedero: FC = () => {
|
||||
const [repStart, setRepStart] = useState(inicioDeMes());
|
||||
const [repEnd, setRepEnd] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
|
||||
// Movimientos del monedero (narrados, con filtros)
|
||||
const savedMov = readDateFilterPrefs(MOV_FILTERS_KEY);
|
||||
const [movWallet, setMovWallet] = useState<WalletType | null>(null);
|
||||
const [movimientos, setMovimientos] = useState<WalletTransaction[]>([]);
|
||||
const [movLoading, setMovLoading] = useState(false);
|
||||
const [movType, setMovType] = useState('');
|
||||
const [movPreset, setMovPreset] = useState<DatePreset>(savedMov?.preset || 'mes');
|
||||
const [movStart, setMovStart] = useState(savedMov?.start || rangeForPreset('mes').start);
|
||||
const [movEnd, setMovEnd] = useState(savedMov?.end || rangeForPreset('mes').end);
|
||||
const [movSum, setMovSum] = useState({ pos: 0, neg: 0 });
|
||||
|
||||
// Ajuste manual auditado
|
||||
const [ajusteOpen, setAjusteOpen] = useState(false);
|
||||
const [ajusteSigno, setAjusteSigno] = useState<'sumar' | 'restar'>('sumar');
|
||||
const [ajustePuntos, setAjustePuntos] = useState('');
|
||||
const [ajusteMotivo, setAjusteMotivo] = useState('');
|
||||
const [ajustando, setAjustando] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (tab !== 'cuentas') return;
|
||||
try {
|
||||
@@ -97,6 +139,26 @@ const Monedero: FC = () => {
|
||||
}
|
||||
}, [tab, repStart, repEnd]);
|
||||
|
||||
const loadMovimientos = useCallback(async (wallet: WalletType, filtros?: { type?: string; from?: string; to?: string }) => {
|
||||
try {
|
||||
setMovLoading(true);
|
||||
const res = await odooApi.getWalletTransactions(wallet.id, {
|
||||
...(filtros?.type ? { type: filtros.type } : {}),
|
||||
...(filtros?.from ? { date_from: filtros.from } : {}),
|
||||
...(filtros?.to ? { date_to: filtros.to } : {}),
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setMovimientos(res.movimientos);
|
||||
setMovSum({ pos: res.sum_positive, neg: res.sum_negative });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar movimientos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setMovLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
@@ -158,6 +220,73 @@ const Monedero: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Movimientos ---
|
||||
const abrirMovimientos = (w: WalletType) => {
|
||||
setMovWallet(w);
|
||||
loadMovimientos(w, { type: movType, from: movStart, to: movEnd });
|
||||
};
|
||||
|
||||
const aplicarPresetMov = (p: DatePreset) => {
|
||||
setMovPreset(p);
|
||||
let from = movStart;
|
||||
let to = movEnd;
|
||||
if (p !== 'custom') {
|
||||
const r = rangeForPreset(p);
|
||||
from = r.start;
|
||||
to = r.end;
|
||||
setMovStart(r.start);
|
||||
setMovEnd(r.end);
|
||||
}
|
||||
writeDateFilterPrefs(MOV_FILTERS_KEY, { preset: p, start: from, end: to });
|
||||
if (movWallet) loadMovimientos(movWallet, { type: movType, from, to });
|
||||
};
|
||||
|
||||
const aplicarTipoMov = (t: string) => {
|
||||
setMovType(t);
|
||||
if (movWallet) loadMovimientos(movWallet, { type: t, from: movStart, to: movEnd });
|
||||
};
|
||||
|
||||
// --- Ajuste manual ---
|
||||
const abrirAjuste = () => {
|
||||
setAjusteSigno('sumar');
|
||||
setAjustePuntos('');
|
||||
setAjusteMotivo('');
|
||||
setAjusteOpen(true);
|
||||
};
|
||||
|
||||
const guardarAjuste = async () => {
|
||||
if (!movWallet) return;
|
||||
const value = parseInt(ajustePuntos, 10);
|
||||
if (Number.isNaN(value) || value <= 0) {
|
||||
toast.error('Ingresa una cantidad de puntos válida');
|
||||
return;
|
||||
}
|
||||
if (!ajusteMotivo.trim()) {
|
||||
toast.error('El motivo del ajuste es obligatorio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAjustando(true);
|
||||
const res = await odooApi.adjustWallet(movWallet.id, {
|
||||
points: ajusteSigno === 'sumar' ? value : -value,
|
||||
reason: ajusteMotivo.trim(),
|
||||
});
|
||||
toast.success(`Ajuste aplicado: ${res.applied > 0 ? '+' : ''}${res.applied} pts`);
|
||||
setAjusteOpen(false);
|
||||
setMovWallet(res.wallet);
|
||||
await loadMovimientos(res.wallet, { type: movType, from: movStart, to: movEnd });
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al aplicar el ajuste');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setAjustando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saldoMov = movWallet?.points ?? 0;
|
||||
|
||||
return (
|
||||
<Layout title="Monedero" subtitle="Puntos y recompensas">
|
||||
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
|
||||
@@ -224,6 +353,7 @@ const Monedero: FC = () => {
|
||||
<p className={`text-lg font-semibold ${type === 'redemption' || type === 'expiration' ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts
|
||||
</p>
|
||||
<p className="text-[11px] text-theme-muted mt-0.5">{fmtMoney(points)}{rate ? ` · ${fmtUsd(points)} USD` : ''}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -264,7 +394,7 @@ const Monedero: FC = () => {
|
||||
<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">Teléfono</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Equivalente</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Saldo (MXN / USD)</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -275,11 +405,20 @@ const Monedero: FC = () => {
|
||||
<td className="p-3 text-sm text-theme-muted">{w.phone}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{w.points} pts</td>
|
||||
<td className="p-3 text-sm text-theme-muted">
|
||||
${w.equivalent_mxn}
|
||||
<span className="font-medium text-theme-heading">{fmtMoney(w.equivalent_mxn)}</span>
|
||||
{rate && <span className="block text-xs">{fmtUsd(w.equivalent_mxn)} USD</span>}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => abrirMovimientos(w)}
|
||||
title="Ver movimientos narrados"
|
||||
>
|
||||
<History size={14} className="mr-1.5" />
|
||||
Movimientos
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -313,10 +452,13 @@ const Monedero: FC = () => {
|
||||
subtitle={w.phone}
|
||||
rows={[
|
||||
{ label: 'Puntos', value: `${w.points} pts` },
|
||||
{ label: 'Equivalente', value: `$${w.equivalent_mxn}${rate ? ` (${fmtUsd(w.equivalent_mxn)} USD)` : ''}` },
|
||||
{ label: 'Saldo', value: `${fmtMoney(w.equivalent_mxn)}${rate ? ` · ${fmtUsd(w.equivalent_mxn)} USD` : ''}` },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => abrirMovimientos(w)} title="Movimientos">
|
||||
<History size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'accrual')}>
|
||||
<Plus size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
@@ -396,6 +538,186 @@ const Monedero: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Movimientos del monedero con narración y filtros */}
|
||||
<Modal
|
||||
isOpen={!!movWallet}
|
||||
onClose={() => setMovWallet(null)}
|
||||
title={movWallet ? `Movimientos · ${movWallet.patient}` : 'Movimientos'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
{puedeAjustar && (
|
||||
<Button variant="outline" onClick={abrirAjuste}>
|
||||
<SlidersHorizontal size={14} className="mr-1.5" />
|
||||
Ajustar puntos
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setMovWallet(null)}>Cerrar</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Saldo</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{saldoMov} pts</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">En pesos</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{fmtMoney(saldoMov)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">En dólares</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{rate ? fmtUsd(saldoMov).replace('≈ ', '') : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => aplicarPresetMov(p.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
movPreset === 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>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||
<Select
|
||||
options={TX_FILTER_OPTIONS}
|
||||
value={movType}
|
||||
onChange={(e) => aplicarTipoMov(e.target.value)}
|
||||
className="sm:max-w-[220px]"
|
||||
/>
|
||||
<Input
|
||||
label="Desde"
|
||||
type="date"
|
||||
value={movStart}
|
||||
onChange={(e) => { setMovStart(e.target.value); setMovPreset('custom'); }}
|
||||
className="sm:max-w-[150px]"
|
||||
/>
|
||||
<Input
|
||||
label="Hasta"
|
||||
type="date"
|
||||
value={movEnd}
|
||||
onChange={(e) => { setMovEnd(e.target.value); setMovPreset('custom'); }}
|
||||
className="sm:max-w-[150px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mb-[1px]"
|
||||
onClick={() => movWallet && loadMovimientos(movWallet, { type: movType, from: movStart, to: movEnd })}
|
||||
>
|
||||
Aplicar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-theme-muted">
|
||||
<span>{movimientos.length} movimientos</span>
|
||||
<span className="text-emerald-700">+{movSum.pos} pts</span>
|
||||
<span className="text-rose-600">{movSum.neg} pts</span>
|
||||
</div>
|
||||
|
||||
{movLoading ? (
|
||||
<Skeleton count={5} className="h-12 w-full" />
|
||||
) : movimientos.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-6 text-center">Sin movimientos con estos filtros.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-theme-border max-h-[45vh] overflow-y-auto">
|
||||
{movimientos.map((m) => (
|
||||
<div key={m.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-theme-heading">{m.narration}</p>
|
||||
<p className="text-[11px] text-theme-muted mt-0.5">
|
||||
{m.date || '—'}
|
||||
{m.operator ? ` · registró ${m.operator}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className={`text-sm font-semibold ${m.points > 0 ? 'text-emerald-700' : m.points < 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
|
||||
{m.points > 0 ? '+' : ''}{m.points} pts
|
||||
</p>
|
||||
<Badge variant={m.type === 'redemption' || m.type === 'expiration' ? 'danger' : m.type === 'adjustment' ? 'warning' : 'success'}>
|
||||
{m.type_label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Ajuste manual auditado */}
|
||||
<Modal
|
||||
isOpen={ajusteOpen}
|
||||
onClose={() => setAjusteOpen(false)}
|
||||
title={movWallet ? `Ajustar puntos · ${movWallet.patient}` : 'Ajustar puntos'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setAjusteOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardarAjuste} loading={ajustando}>Aplicar ajuste</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Saldo actual: <span className="font-medium text-theme-heading">{saldoMov} pts</span>
|
||||
</p>
|
||||
<div>
|
||||
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Tipo de ajuste</p>
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAjusteSigno('sumar')}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
ajusteSigno === 'sumar' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
Sumar puntos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAjusteSigno('restar')}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
ajusteSigno === 'restar' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
Restar puntos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Puntos *"
|
||||
type="number"
|
||||
min={1}
|
||||
value={ajustePuntos}
|
||||
onChange={(e) => setAjustePuntos(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Motivo del ajuste *"
|
||||
rows={2}
|
||||
value={ajusteMotivo}
|
||||
onChange={(e) => setAjusteMotivo(e.target.value)}
|
||||
placeholder="Ej. cortesía, corrección de captura, promoción..."
|
||||
/>
|
||||
<p className="text-xs text-theme-muted">
|
||||
El ajuste queda registrado en el historial del monedero con el motivo y el usuario que lo capturó.
|
||||
Un ajuste negativo hace piso en 0 puntos.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -679,6 +679,85 @@ export interface AdeudoRow {
|
||||
num_ventas: number;
|
||||
total_adeudo: number;
|
||||
ultima_venta: string | null;
|
||||
plan?: PaymentPlan | null;
|
||||
}
|
||||
|
||||
export interface PaymentPlan {
|
||||
count: number;
|
||||
amount: number;
|
||||
next_payment_date: string | null;
|
||||
note: string;
|
||||
start: string | null;
|
||||
by: string;
|
||||
paid_count: number;
|
||||
remaining_count: number;
|
||||
}
|
||||
|
||||
export interface ReceiptApplied {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string | null;
|
||||
applied: number;
|
||||
total: number;
|
||||
state: string;
|
||||
amount_due: number;
|
||||
}
|
||||
|
||||
export interface ReceiptData {
|
||||
folio: string;
|
||||
date: string;
|
||||
patient_id: number;
|
||||
patient: string;
|
||||
patient_code: string;
|
||||
phone: string;
|
||||
amount: number;
|
||||
payment_method: string;
|
||||
adeudo_anterior: number;
|
||||
saldo_restante: number;
|
||||
aplicado: ReceiptApplied[];
|
||||
operator: string;
|
||||
wallet_points: number;
|
||||
}
|
||||
|
||||
export type WalletTxType = 'accrual' | 'redemption' | 'adjustment' | 'expiration';
|
||||
|
||||
export interface WalletTransaction {
|
||||
id: number;
|
||||
type: WalletTxType;
|
||||
type_label: string;
|
||||
points: number;
|
||||
narration: string;
|
||||
description: string;
|
||||
reference: string;
|
||||
operator: string;
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryConteoLine {
|
||||
id: number;
|
||||
item_id: number;
|
||||
item: string;
|
||||
unit: string;
|
||||
qty_sistema: number;
|
||||
qty_contado: number;
|
||||
difference: number;
|
||||
cost: number;
|
||||
value_diff: number;
|
||||
move_id: number | null;
|
||||
}
|
||||
|
||||
export interface InventoryConteo {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string | null;
|
||||
user_name: string;
|
||||
notes: string;
|
||||
total_items: number;
|
||||
total_diferencias: number;
|
||||
total_faltante: number;
|
||||
total_sobrante: number;
|
||||
total_value_diff: number;
|
||||
lines?: InventoryConteoLine[];
|
||||
}
|
||||
|
||||
export interface ComisionRow {
|
||||
@@ -1261,6 +1340,17 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Cobro del adeudo del paciente (parcial o total) + plan de pagos
|
||||
async payAdeudo(patientId: number, payload: { amount: number; payment_method: string }): Promise<{ status: string; payment: Payment; receipt: ReceiptData }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/pay_adeudo`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async setPaymentPlan(patientId: number, payload: { count: number; amount: number; next_payment_date?: string | null; note?: string }): Promise<{ status: string; plan: PaymentPlan }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/payment_plan`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Servicios
|
||||
async getServices(params?: { search?: string; category?: string; page?: number; page_size?: number }): Promise<{ status: string; services: Service[]; total?: number; page?: number; total_pages?: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`, { params });
|
||||
@@ -1347,6 +1437,18 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Movimientos del monedero con narración legible
|
||||
async getWalletTransactions(walletId: number, params?: { type?: string; date_from?: string; date_to?: string }): Promise<{ status: string; wallet: Wallet; movimientos: WalletTransaction[]; total: number; sum_positive: number; sum_negative: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets/${walletId}/transactions`, { params: params || {} });
|
||||
return data;
|
||||
},
|
||||
|
||||
// Ajuste manual auditado de puntos (admin/gerencia)
|
||||
async adjustWallet(walletId: number, payload: { points: number; reason: string }): Promise<{ status: string; wallet: Wallet; applied: number; movimiento: { id: number; points: number; date: string; operator: string; narration: string } }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wallets/${walletId}/adjust`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Pagos
|
||||
async getPayments(state?: string): Promise<{ status: string; payments: Payment[] }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/payments`, { params: state ? { state } : {} });
|
||||
@@ -1487,6 +1589,22 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Conteo físico cíclico de inventario
|
||||
async getConteos(limit = 30): Promise<{ status: string; conteos: InventoryConteo[] }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventario/conteos`, { params: { limit } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getConteo(id: number): Promise<{ status: string; conteo: InventoryConteo }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventario/conteos/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async createConteo(payload: { notes?: string; lines: { item_id: number; qty_contado: number }[] }): Promise<{ status: string; conteo: InventoryConteo; ajustes: number }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventario/conteos`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getHorasAgenda(start?: string, end?: string): Promise<{ status: string; horas: HoraAgendaRow[] }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/horas-agenda`, { params: { start, end } });
|
||||
return data;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/inventario_views.xml',
|
||||
'data/sequences.xml',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
13
odoo-addons/skeen_inventario/data/sequences.xml
Normal file
13
odoo-addons/skeen_inventario/data/sequences.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="seq_skeen_inventario_conteo" model="ir.sequence">
|
||||
<field name="name">SKEEN Conteo físico de inventario</field>
|
||||
<field name="code">skeen.inventario.conteo</field>
|
||||
<field name="prefix">CONTEO-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -107,22 +107,119 @@ class SkeenInventarioMove(models.Model):
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
moves = super().create(vals_list)
|
||||
for m in moves:
|
||||
item = m.item_id
|
||||
if not item:
|
||||
# Las existencias anterior/resultante se calculan ANTES del INSERT: si
|
||||
# se escribieran después con write(), Odoo omite el UPDATE cuando el
|
||||
# valor coincide con el de la caché (p. ej. after_qty = 0) y la columna
|
||||
# quedaría NULL en la base.
|
||||
Item = self.env['skeen.inventario.item'].sudo()
|
||||
for vals in vals_list:
|
||||
item = Item.browse(vals.get('item_id'))
|
||||
if not item.exists():
|
||||
continue
|
||||
before = item.qty or 0.0
|
||||
q = m.qty or 0.0
|
||||
if m.type in ('compra', 'cancelacion'):
|
||||
q = vals.get('qty') or 0.0
|
||||
mtype = vals.get('type')
|
||||
if mtype in ('compra', 'cancelacion'):
|
||||
after = before + q
|
||||
elif m.type in ('venta', 'baja'):
|
||||
elif mtype in ('venta', 'baja'):
|
||||
after = before - q
|
||||
elif m.type == 'ajuste':
|
||||
elif mtype == 'ajuste':
|
||||
after = q # ajuste fija la existencia al valor del conteo
|
||||
else:
|
||||
after = before
|
||||
after = max(0.0, after)
|
||||
item.write({'qty': after, 'last_count_date': fields.Date.today() if m.type == 'ajuste' else item.last_count_date})
|
||||
m.write({'before_qty': before, 'after_qty': after})
|
||||
vals['before_qty'] = before
|
||||
vals['after_qty'] = max(0.0, after)
|
||||
|
||||
moves = super().create(vals_list)
|
||||
for m in moves:
|
||||
if not m.item_id:
|
||||
continue
|
||||
vals_item = {'qty': m.after_qty}
|
||||
if m.type == 'ajuste':
|
||||
vals_item['last_count_date'] = fields.Date.today()
|
||||
m.item_id.write(vals_item)
|
||||
return moves
|
||||
|
||||
|
||||
class SkeenInventarioConteo(models.Model):
|
||||
"""Conteo físico cíclico de inventario.
|
||||
|
||||
Guarda la foto del conteo (sistema vs contado por item) y los ajustes
|
||||
generados, para poder consultar el historial y reimprimir el resumen.
|
||||
"""
|
||||
_name = 'skeen.inventario.conteo'
|
||||
_description = 'Conteo físico de inventario SKEEN'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||
date = fields.Datetime(string='Fecha', default=fields.Datetime.now)
|
||||
user_name = fields.Char(string='Realizado por')
|
||||
notes = fields.Text(string='Notas')
|
||||
line_ids = fields.One2many('skeen.inventario.conteo.line', 'conteo_id', string='Líneas')
|
||||
|
||||
total_items = fields.Integer(string='Artículos contados', compute='_compute_totals', store=True)
|
||||
total_diferencias = fields.Integer(string='Con diferencia', compute='_compute_totals', store=True)
|
||||
total_faltante = fields.Float(string='Faltante', compute='_compute_totals', store=True)
|
||||
total_sobrante = fields.Float(string='Sobrante', compute='_compute_totals', store=True)
|
||||
total_value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_totals', store=True)
|
||||
|
||||
@api.depends('line_ids.difference', 'line_ids.value_diff')
|
||||
def _compute_totals(self):
|
||||
for c in self:
|
||||
c.total_items = len(c.line_ids)
|
||||
c.total_diferencias = len(c.line_ids.filtered(lambda l: abs(l.difference) > 1e-9))
|
||||
c.total_faltante = sum(l.value_diff for l in c.line_ids if l.difference < 0)
|
||||
c.total_sobrante = sum(l.value_diff for l in c.line_ids if l.difference > 0)
|
||||
c.total_value_diff = c.total_faltante + c.total_sobrante
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.inventario.conteo') or 'CONTEO-001'
|
||||
return super().create(vals_list)
|
||||
|
||||
def apply_count(self):
|
||||
"""Genera un movimiento type='ajuste' por cada línea con diferencia.
|
||||
|
||||
El movimiento de ajuste fija la existencia del artículo al valor
|
||||
contado (ver SkeenInventarioMove.create) y sella la fecha de conteo.
|
||||
Devuelve la cantidad de ajustes creados.
|
||||
"""
|
||||
Move = self.env['skeen.inventario.move']
|
||||
ajustes = 0
|
||||
for line in self.line_ids:
|
||||
if abs(line.difference) <= 1e-9:
|
||||
continue
|
||||
move = Move.create({
|
||||
'item_id': line.item_id.id,
|
||||
'type': 'ajuste',
|
||||
'qty': line.qty_contado,
|
||||
'reference': self.name,
|
||||
'notes': f'Conteo físico {self.name}',
|
||||
})
|
||||
line.write({'move_id': move.id})
|
||||
ajustes += 1
|
||||
return ajustes
|
||||
|
||||
|
||||
class SkeenInventarioConteoLine(models.Model):
|
||||
_name = 'skeen.inventario.conteo.line'
|
||||
_description = 'Línea de conteo físico'
|
||||
_order = 'id'
|
||||
|
||||
conteo_id = fields.Many2one('skeen.inventario.conteo', string='Conteo', required=True, ondelete='cascade')
|
||||
item_id = fields.Many2one('skeen.inventario.item', string='Artículo', required=True, ondelete='cascade')
|
||||
unit = fields.Char(related='item_id.unit', string='Unidad', readonly=True)
|
||||
qty_sistema = fields.Float(string='Sistema')
|
||||
qty_contado = fields.Float(string='Contado')
|
||||
difference = fields.Float(string='Diferencia', compute='_compute_difference', store=True)
|
||||
cost = fields.Float(string='Costo unitario')
|
||||
value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_difference', store=True)
|
||||
move_id = fields.Many2one('skeen.inventario.move', string='Ajuste generado', readonly=True)
|
||||
|
||||
@api.depends('qty_sistema', 'qty_contado', 'cost')
|
||||
def _compute_difference(self):
|
||||
for line in self:
|
||||
line.difference = (line.qty_contado or 0.0) - (line.qty_sistema or 0.0)
|
||||
line.value_diff = line.difference * (line.cost or 0.0)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_inventario_item_user,Acceso a Items de Inventario SKEEN,model_skeen_inventario_item,base.group_user,1,1,1,1
|
||||
access_skeen_inventario_move_user,Acceso a Movimientos de Inventario SKEEN,model_skeen_inventario_move,base.group_user,1,1,1,1
|
||||
access_skeen_inventario_conteo_user,Acceso a Conteos de Inventario SKEEN,model_skeen_inventario_conteo,base.group_user,1,1,1,1
|
||||
access_skeen_inventario_conteo_line_user,Acceso a Lineas de Conteo SKEEN,model_skeen_inventario_conteo_line,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -54,7 +54,7 @@ class SkeenMonedero(models.Model):
|
||||
'history': history,
|
||||
}
|
||||
|
||||
def add_points(self, points, description='', reference=''):
|
||||
def add_points(self, points, description='', reference='', operator=''):
|
||||
"""Agregar puntos al monedero"""
|
||||
self.ensure_one()
|
||||
self.write({'points': self.points + points})
|
||||
@@ -64,10 +64,11 @@ class SkeenMonedero(models.Model):
|
||||
'points': points,
|
||||
'description': description or 'Acumulación de puntos',
|
||||
'reference': reference,
|
||||
'operator': operator,
|
||||
})
|
||||
return True
|
||||
|
||||
def redeem_points(self, points, description='', reference=''):
|
||||
def redeem_points(self, points, description='', reference='', operator=''):
|
||||
"""Redimir puntos del monedero"""
|
||||
self.ensure_one()
|
||||
if points > self.points:
|
||||
@@ -80,10 +81,11 @@ class SkeenMonedero(models.Model):
|
||||
'points': -points,
|
||||
'description': description or 'Redención de puntos',
|
||||
'reference': reference,
|
||||
'operator': operator,
|
||||
})
|
||||
return True
|
||||
|
||||
def adjust_points(self, delta, description='', reference=''):
|
||||
def adjust_points(self, delta, description='', reference='', operator=''):
|
||||
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
|
||||
|
||||
Usado por devoluciones para revertir puntos acumulados por una venta.
|
||||
@@ -103,6 +105,7 @@ class SkeenMonedero(models.Model):
|
||||
'points': effective_delta,
|
||||
'description': description or 'Ajuste de puntos',
|
||||
'reference': reference,
|
||||
'operator': operator,
|
||||
})
|
||||
return effective_delta
|
||||
|
||||
@@ -126,6 +129,9 @@ class SkeenMonederoTransaction(models.Model):
|
||||
points = fields.Integer(string='Puntos', required=True)
|
||||
description = fields.Text(string='Descripción')
|
||||
reference = fields.Char(string='Referencia')
|
||||
# Usuario del frontend que capturó el movimiento (los controladores
|
||||
# auth='none' no dejan uid de Odoo, así que se guarda explícito).
|
||||
operator = fields.Char(string='Registrado por')
|
||||
|
||||
# Fecha
|
||||
create_date = fields.Datetime(string='Fecha', readonly=True, default=fields.Datetime.now)
|
||||
|
||||
@@ -97,6 +97,15 @@ class ResPartner(models.Model):
|
||||
total_visits = fields.Integer(string='Total Visitas', default=0)
|
||||
total_spent = fields.Float(string='Total Gastado', default=0.0)
|
||||
|
||||
# Plan de pagos del adeudo (acuerdo manual, sin recordatorios automáticos)
|
||||
payment_plan_count = fields.Integer(string='Pagos Acordados', default=0)
|
||||
payment_plan_amount = fields.Float(string='Monto por Pago', default=0.0)
|
||||
next_payment_date = fields.Date(string='Siguiente Pago')
|
||||
payment_plan_note = fields.Text(string='Nota del Plan de Pagos')
|
||||
payment_plan_start = fields.Date(string='Inicio del Plan')
|
||||
payment_plan_since = fields.Datetime(string='Plan vigente desde')
|
||||
payment_plan_by = fields.Char(string='Plan registrado por')
|
||||
|
||||
# Adjuntos (expediente escaneado y galería de imágenes)
|
||||
adjunto_ids = fields.One2many('skeen.patient.adjunto', 'partner_id', string='Documentos')
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'depends': ['base', 'skeen_citas'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/pago_sequence.xml',
|
||||
'views/pago_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
14
odoo-addons/skeen_pagos/data/pago_sequence.xml
Normal file
14
odoo-addons/skeen_pagos/data/pago_sequence.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="seq_skeen_pago" model="ir.sequence">
|
||||
<field name="name">SKEEN Pago</field>
|
||||
<field name="code">skeen.pago</field>
|
||||
<field name="prefix">PAGO-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1526,6 +1526,182 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Adeudos: cobro con recibo y plan de pagos
|
||||
# ============================================================
|
||||
def _adeudo_ventas(self, patient_id):
|
||||
"""Ventas con saldo pendiente del paciente, más antiguas primero."""
|
||||
return request.env['skeen.venta'].sudo().search([
|
||||
('partner_id', '=', patient_id),
|
||||
('state', '!=', 'cancelled'),
|
||||
('amount_due', '>', 0),
|
||||
], order='date asc, id asc')
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def pay_patient_adeudo(self, patient_id, **kw):
|
||||
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
|
||||
|
||||
El pago se aplica a las ventas con saldo pendiente, de la más antigua a
|
||||
la más reciente, replicando la lógica de /sales/<id>/pay (incluye los
|
||||
puntos de monedero al liquidar una venta).
|
||||
"""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
amount = float(data.get('amount') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Monto inválido'}, 400)
|
||||
method = data.get('payment_method', 'cash')
|
||||
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
|
||||
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||
if amount <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
|
||||
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
|
||||
ventas = self._adeudo_ventas(patient_id)
|
||||
total_adeudo = round(sum(v.amount_due for v in ventas), 2)
|
||||
if total_adeudo <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El paciente no tiene adeudo pendiente'}, 400)
|
||||
if round(amount, 2) > total_adeudo + 0.01:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': f'El monto excede el adeudo pendiente (${total_adeudo:,.2f})',
|
||||
}, 400)
|
||||
|
||||
payment = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
restante = round(amount, 2)
|
||||
aplicado = []
|
||||
for venta in ventas:
|
||||
if restante <= 0:
|
||||
break
|
||||
por_aplicar = round(min(restante, venta.amount_due), 2)
|
||||
if por_aplicar <= 0:
|
||||
continue
|
||||
nuevo_pagado = round(venta.amount_paid + por_aplicar, 2)
|
||||
if nuevo_pagado >= venta.total - 0.005:
|
||||
venta.write({'amount_paid': nuevo_pagado})
|
||||
venta.action_mark_paid() # otorga puntos y liquida la venta
|
||||
nuevo_estado = 'paid'
|
||||
else:
|
||||
venta.write({'amount_paid': nuevo_pagado, 'state': 'partial'})
|
||||
nuevo_estado = 'partial'
|
||||
restante = round(restante - por_aplicar, 2)
|
||||
aplicado.append({
|
||||
'id': venta.id,
|
||||
'name': venta.name,
|
||||
'date': venta.date.strftime('%Y-%m-%d') if venta.date else None,
|
||||
'applied': por_aplicar,
|
||||
'total': venta.total,
|
||||
'state': nuevo_estado,
|
||||
'amount_due': venta.amount_due,
|
||||
})
|
||||
|
||||
saldo = round(sum(v.amount_due for v in self._adeudo_ventas(patient_id)), 2)
|
||||
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', partner.id)], limit=1)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'payment': self._payment_to_dict(payment),
|
||||
'receipt': {
|
||||
'folio': payment.name,
|
||||
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'patient_code': partner.patient_id or '',
|
||||
'phone': partner.phone or '',
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'adeudo_anterior': total_adeudo,
|
||||
'saldo_restante': saldo,
|
||||
'aplicado': aplicado,
|
||||
'operator': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
|
||||
'wallet_points': monedero.points if monedero else 0,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/payment_plan', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def set_payment_plan(self, patient_id, **kw):
|
||||
"""Acuerdo de pagos del adeudo: N pagos de $X, siguiente fecha y nota.
|
||||
|
||||
Enviar count<=0 limpia el plan. No hay recordatorios automáticos: es
|
||||
solo registro y visualización.
|
||||
"""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
count = int(data.get('count') or 0)
|
||||
plan_amount = float(data.get('amount') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Valores inválidos'}, 400)
|
||||
if count > 0:
|
||||
if plan_amount <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El monto por pago debe ser mayor a 0'}, 400)
|
||||
partner.write({
|
||||
'payment_plan_count': count,
|
||||
'payment_plan_amount': plan_amount,
|
||||
'next_payment_date': data.get('next_payment_date') or False,
|
||||
'payment_plan_note': data.get('note') or '',
|
||||
'payment_plan_start': fields.Date.today(),
|
||||
'payment_plan_since': fields.Datetime.now(),
|
||||
'payment_plan_by': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
|
||||
})
|
||||
else:
|
||||
partner.write({
|
||||
'payment_plan_count': 0,
|
||||
'payment_plan_amount': 0.0,
|
||||
'next_payment_date': False,
|
||||
'payment_plan_note': '',
|
||||
'payment_plan_start': False,
|
||||
'payment_plan_since': False,
|
||||
'payment_plan_by': '',
|
||||
})
|
||||
return json_response({'status': 'success', 'plan': self._payment_plan_dict(partner)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _payment_plan_dict(self, partner):
|
||||
"""Plan del paciente + pagos ya cubiertos desde que se acordó.
|
||||
|
||||
Sólo cuentan los pagos posteriores al acuerdo (payment_plan_since): los
|
||||
que ya se habían cobrado antes no abonan al plan recién registrado.
|
||||
"""
|
||||
count = partner.payment_plan_count or 0
|
||||
pagados = 0
|
||||
if count > 0 and partner.payment_plan_since:
|
||||
pagados = request.env['skeen.pago'].sudo().search_count([
|
||||
('partner_id', '=', partner.id),
|
||||
('state', '=', 'completed'),
|
||||
('create_date', '>=', partner.payment_plan_since),
|
||||
])
|
||||
return {
|
||||
'count': count,
|
||||
'amount': partner.payment_plan_amount or 0.0,
|
||||
'next_payment_date': partner.next_payment_date.strftime('%Y-%m-%d') if partner.next_payment_date else None,
|
||||
'note': partner.payment_plan_note or '',
|
||||
'start': partner.payment_plan_start.strftime('%Y-%m-%d') if partner.payment_plan_start else None,
|
||||
'by': partner.payment_plan_by or '',
|
||||
'paid_count': min(pagados, count) if count else 0,
|
||||
'remaining_count': max(0, count - pagados) if count else 0,
|
||||
}
|
||||
|
||||
@require_role('lectura')
|
||||
def expedientes_recientes(self, **kw):
|
||||
"""Pacientes con visitas recientes (últimos 30 días por default)"""
|
||||
@@ -2569,6 +2745,146 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _tx_narration(self, tx, totales_ventas):
|
||||
"""Narración legible del movimiento, generada del tipo + referencia."""
|
||||
pts = tx.points or 0
|
||||
abs_pts = abs(pts)
|
||||
ref = (tx.reference or '').strip()
|
||||
desc = (tx.description or '').strip()
|
||||
desc_low = desc.lower()
|
||||
operador = f' · por {tx.operator}' if tx.operator else ''
|
||||
total = totales_ventas.get(ref)
|
||||
|
||||
if ref.startswith('VENTA-'):
|
||||
monto = f' (${total:,.2f})' if total else ''
|
||||
if tx.type == 'accrual':
|
||||
return f'Ganó {abs_pts} pts por la venta {ref}{monto}'
|
||||
if tx.type == 'redemption':
|
||||
return f'Usó {abs_pts} pts en la venta {ref}{monto}'
|
||||
if tx.type == 'adjustment':
|
||||
if 'cancelaci' in desc_low or 'reversi' in desc_low:
|
||||
return f'Reversión de {abs_pts} pts por cancelación de la venta {ref}'
|
||||
if 'devoluci' in desc_low:
|
||||
return f'Reversión de {abs_pts} pts por devolución de la venta {ref}'
|
||||
return (desc or f'Ajuste de {pts} pts en la venta {ref}') + operador
|
||||
if tx.type == 'expiration':
|
||||
return f'Vencieron {abs_pts} pts de la venta {ref}'
|
||||
|
||||
if tx.type == 'adjustment':
|
||||
if desc_low.startswith('ajuste manual'):
|
||||
return f'{desc}{operador}'
|
||||
return f'Ajuste manual: {desc}' if desc else f'Ajuste manual de {pts} pts{operador}'
|
||||
if tx.type == 'accrual':
|
||||
if desc and desc_low != 'acumulación de puntos':
|
||||
return f'Acumulación de {abs_pts} pts · {desc}{operador}'
|
||||
return f'Acumulación de {abs_pts} pts{operador}'
|
||||
if tx.type == 'redemption':
|
||||
if desc and desc_low != 'redención de puntos':
|
||||
return f'Redención de {abs_pts} pts · {desc}{operador}'
|
||||
return f'Redención de {abs_pts} pts{operador}'
|
||||
if tx.type == 'expiration':
|
||||
return f'Vencieron {abs_pts} pts'
|
||||
return desc or f'Movimiento de {pts} pts'
|
||||
|
||||
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/transactions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def wallet_transactions(self, wallet_id, **kw):
|
||||
"""Movimientos del monedero con narración legible y filtros."""
|
||||
try:
|
||||
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
|
||||
if not monedero.exists():
|
||||
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||
domain = [('monedero_id', '=', monedero.id)]
|
||||
ttype = kw.get('type')
|
||||
if ttype in ('accrual', 'redemption', 'adjustment', 'expiration'):
|
||||
domain.append(('type', '=', ttype))
|
||||
if kw.get('date_from'):
|
||||
domain.append(('create_date', '>=', f"{kw['date_from']} 00:00:00"))
|
||||
if kw.get('date_to'):
|
||||
domain.append(('create_date', '<=', f"{kw['date_to']} 23:59:59"))
|
||||
tx = request.env['skeen.monedero.transaction'].sudo().search(
|
||||
domain, order='create_date desc, id desc', limit=300)
|
||||
|
||||
# Totales de las ventas referenciadas (para narrar el monto)
|
||||
refs = {t.reference for t in tx if t.reference and t.reference.startswith('VENTA-')}
|
||||
totales = {}
|
||||
if refs:
|
||||
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
|
||||
totales[v.name] = v.total
|
||||
|
||||
movimientos = [{
|
||||
'id': t.id,
|
||||
'type': t.type,
|
||||
'type_label': dict(request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
|
||||
'points': t.points,
|
||||
'narration': self._tx_narration(t, totales),
|
||||
'description': t.description or '',
|
||||
'reference': t.reference or '',
|
||||
'operator': t.operator or '',
|
||||
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
|
||||
} for t in tx]
|
||||
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'wallet': self._wallet_to_dict(monedero),
|
||||
'movimientos': movimientos,
|
||||
'total': len(movimientos),
|
||||
'sum_positive': sum(m['points'] for m in movimientos if m['points'] > 0),
|
||||
'sum_negative': sum(m['points'] for m in movimientos if m['points'] < 0),
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/adjust', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def wallet_adjust(self, wallet_id, **kw):
|
||||
"""Ajuste manual auditado de puntos (permiso admin/gerencia).
|
||||
|
||||
Body: {"points": 50 | -20, "reason": "cortesía"}. El motivo es
|
||||
obligatorio y queda en la descripción del movimiento junto con el
|
||||
usuario del frontend que lo capturó.
|
||||
"""
|
||||
try:
|
||||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
|
||||
if not monedero.exists():
|
||||
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
reason = (data.get('reason') or '').strip()
|
||||
if not reason:
|
||||
return json_response({'status': 'error', 'message': 'El motivo del ajuste es obligatorio'}, 400)
|
||||
try:
|
||||
points = int(data.get('points') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Cantidad de puntos inválida'}, 400)
|
||||
if points == 0:
|
||||
return json_response({'status': 'error', 'message': 'La cantidad de puntos no puede ser 0'}, 400)
|
||||
operador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
aplicado = monedero.adjust_points(
|
||||
points,
|
||||
description=f'Ajuste manual: {reason}',
|
||||
reference='AJUSTE-MANUAL',
|
||||
operator=operador,
|
||||
)
|
||||
tx = request.env['skeen.monedero.transaction'].sudo().search(
|
||||
[('monedero_id', '=', monedero.id)], order='id desc', limit=1)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'wallet': self._wallet_to_dict(monedero),
|
||||
'applied': aplicado,
|
||||
'movimiento': {
|
||||
'id': tx.id,
|
||||
'points': tx.points,
|
||||
'date': tx.create_date.strftime('%Y-%m-%d %H:%M') if tx.create_date else None,
|
||||
'operator': tx.operator or '',
|
||||
'narration': self._tx_narration(tx, {}),
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _wallet_to_dict(self, w):
|
||||
return {
|
||||
'id': w.id,
|
||||
@@ -3225,11 +3541,15 @@ class SkeenFrontendController(http.Controller):
|
||||
'ultima_venta': str(r['date'])[:10] if r.get('date') else None,
|
||||
})
|
||||
phones = {}
|
||||
partners = {}
|
||||
if items:
|
||||
for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]):
|
||||
phones[p.id] = p.phone or ''
|
||||
partners[p.id] = p
|
||||
for i in items:
|
||||
i['phone'] = phones.get(i['id'], '')
|
||||
plan = self._payment_plan_dict(partners[i['id']]) if i['id'] in partners else None
|
||||
i['plan'] = plan if plan and plan['count'] > 0 else None
|
||||
items.sort(key=lambda x: -x['total_adeudo'])
|
||||
total_cartera = sum(i['total_adeudo'] for i in items)
|
||||
return json_response({
|
||||
@@ -4592,6 +4912,102 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Conteo físico cíclico de inventario
|
||||
# ============================================================
|
||||
def _conteo_to_dict(self, c, with_lines=False):
|
||||
d = {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'date': c.date.strftime('%Y-%m-%d %H:%M') if c.date else None,
|
||||
'user_name': c.user_name or '',
|
||||
'notes': c.notes or '',
|
||||
'total_items': c.total_items,
|
||||
'total_diferencias': c.total_diferencias,
|
||||
'total_faltante': c.total_faltante,
|
||||
'total_sobrante': c.total_sobrante,
|
||||
'total_value_diff': c.total_value_diff,
|
||||
}
|
||||
if with_lines:
|
||||
d['lines'] = [{
|
||||
'id': l.id,
|
||||
'item_id': l.item_id.id,
|
||||
'item': l.item_id.name,
|
||||
'unit': l.unit or 'pieza',
|
||||
'qty_sistema': l.qty_sistema,
|
||||
'qty_contado': l.qty_contado,
|
||||
'difference': l.difference,
|
||||
'cost': l.cost,
|
||||
'value_diff': l.value_diff,
|
||||
'move_id': l.move_id.id if l.move_id else None,
|
||||
} for l in c.line_ids]
|
||||
return d
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_conteos(self, **kw):
|
||||
try:
|
||||
limit = min(max(int(kw.get('limit', 30) or 30), 1), 100)
|
||||
conteos = request.env['skeen.inventario.conteo'].sudo().search([], order='date desc, id desc', limit=limit)
|
||||
return json_response({'status': 'success', 'conteos': [self._conteo_to_dict(c) for c in conteos]})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos/<int:conteo_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_conteo(self, conteo_id, **kw):
|
||||
try:
|
||||
conteo = request.env['skeen.inventario.conteo'].sudo().browse(conteo_id)
|
||||
if not conteo.exists():
|
||||
return json_response({'status': 'error', 'message': 'Conteo no encontrado'}, 404)
|
||||
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def create_conteo(self, **kw):
|
||||
"""Registra un conteo físico y aplica los ajustes por diferencia.
|
||||
|
||||
Body: {"notes": "...", "lines": [{"item_id": 1, "qty_contado": 7.5}, ...]}
|
||||
Solo se genera movimiento para las líneas con diferencia; el ajuste
|
||||
fija la existencia al valor contado.
|
||||
"""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
raw_lines = data.get('lines') or []
|
||||
if not isinstance(raw_lines, list) or not raw_lines:
|
||||
return json_response({'status': 'error', 'message': 'Falta la lista de líneas contadas'}, 400)
|
||||
Item = request.env['skeen.inventario.item'].sudo()
|
||||
line_vals = []
|
||||
for l in raw_lines:
|
||||
try:
|
||||
item_id = int(l.get('item_id') or 0)
|
||||
qty_contado = float(l.get('qty_contado') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Línea con valores inválidos'}, 400)
|
||||
item = Item.browse(item_id)
|
||||
if not item.exists():
|
||||
return json_response({'status': 'error', 'message': f'Artículo {item_id} no encontrado'}, 404)
|
||||
line_vals.append((0, 0, {
|
||||
'item_id': item.id,
|
||||
'qty_sistema': item.qty,
|
||||
'qty_contado': qty_contado,
|
||||
'cost': item.cost,
|
||||
}))
|
||||
operador = getattr(request.frontend_user, 'name', '') or (
|
||||
getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '')
|
||||
conteo = request.env['skeen.inventario.conteo'].sudo().create({
|
||||
'user_name': operador,
|
||||
'notes': data.get('notes', ''),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
ajustes = conteo.apply_count()
|
||||
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True), 'ajustes': ajustes}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Reporte de comisiones (venta de artículos recetados por médico)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user