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

@@ -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" />