fase 2.2: impuestos configurables por instancia (tasa por servicio/articulo, calculo por linea, desglose en POS/tickets/estado de cuenta, reporte de impuestos, permiso ventas.impuestos) con defaults apagados

This commit is contained in:
2026-10-11 08:04:11 +00:00
parent 28a690aa0f
commit 358b85afcd
22 changed files with 1105 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ import { Printer, Wallet, AlertTriangle } from 'lucide-react';
import { Button, Modal, Skeleton, toast } from './ui';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type EstadoCuenta } from '../services/odoo';
import { useClinicaPrint } from '../lib/ticketConfig';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
@@ -78,12 +79,14 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
// El hook va antes de cualquier return condicional (reglas de React).
const clinic = useClinicaPrint();
const impuestos = useClinicSettings().impuestos;
if (!paciente) return null;
const fechaEmision = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
const totalVentas = ventas.reduce((a, v) => a + v.total, 0);
const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0);
const totalPorCobrar = ventas.reduce((a, v) => a + v.amount_due, 0);
const totalImpuesto = ventas.reduce((a, v) => a + (v.tax_total ?? 0), 0);
// Cronología unificada de citas y visitas
const cronologia = [
@@ -164,6 +167,13 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
</div>
</div>
{impuestos.activo && totalImpuesto > 0 && (
<p className="text-xs text-[#555] mb-4 text-right">
{impuestos.nombre} del periodo: <span className="font-semibold text-[#222]">{fmtMoney(totalImpuesto)}</span>
{impuestos.precios_incluyen_impuesto ? ' (incluido en los precios)' : ''}
</p>
)}
{/* Detalle de ventas */}
<h3 className="text-sm font-bold border-b border-[#ddd] pb-1 mb-2">Detalle de ventas</h3>
{ventas.length === 0 ? (

View File

@@ -13,6 +13,7 @@ import {
import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useSucursales } from '../lib/sucursales';
import { useClinicSettings } from '../lib/clinic-settings';
// Editor del diseño del ticket (Configuración › Tickets).
//
@@ -61,6 +62,7 @@ const SECCIONES: { bloque: string; label: string; claves: { key: string; label:
{ key: 'subtotal', label: 'Subtotal' },
{ key: 'descuento', label: 'Descuento' },
{ key: 'puntos_canjeados', label: 'Puntos canjeados' },
{ key: 'impuesto', label: 'Impuesto (si está activo)' },
{ key: 'total', label: 'Total' },
{ key: 'total_usd', label: 'Equivalente en dólares' },
],
@@ -138,6 +140,11 @@ export const TicketDesigner: FC = () => {
const config = useTicketConfig();
const { user } = useAuth();
const { sucursales } = useSucursales();
const impuestos = useClinicSettings().impuestos;
// Muestra con impuesto cuando la clínica lo tiene activo (si no, igual que hoy).
const muestra: TicketData = impuestos.activo
? { ...MUESTRA, tax: 480, tax_name: impuestos.nombre }
: MUESTRA;
// Nombre de la sucursal predeterminada para la vista previa (antes fijo).
const sucursalMuestra = (sucursales.find((s) => s.is_default) || sucursales[0])?.name || 'Rosarito Centro';
// Restaurar el formato original queda solo para administración del sistema;
@@ -508,7 +515,7 @@ export const TicketDesigner: FC = () => {
<div className="xl:sticky xl:top-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
<TicketDoc data={{ ...MUESTRA, sucursal: sucursalMuestra }} config={previewConfig} preview />
<TicketDoc data={{ ...muestra, sucursal: sucursalMuestra }} config={previewConfig} preview />
</div>
<p className="text-[11px] text-theme-muted mt-2">
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman

View File

@@ -19,6 +19,9 @@ export interface TicketData {
lines: TicketLinea[];
subtotal: number;
discount: number;
/** Impuesto del ticket y su etiqueta (vacío cuando los impuestos están apagados) */
tax?: number;
tax_name?: string;
total: number;
metodo?: string | null;
puntos_usados?: number;
@@ -173,6 +176,9 @@ export const TicketDoc: FC<TicketDocProps> = ({ data, config, preview }) => {
{on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && (
<Fila izquierda="Puntos canjeados" derecha={`-${data.puntos_usados} pts`} />
)}
{on('impuesto') && (data.tax ?? 0) > 0 && (
<Fila izquierda={data.tax_name || 'Impuesto'} derecha={fmtMXN(data.tax!)} />
)}
{on('total') && <Fila izquierda="Total" derecha={fmtMXN(data.total)} fuerte />}
{on('total_usd') && data.rate_used ? (
<Fila

View File

@@ -0,0 +1,215 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Percent, Receipt } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type TaxReportResult } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
// Reporte de impuestos: base, impuesto y total del periodo, agrupado por mes,
// servicio y sucursal. Con los impuestos apagados sale en ceros (no cambia
// ninguna cifra existente).
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
const mesLabel = (mes: string) => {
const [y, m] = mes.split('-');
if (!y || !m) return mes;
return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('es-MX', { month: 'long', year: 'numeric' });
};
const ImpuestosReport: FC = () => {
const { start, end, setStart, setEnd } = useReportRange('impuestos');
const [data, setData] = useState<TaxReportResult | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getTaxReport(start, end);
if (res.status === 'success') setData(res);
} catch (err) {
toast.error('Error al cargar impuestos');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
data.por_mes.forEach((m) =>
rows.push({ grupo: 'Por mes', concepto: mesLabel(m.mes), ventas: m.ventas, base: m.base, impuesto: m.impuesto }));
data.por_servicio.forEach((s) =>
rows.push({ grupo: 'Por servicio', concepto: s.servicio, ventas: s.cantidad, base: s.base, impuesto: s.impuesto }));
data.por_sucursal.forEach((s) =>
rows.push({ grupo: 'Por sucursal', concepto: s.sucursal, ventas: null, base: s.base, impuesto: s.impuesto }));
await exportToExcel({
filename: `impuestos-${start}_${end}`,
sheetName: 'Impuestos',
title: `Impuestos ${data.nombre}`,
subtitle: `Del ${start} al ${end}${data.incluido ? ' · precios con impuesto incluido' : ''}`,
columns: [
{ header: 'Grupo', key: 'grupo' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad / ventas', key: 'ventas', format: 'number' },
{ header: 'Base', key: 'base', format: 'currency' },
{ header: 'Impuesto', key: 'impuesto', format: 'currency' },
],
rows,
totals: { grupo: '', concepto: 'Impuesto total', base: data.total_base, impuesto: data.total_impuesto },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const chartData = (data?.por_mes || []).map((m) => ({ name: mesLabel(m.mes), impuesto: m.impuesto, base: m.base }));
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{data && !data.activo && (
<p className="text-xs text-theme-muted mt-3">
Los impuestos están apagados en la configuración de la clínica; este reporte sale en ceros.
</p>
)}
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : data ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Base (sin impuesto)</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total_base)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">{data.nombre} del periodo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total_impuesto)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total con impuesto</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total)}</p>
</Card>
</div>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">{data?.nombre || 'Impuesto'} por mes</h3>
{loading ? (
<Skeleton className="h-[280px] w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<Percent size={28} />} />
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} tickFormatter={(v: number) => formatCurrency(v)} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}
formatter={(v) => formatCurrency(Number(v))}
/>
<Bar dataKey="impuesto" name="Impuesto" fill="#57534e" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Por sucursal</h3>
{loading ? (
<Skeleton className="h-32 w-full" />
) : !data || data.por_sucursal.length === 0 ? (
<p className="text-sm text-theme-muted">Sin datos en el periodo.</p>
) : (
<div className="space-y-2">
{data.por_sucursal.map((s) => (
<div key={s.sucursal} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{s.sucursal}</span>
<span className="font-medium text-theme-heading">{formatCurrency(s.impuesto)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center gap-2">
<Receipt size={18} /> Impuesto por servicio
</h3>
{loading ? (
<Skeleton className="h-40 w-full" />
) : !data || data.por_servicio.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<Receipt size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-theme-muted border-b border-theme-border">
<th className="py-2 pr-4 font-medium">Servicio / artículo</th>
<th className="py-2 pr-4 font-medium text-right">Cantidad</th>
<th className="py-2 pr-4 font-medium text-right">Base</th>
<th className="py-2 font-medium text-right">Impuesto</th>
</tr>
</thead>
<tbody>
{data.por_servicio.map((s) => (
<tr key={s.servicio} className="border-b border-theme-border last:border-0">
<td className="py-2 pr-4 text-theme-heading">{s.servicio}</td>
<td className="py-2 pr-4 text-right text-theme-muted">{s.cantidad.toLocaleString('es-MX')}</td>
<td className="py-2 pr-4 text-right text-theme-heading">{formatCurrency(s.base)}</td>
<td className="py-2 text-right font-medium text-theme-heading">{formatCurrency(s.impuesto)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default ImpuestosReport;

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
// Es la fuente que decide si los impuestos están activos en cada pantalla.
const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings => ({
...CLINIC_SETTINGS_DEFAULT,
...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
});
let actual: ClinicSettings = normalizar(null);
let peticion: Promise<ClinicSettings> | null = null;
const oyentes = new Set<(s: ClinicSettings) => void>();
const publicar = (s: ClinicSettings) => {
actual = s;
oyentes.forEach((f) => f(s));
};
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
if (peticion) return peticion;
peticion = odooApi.getClinicSettings()
.then((res) => {
if (res.status === 'success' && res.settings) publicar(normalizar(res.settings));
return actual;
})
.catch(() => actual)
.finally(() => { peticion = null; });
return peticion;
};
/** Fuerza la recarga (tras guardar en Configuración). */
export const recargarClinicSettings = (): Promise<ClinicSettings> => {
peticion = null;
return cargarClinicSettings();
};
/** Registra los ajustes ya cargados/guardados sin volver a pedirlos. */
export const publicarClinicSettings = (settings: Partial<ClinicSettings>) =>
publicar(normalizar(settings));
export const useClinicSettings = (): ClinicSettings => {
const [s, setS] = useState(actual);
useEffect(() => {
const f = (v: ClinicSettings) => setS(v);
oyentes.add(f);
cargarClinicSettings().then(setS);
return () => { oyentes.delete(f); };
}, []);
return s;
};

View File

@@ -9,7 +9,7 @@ export const PERM_KEYS: string[] = [
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.cancelar',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar',
'ventas.devolver', 'ventas.reimprimir',
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
'servicios.ver', 'servicios.editar',
@@ -46,6 +46,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
'visitas.eliminar': W, 'visitas.archivos': W,
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,
'ventas.impuestos': MONEY,
'ventas.cancelar': AG, 'ventas.devolver': MONEY, 'ventas.reimprimir': ALL,
'inventario.ver': ALL, 'inventario.ajustar': WN, 'inventario.mover': WN,
'inventario.costos': WN,

View File

@@ -68,6 +68,7 @@ export const FALLBACK_CONFIG: TicketConfig = {
subtotal: true,
descuento: true,
puntos_canjeados: true,
impuesto: true,
total: true,
total_usd: true,
metodo_pago: true,

View File

@@ -14,8 +14,9 @@ import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
@@ -101,7 +102,11 @@ const Configuracion: FC = () => {
} catch {
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
if (alive) setSettings(next);
if (alive) setSettings({
...CLINIC_SETTINGS_DEFAULT,
...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
});
})
.catch(() => {
toast.error('No se pudieron cargar los ajustes de la clínica');
@@ -115,7 +120,10 @@ const Configuracion: FC = () => {
const save = async () => {
try {
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
if (res.status === 'success') setSettings(res.settings);
if (res.status === 'success') {
setSettings(res.settings);
publicarClinicSettings(res.settings);
}
toast.success('Configuración guardada');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
@@ -127,6 +135,13 @@ const Configuracion: FC = () => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
const updateImpuesto = (patch: Partial<ImpuestosSettings>) => {
setSettings((prev) => ({
...prev,
impuestos: { ...IMPUESTOS_DEFAULT, ...prev.impuestos, ...patch },
}));
};
// ---- Tipo de cambio USD/MXN ----
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
const [tcInput, setTcInput] = useState('');
@@ -506,6 +521,74 @@ const Configuracion: FC = () => {
<p className="text-xs text-theme-muted">
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
</p>
{/* Impuestos (por instancia). Apagados = comportamiento de
siempre: sin cálculo, sin desglose y sin renglón en tickets. */}
<div className="border border-theme-border rounded-xl p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Percent size={16} className="text-theme-muted" />
<span className="text-sm font-medium text-theme-heading">Impuestos</span>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.activo}
onChange={(e) => updateImpuesto({ activo: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Calcular impuestos
</label>
</div>
<p className="text-xs text-theme-muted">
{settings.impuestos.activo
? 'El impuesto se muestra y se cobra en POS, tickets y reportes.'
: 'Apagado: los totales, tickets y reportes salen exactamente como hoy.'}
</p>
{settings.impuestos.activo && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nombre del impuesto"
value={settings.impuestos.nombre}
onChange={(e) => updateImpuesto({ nombre: e.target.value })}
placeholder="IVA"
/>
<Input
label="Tasa por defecto (%)"
type="number"
min={0}
max={100}
step={0.01}
value={String(settings.impuestos.tasa_default)}
onChange={(e) => updateImpuesto({ tasa_default: parseFloat(e.target.value) || 0 })}
/>
</div>
<Input
label="Opciones de tasa (%)"
value={settings.impuestos.tasa_opciones.join(', ')}
onChange={(e) => {
const opciones = e.target.value
.split(',')
.map((v) => parseFloat(v.trim()))
.filter((v) => !Number.isNaN(v) && v >= 0 && v <= 100);
updateImpuesto({ tasa_opciones: opciones.length ? opciones : [0] });
}}
placeholder="0, 8, 16"
/>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.precios_incluyen_impuesto}
onChange={(e) => updateImpuesto({ precios_incluyen_impuesto: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Los precios capturados ya incluyen impuesto
</label>
</>
)}
</div>
<div className="pt-2">
<Button onClick={save}>
<Save size={16} className="mr-2" />

View File

@@ -10,6 +10,7 @@ import ConteoPrint from '../components/inventario/ConteoPrint';
import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
import { useClinicSettings } from '../lib/clinic-settings';
const TABS = [
{ key: 'items', label: 'Items' },
@@ -61,12 +62,14 @@ const MOVE_OPTIONS = [
const emptyItem: Partial<InventoryItem> = {
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, price: 0, expiry_date: null, notes: '',
tax_use_default: true, tax_rate: 0,
};
const Inventario: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'items';
const setTab = (t: string) => setSearchParams({ tab: t });
const impuestos = useClinicSettings().impuestos;
const [items, setItems] = useState<InventoryItem[]>([]);
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
const [loading, setLoading] = useState(true);
@@ -178,7 +181,8 @@ const Inventario: FC = () => {
const openEdit = (it: InventoryItem) => {
setEditItem(it);
setEditForm({ name: it.name, sku: it.sku, category: it.category, unit: it.unit,
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes });
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes,
tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 });
};
const saveEdit = async () => {
@@ -482,6 +486,22 @@ const Inventario: FC = () => {
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(newForm.price ?? 0)} onChange={(e) => setNewForm({ ...newForm, price: parseFloat(e.target.value) || 0 })} />
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...[...new Set([...(impuestos.tasa_opciones || []), newForm.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={newForm.tax_use_default !== false ? 'default' : String(newForm.tax_rate ?? 0)}
onChange={(e) => {
const v = e.target.value;
setNewForm({ ...newForm, tax_use_default: v === 'default', tax_rate: v === 'default' ? 0 : parseFloat(v) || 0 });
}}
/>
)}
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
{newForm.kind === 'consumible' && (
@@ -500,6 +520,22 @@ const Inventario: FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"><Input label="Nombre" value={editForm.name || ''} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(editForm.price ?? 0)} onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })} />
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...[...new Set([...(impuestos.tasa_opciones || []), editForm.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={editForm.tax_use_default !== false ? 'default' : String(editForm.tax_rate ?? 0)}
onChange={(e) => {
const v = e.target.value;
setEditForm({ ...editForm, tax_use_default: v === 'default', tax_rate: v === 'default' ? 0 : parseFloat(v) || 0 });
}}
/>
)}
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(editForm.cost ?? 0)} onChange={(e) => setEditForm({ ...editForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="SKU / Código" value={editForm.sku || ''} onChange={(e) => setEditForm({ ...editForm, sku: e.target.value })} />
<Input label="Línea / Categoría" value={editForm.category || ''} onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} />

View File

@@ -21,6 +21,7 @@ import {
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
@@ -49,11 +50,15 @@ interface TicketLine {
sesiones?: number;
stock?: number;
unit?: string;
/** Tasa de impuesto (%) de la línea (0 con impuestos apagados) */
tax_rate?: number;
}
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
@@ -347,16 +352,28 @@ const Pos: FC = () => {
service_id: p.service_id ?? undefined,
paquete_id: p.id,
sesiones: p.sessions,
tax_rate: tasaDePaquete(p),
}];
});
};
// ---- Ticket ----
// Tasa de impuesto que corresponde a un platillo del catálogo (0 si apagado).
const tasaDeServicio = (s: Service) =>
impuestos.activo ? (s.tax_use_default !== false ? impuestos.tasa_default : (s.tax_rate ?? 0)) : 0;
const tasaDeItem = (it: InventoryItem) =>
impuestos.activo ? (it.tax_use_default !== false ? impuestos.tasa_default : (it.tax_rate ?? 0)) : 0;
const tasaDePaquete = (p: Paquete) => {
if (!impuestos.activo) return 0;
const servicio = services.find((s) => s.id === p.service_id);
return servicio ? tasaDeServicio(servicio) : impuestos.tasa_default;
};
const addLine = (service: Service) => { setLines((prev) => {
const key = `s-${service.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id }];
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id, tax_rate: tasaDeServicio(service) }];
});
};
@@ -365,7 +382,7 @@ const Pos: FC = () => {
const key = `i-${item.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit }];
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit, tax_rate: tasaDeItem(item) }];
});
};
@@ -380,9 +397,20 @@ const Pos: FC = () => {
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
};
const subtotal = lines.reduce((a, l) => a + l.qty * l.price, 0);
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
// y el total sale exactamente igual que antes.
const bruto = lines.reduce((a, l) => a + l.qty * l.price, 0);
const impuestoLinea = (l: TicketLine): number => {
if (!impuestos.activo) return 0;
const t = l.tax_rate ?? 0;
if (t <= 0) return 0;
const b = l.qty * l.price;
return impuestos.precios_incluyen_impuesto ? b - b / (1 + t / 100) : (b * t) / 100;
};
const impuesto = impuestos.activo ? lines.reduce((a, l) => a + impuestoLinea(l), 0) : 0;
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum);
const total = Math.max(0, subtotal - discountNum + impuesto);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
@@ -443,6 +471,7 @@ const Pos: FC = () => {
...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}),
quantity: l.qty,
price_unit: l.price,
...(impuestos.activo ? { tax_rate: l.tax_rate ?? 0 } : {}),
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
...(doctorId ? { prescribed_by_id: parseInt(doctorId, 10) } : {}),
})),
@@ -521,11 +550,15 @@ const Pos: FC = () => {
const key = l.kind === 'service' ? `s-${l.service_id}` : `i-${l.item_id}`;
const found = nuevas.find((x) => x.key === key);
if (found) { found.qty += l.quantity; continue; }
const tasa = l.kind === 'service'
? (() => { const s = services.find((x) => x.id === l.service_id); return s ? tasaDeServicio(s) : impuestos.tasa_default; })()
: (() => { const it = invItems.find((x) => x.id === l.item_id); return it ? tasaDeItem(it) : impuestos.tasa_default; })();
nuevas.push({
key,
name: l.name,
qty: l.quantity,
price: l.price_current ?? l.price_last,
tax_rate: impuestos.activo ? tasa : 0,
...(l.kind === 'service' ? { service_id: l.service_id ?? undefined } : {}),
...(l.kind === 'item' ? { item_id: l.item_id ?? undefined, stock: l.stock ?? undefined, unit: l.unit ?? undefined } : {}),
});
@@ -769,13 +802,37 @@ const Pos: FC = () => {
>
<X size={14} />
</button>
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
)}
</p>
{impuestos.activo && (
<span className="flex items-center gap-1.5 text-[11px] text-theme-muted">
<span>{impuestos.nombre}</span>
{puedeImpuestos ? (
<select
value={String(l.tax_rate ?? 0)}
onChange={(e) => updateLine(l.key, { tax_rate: parseFloat(e.target.value) || 0 })}
className="border border-theme-border-strong rounded-md px-1 py-0.5 text-[11px]"
title={`Tasa de ${impuestos.nombre} de la línea`}
>
{[...new Set([...(impuestos.tasa_opciones || []), l.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => <option key={o} value={String(o)}>{o}%</option>)}
</select>
) : (
<span>{l.tax_rate ?? 0}%</span>
)}
{impuestoLinea(l) > 0 && (
<span>{fmtMoney(impuestoLinea(l))}</span>
)}
</span>
)}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
@@ -807,6 +864,19 @@ const Pos: FC = () => {
</span>
</span>
</div>
{impuestos.activo && (
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
</span>
<span className="text-theme-heading">
{fmtMoney(impuesto)}
<span className="text-xs text-theme-muted ml-1.5">
{toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'}
</span>
</span>
</div>
)}
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<span className="flex items-center gap-1.5">
@@ -1407,6 +1477,8 @@ const Pos: FC = () => {
})),
subtotal: resultado.sale.subtotal,
discount: resultado.sale.discount,
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
tax_name: impuestos.nombre,
total: resultado.sale.total,
metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos',
puntos_usados: resultado.puntos_usados,

View File

@@ -18,6 +18,7 @@ import Layout from '../components/Layout';
import DailyReport from '../components/reportes/DailyReport';
import CortesReport from '../components/reportes/CortesReport';
import IngresosReport from '../components/reportes/IngresosReport';
import ImpuestosReport from '../components/reportes/ImpuestosReport';
import InventarioReport from '../components/reportes/InventarioReport';
import AdeudosReport from '../components/reportes/AdeudosReport';
import ComisionesReport from '../components/reportes/ComisionesReport';
@@ -72,6 +73,7 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'general', label: 'General', desc: 'Resumen del periodo, citas y comisiones' },
{ key: 'diario', label: 'Movimientos diarios', desc: 'Citas, ventas, cobros e inventario del día' },
{ key: 'ingresos', label: 'Ingresos', desc: 'Ventas y formas de pago' },
{ key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' },
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
@@ -448,6 +450,8 @@ const ReportesContent: FC = () => {
<CortesReport />
) : tab === 'ingresos' ? (
<IngresosReport />
) : tab === 'impuestos' ? (
<ImpuestosReport />
) : tab === 'inventario' ? (
<InventarioReport />
) : tab === 'adeudos' ? (

View File

@@ -19,6 +19,7 @@ import { odooApi, type Service } from '../services/odoo';
import { useGlosario } from '../lib/glosario';
import { useAuth } from '../lib/auth';
import { useServiceCategories } from '../lib/service-categories';
import { useClinicSettings } from '../lib/clinic-settings';
interface ServiceForm {
name: string;
@@ -32,6 +33,8 @@ interface ServiceForm {
description: string;
color: string;
service_group: string;
tax_use_default: boolean;
tax_rate: string;
}
const emptyForm: ServiceForm = {
@@ -46,12 +49,15 @@ const emptyForm: ServiceForm = {
description: '',
color: '#EDA588',
service_group: '',
tax_use_default: true,
tax_rate: '0',
};
const Servicios: FC = () => {
const { term } = useGlosario();
const { can } = useAuth();
const puedeEditar = can('servicios.editar');
const impuestos = useClinicSettings().impuestos;
const { categories, labelFor } = useServiceCategories();
const categoryOptions = useMemo(
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
@@ -79,6 +85,12 @@ const Servicios: FC = () => {
const [reordenando, setReordenando] = useState(false);
const [form, setForm] = useState<ServiceForm>(emptyForm);
// Opciones de tasa del selector: las configuradas + la del servicio en edición.
const opcionesImpuesto = useMemo(() => {
const set = new Set<number>([...(impuestos.tasa_opciones || []), parseFloat(form.tax_rate) || 0]);
return [...set].sort((a, b) => a - b);
}, [impuestos.tasa_opciones, form.tax_rate]);
const load = useCallback(async () => {
try {
setLoading(true);
@@ -156,6 +168,8 @@ const Servicios: FC = () => {
description: s.description || '',
color: s.color || '#EDA588',
service_group: s.service_group || '',
tax_use_default: s.tax_use_default !== false,
tax_rate: String(s.tax_rate ?? 0),
});
setModalOpen(true);
};
@@ -177,6 +191,8 @@ const Servicios: FC = () => {
description: s.description || '',
color: s.color || '#EDA588',
service_group: s.service_group || '',
tax_use_default: s.tax_use_default !== false,
tax_rate: String(s.tax_rate ?? 0),
});
setModalOpen(true);
};
@@ -261,6 +277,10 @@ const Servicios: FC = () => {
color: form.color,
service_group: form.service_group,
};
if (impuestos.activo) {
payload.tax_use_default = form.tax_use_default;
payload.tax_rate = form.tax_use_default ? 0 : (parseFloat(form.tax_rate) || 0);
}
try {
setSubmitting(true);
if (editing) {
@@ -566,6 +586,20 @@ const Servicios: FC = () => {
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
</div>
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...opcionesImpuesto.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={form.tax_use_default ? 'default' : form.tax_rate}
onChange={(e) => {
const v = e.target.value;
setForm({ ...form, tax_use_default: v === 'default', tax_rate: v === 'default' ? '0' : v });
}}
/>
)}
<TextArea
label="Descripción"
value={form.description}

View File

@@ -8,6 +8,7 @@ import TicketPrint from '../components/TicketPrint';
import TicketEditor from '../components/TicketEditor';
import { odooApi, type Sale } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
const PREF_KEY = 'skeen_tickets_filtros';
@@ -48,6 +49,7 @@ const PAGE_SIZE = 50;
*/
const Tickets: FC = () => {
const { user, can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
const puedeCancelar = can('ventas.cancelar');
const prefs = readDateFilterPrefs(PREF_KEY);
@@ -511,6 +513,8 @@ const Tickets: FC = () => {
})),
subtotal: reimprimir.subtotal,
discount: reimprimir.discount,
tax: reimprimir.tax_total ?? reimprimir.tax ?? 0,
tax_name: impuestos.nombre,
total: reimprimir.total,
amount_paid: reimprimir.amount_paid,
amount_due: reimprimir.amount_due,

View File

@@ -23,6 +23,7 @@ import {
import { odooApi, type Sale, type SaleLine, type Service, type Doctor } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange';
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
@@ -47,6 +48,7 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const { openPatient } = usePatientDrawer();
const { brand, can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeCancelar = can('ventas.cancelar');
const puedeCobrar = can('ventas.cobrar');
const puedeDevolver = can('ventas.devolver');
@@ -904,6 +906,8 @@ const Ventas: FC = () => {
})),
subtotal: printSale.subtotal,
discount: printSale.discount,
tax: printSale.tax_total ?? printSale.tax ?? 0,
tax_name: impuestos.nombre,
total: printSale.total,
amount_paid: printSale.amount_paid,
amount_due: printSale.amount_due,

View File

@@ -316,6 +316,9 @@ export interface Service {
sequence?: number;
sold_90d?: number | null;
genera_monedero?: boolean;
/** Impuesto propio del servicio (%) y si usa la tasa por defecto de la config */
tax_rate?: number;
tax_use_default?: boolean;
}
export interface ImpactSimulationDoctor {
@@ -476,6 +479,9 @@ export interface SaleLine {
quantity: number;
price_unit: number;
subtotal: number;
/** Tasa (%) e importe del impuesto de la línea (0 con impuestos apagados) */
tax_rate?: number;
tax_amount?: number;
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
@@ -531,6 +537,9 @@ export interface Sale {
subtotal: number;
discount: number;
tax: number;
/** Impuesto sumado de las líneas y base sin impuesto (0 = sin impuestos) */
tax_total?: number;
subtotal_sin_impuesto?: number;
total: number;
amount_paid: number;
amount_due: number;
@@ -757,6 +766,9 @@ export interface InventoryItem {
last_count_date: string | null;
active: boolean;
genera_monedero?: boolean;
/** Impuesto propio del artículo (%) y si usa la tasa por defecto */
tax_rate?: number;
tax_use_default?: boolean;
notes: string;
stock_level: InventoryLevel;
}
@@ -963,6 +975,27 @@ export interface ServiceCategory {
}
// ---- Ajustes de la clínica ----
export interface ImpuestosSettings {
/** Enciende el cálculo del impuesto en POS, tickets y reportes */
activo: boolean;
/** Etiqueta del impuesto (p. ej. 'IVA') */
nombre: string;
/** % aplicado cuando el catálogo no fija uno */
tasa_default: number;
/** El precio capturado ya trae impuesto: el subtotal se desglosa hacia atrás */
precios_incluyen_impuesto: boolean;
/** Tasas ofrecidas en los formularios (%) */
tasa_opciones: number[];
}
export const IMPUESTOS_DEFAULT: ImpuestosSettings = {
activo: false,
nombre: 'IVA',
tasa_default: 0,
precios_incluyen_impuesto: false,
tasa_opciones: [0, 8, 16],
};
export interface ClinicSettings {
nombre: string;
telefono: string;
@@ -976,6 +1009,7 @@ export interface ClinicSettings {
url_publica: string;
moneda_base: string;
nota_ticket: string;
impuestos: ImpuestosSettings;
}
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
@@ -991,6 +1025,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
url_publica: '',
moneda_base: 'MXN',
nota_ticket: '',
impuestos: IMPUESTOS_DEFAULT,
};
export interface FrontendUser {
@@ -1407,18 +1442,39 @@ export interface AppNotification {
export interface PosCheckoutPayload {
partner_id: number;
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number; tax_rate?: number }[];
discount?: number;
/** Tasa de impuesto a nivel venta (opcional); cada línea puede traer la suya */
tax_rate?: number;
payment_method?: string;
amount_received?: number;
pay_with_points?: boolean;
currency?: 'MXN' | 'USD';
}
export interface ImpuestosDesglose {
activo: boolean;
nombre: string;
incluido: boolean;
subtotal: number;
descuento: number;
impuesto: number;
total: number;
subtotal_usd?: number;
impuesto_usd?: number;
total_usd?: number;
}
export interface PosCheckoutResult {
status: string;
sale: Sale;
payment: Payment | null;
/** Desglose de la venta (subtotal, descuento, impuesto, total) */
subtotal?: number;
descuento?: number;
impuesto?: number;
total?: number;
impuestos?: ImpuestosDesglose;
cambio: number;
/** Efectivo recibido en caja (MXN), para imprimir "Recibido" en el ticket */
recibido?: number;
@@ -1446,6 +1502,42 @@ export interface InventoryReportSummary {
critical: number;
}
// ---- Reporte de impuestos ----
export interface TaxMonthRow {
mes: string;
base: number;
impuesto: number;
ventas: number;
}
export interface TaxServiceRow {
servicio: string;
base: number;
impuesto: number;
cantidad: number;
}
export interface TaxBranchRow {
sucursal: string;
base: number;
impuesto: number;
}
export interface TaxReportResult {
status: string;
start: string;
end: string;
activo: boolean;
nombre: string;
incluido: boolean;
total_base: number;
total_impuesto: number;
total: number;
por_mes: TaxMonthRow[];
por_servicio: TaxServiceRow[];
por_sucursal: TaxBranchRow[];
}
export interface ApiResponse<T> {
status: string;
message?: string;
@@ -2517,6 +2609,11 @@ export const odooApi = {
return data;
},
async getTaxReport(start?: string, end?: string): Promise<TaxReportResult> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/taxes`, { params: { start, end } });
return data;
},
// Tipo de cambio
async getExchangeRate(): Promise<{ status: string; current: { rate: number; date: string; source: string } | null; history: { id: number; date: string; rate: number; source: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`);