diff --git a/frontend/src/components/EstadoCuentaPrint.tsx b/frontend/src/components/EstadoCuentaPrint.tsx index 7349309..a5f9137 100644 --- a/frontend/src/components/EstadoCuentaPrint.tsx +++ b/frontend/src/components/EstadoCuentaPrint.tsx @@ -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 = ({ 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 = ({ isOpen, onClose, + {impuestos.activo && totalImpuesto > 0 && ( +

+ {impuestos.nombre} del periodo: {fmtMoney(totalImpuesto)} + {impuestos.precios_incluyen_impuesto ? ' (incluido en los precios)' : ''} +

+ )} + {/* Detalle de ventas */}

Detalle de ventas

{ventas.length === 0 ? ( diff --git a/frontend/src/components/TicketDesigner.tsx b/frontend/src/components/TicketDesigner.tsx index b6a0ed8..ddd704f 100644 --- a/frontend/src/components/TicketDesigner.tsx +++ b/frontend/src/components/TicketDesigner.tsx @@ -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 = () => {

Vista previa

- +

La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman diff --git a/frontend/src/components/TicketDoc.tsx b/frontend/src/components/TicketDoc.tsx index 9958f9f..1f2e85f 100644 --- a/frontend/src/components/TicketDoc.tsx +++ b/frontend/src/components/TicketDoc.tsx @@ -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 = ({ data, config, preview }) => { {on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && ( )} + {on('impuesto') && (data.tax ?? 0) > 0 && ( + + )} {on('total') && } {on('total_usd') && data.rate_used ? ( + 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(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[] = []; + 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 ( +

+ + +
+ setStart(e.target.value)} className="sm:max-w-[180px]" /> + setEnd(e.target.value)} className="sm:max-w-[180px]" /> +
+ +
+
+ {data && !data.activo && ( +

+ Los impuestos están apagados en la configuración de la clínica; este reporte sale en ceros. +

+ )} +
+
+ + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+ ) : data ? ( +
+ +

Base (sin impuesto)

+

{formatCurrency(data.total_base)}

+
+ +

{data.nombre} del periodo

+

{formatCurrency(data.total_impuesto)}

+
+ +

Total con impuesto

+

{formatCurrency(data.total)}

+
+
+ ) : null} + +
+ + +

{data?.nombre || 'Impuesto'} por mes

+ {loading ? ( + + ) : chartData.length === 0 ? ( + } /> + ) : ( + + + + + formatCurrency(v)} /> + formatCurrency(Number(v))} + /> + + + + )} +
+
+ + + +

Por sucursal

+ {loading ? ( + + ) : !data || data.por_sucursal.length === 0 ? ( +

Sin datos en el periodo.

+ ) : ( +
+ {data.por_sucursal.map((s) => ( +
+ {s.sucursal} + {formatCurrency(s.impuesto)} +
+ ))} +
+ )} +
+
+
+ + + +

+ Impuesto por servicio +

+ {loading ? ( + + ) : !data || data.por_servicio.length === 0 ? ( + } /> + ) : ( +
+ + + + + + + + + + + {data.por_servicio.map((s) => ( + + + + + + + ))} + +
Servicio / artículoCantidadBaseImpuesto
{s.servicio}{s.cantidad.toLocaleString('es-MX')}{formatCurrency(s.base)}{formatCurrency(s.impuesto)}
+
+ )} +
+
+
+ ); +}; + +export default ImpuestosReport; diff --git a/frontend/src/lib/clinic-settings.ts b/frontend/src/lib/clinic-settings.ts new file mode 100644 index 0000000..6619c5a --- /dev/null +++ b/frontend/src/lib/clinic-settings.ts @@ -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 | null): ClinicSettings => ({ + ...CLINIC_SETTINGS_DEFAULT, + ...(settings || {}), + impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) }, +}); + +let actual: ClinicSettings = normalizar(null); +let peticion: Promise | null = null; +const oyentes = new Set<(s: ClinicSettings) => void>(); + +const publicar = (s: ClinicSettings) => { + actual = s; + oyentes.forEach((f) => f(s)); +}; + +export const cargarClinicSettings = (): Promise => { + 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 => { + peticion = null; + return cargarClinicSettings(); +}; + +/** Registra los ajustes ya cargados/guardados sin volver a pedirlos. */ +export const publicarClinicSettings = (settings: Partial) => + 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; +}; diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index b616e96..b9c764d 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -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 = { '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, diff --git a/frontend/src/lib/ticketConfig.ts b/frontend/src/lib/ticketConfig.ts index 6bbcb9a..3d256de 100644 --- a/frontend/src/lib/ticketConfig.ts +++ b/frontend/src/lib/ticketConfig.ts @@ -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, diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index e99c1b1..47da953 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -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) => { + 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 = () => {

Moneda base: {settings.moneda_base || 'MXN'}

+ + {/* Impuestos (por instancia). Apagados = comportamiento de + siempre: sin cálculo, sin desglose y sin renglón en tickets. */} +
+
+
+ + Impuestos +
+ +
+

+ {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.'} +

+ {settings.impuestos.activo && ( + <> +
+ updateImpuesto({ nombre: e.target.value })} + placeholder="IVA" + /> + updateImpuesto({ tasa_default: parseFloat(e.target.value) || 0 })} + /> +
+ { + 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" + /> + + + )} +
+
-

- {l.item_id && } - {l.name} - {l.item_id && l.stock !== undefined && ( - {l.stock} {l.unit || 'pzas'} en existencia +

+

+ {l.item_id && } + {l.name} + {l.item_id && l.stock !== undefined && ( + {l.stock} {l.unit || 'pzas'} en existencia + )} +

+ {impuestos.activo && ( + + {impuestos.nombre} + {puedeImpuestos ? ( + + ) : ( + {l.tax_rate ?? 0}% + )} + {impuestoLinea(l) > 0 && ( + {fmtMoney(impuestoLinea(l))} + )} + )} -

+
{l.qty} @@ -807,6 +864,19 @@ const Pos: FC = () => {
+ {impuestos.activo && ( +
+ + {impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''} + + + {fmtMoney(impuesto)} + + {toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'} + + +
+ )}
Descuento @@ -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, diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index 5407fd2..c7377d5 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -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 = () => { ) : tab === 'ingresos' ? ( + ) : tab === 'impuestos' ? ( + ) : tab === 'inventario' ? ( ) : tab === 'adeudos' ? ( diff --git a/frontend/src/pages/Servicios.tsx b/frontend/src/pages/Servicios.tsx index 0a32978..7426784 100644 --- a/frontend/src/pages/Servicios.tsx +++ b/frontend/src/pages/Servicios.tsx @@ -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(emptyForm); + // Opciones de tasa del selector: las configuradas + la del servicio en edición. + const opcionesImpuesto = useMemo(() => { + const set = new Set([...(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 })} />
+ {impuestos.activo && ( +