Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/configuracion/page.tsx
Horux Dev 9c4a2343f5 feat(auth): supervisor puede configurar FIEL, CSD y Obligaciones
- Backend: agrega 'supervisor' a authorize() de rutas:
  - POST/DELETE /contribuyentes/:id/fiel
  - POST /contribuyentes/:id/facturapi/csd
  - POST/DELETE /contribuyentes/:id/obligaciones/*
- Frontend: muestra tarjeta 'Obligaciones Fiscales' en /configuracion
  para rol supervisor
2026-05-25 16:39:31 +00:00

648 lines
26 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Label, Input } from '@horux/shared-ui';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw, Scale, Trash2, MapPin, KeyRound, Tags, Receipt, Bell, Package } from 'lucide-react';
import { isGlobalAdminRfc, isDespachoTenant } from '@horux/shared';
import Link from 'next/link';
import { apiClient } from '@/lib/api/client';
import { useBancos, useCreateBanco, useDeleteBanco } from '@/lib/hooks/use-bancos';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{
name: 'light',
label: 'Light',
description: 'Tema claro profesional',
layoutDesc: 'Sidebar estándar fijo',
layoutIcon: Sidebar,
},
{
name: 'dark',
label: 'Dark',
description: 'Modo oscuro con acentos neón',
layoutDesc: 'Sidebar flotante con efecto glass',
layoutIcon: Sparkles,
},
];
function RegimenesActivosSection() {
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const tenantKey = viewingTenantId || 'own';
const { data: catalogo } = useQuery({
queryKey: ['regimenes-catalogo'],
queryFn: async () => {
const res = await apiClient.get<{ id: number; clave: string; descripcion: string; tipoPersona: string }[]>('/regimenes');
return res.data;
},
});
// Despacho: read régimen from contribuyente; Horux360: from tenant activos
const { data: activos } = useQuery({
queryKey: ['regimenes-activos', tenantKey, selectedContribuyenteId],
queryFn: async () => {
if (isDespacho && selectedContribuyenteId) {
const res = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}`);
const c = res.data;
// Build activos array from regimen_fiscal field (may be comma-separated: "623,606,612")
if (c.regimenFiscal && catalogo) {
const claves = c.regimenFiscal.split(',').map((s: string) => s.trim());
return claves
.map((clave: string) => catalogo.find((cat: any) => cat.clave === clave))
.filter(Boolean)
.map((cat: any) => ({ id: cat.id, clave: cat.clave, descripcion: cat.descripcion }));
}
return [];
}
const res = await apiClient.get<{ id: number; clave: string; descripcion: string }[]>('/regimenes/activos');
return res.data;
},
enabled: !!catalogo,
});
const [selected, setSelected] = useState<Set<number>>(new Set());
useEffect(() => {
if (activos && catalogo) {
const ids = new Set(activos.map((a: { clave: string }) => catalogo.find((c: { clave: string; id: number }) => c.clave === a.clave)?.id).filter(Boolean) as number[]);
setSelected(ids);
}
}, [activos, catalogo]);
const toggle = (id: number) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
await apiClient.put('/regimenes/activos', { regimenIds: Array.from(selected) });
queryClient.invalidateQueries({ queryKey: ['regimenes-activos', tenantKey, selectedContribuyenteId] });
queryClient.invalidateQueries({ queryKey: ['calendario'] });
queryClient.invalidateQueries({ queryKey: ['regimenes-periodo'] });
} catch {
alert('Error al guardar');
} finally {
setSaving(false);
}
};
if (!catalogo) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Scale className="h-4 w-4" />
Regimenes Fiscales Activos
</CardTitle>
<CardDescription>
Selecciona los regimenes fiscales bajo los que opera tu empresa. Esto afecta el calendario de obligaciones y los filtros disponibles.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2 md:grid-cols-2">
{catalogo.map(r => (
<button
key={r.id}
onClick={() => toggle(r.id)}
className={`flex items-center gap-3 p-3 rounded-lg border text-left transition-all ${
selected.has(r.id)
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/30'
}`}
>
<div className={`h-5 w-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
selected.has(r.id) ? 'border-primary bg-primary' : 'border-muted-foreground/30'
}`}>
{selected.has(r.id) && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<div className="min-w-0">
<span className="text-xs font-mono font-bold text-muted-foreground">{r.clave}</span>
<p className="text-sm truncate">{r.descripcion}</p>
</div>
</button>
))}
</div>
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">{selected.size} regimenes seleccionados</p>
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</CardContent>
</Card>
);
}
function DomicilioFiscalSection() {
const queryClient = useQueryClient();
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const tenantKey = viewingTenantId || 'own';
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Despacho: read domicilio from contribuyente; Horux360: read from tenant datos-fiscales
const { data, isLoading } = useQuery({
queryKey: ['datos-fiscales', tenantKey, selectedContribuyenteId],
queryFn: async () => {
if (isDespacho && selectedContribuyenteId) {
const res = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}`);
const c = res.data;
const dom = c.domicilio || {};
return {
codigoPostal: c.codigoPostal || dom.codigoPostal || '',
calle: dom.calle || '',
numExterior: dom.numExterior || '',
numInterior: dom.numInterior || '',
colonia: dom.colonia || '',
ciudad: dom.ciudad || '',
municipio: dom.municipio || '',
estado: dom.estado || '',
telefono: dom.telefono || '',
};
}
const res = await apiClient.get('/facturacion/datos-fiscales');
return res.data;
},
});
const [form, setForm] = useState({
codigoPostal: '', calle: '', numExterior: '', numInterior: '',
colonia: '', ciudad: '', municipio: '', estado: '', telefono: '',
});
useEffect(() => {
if (data) {
setForm({
codigoPostal: data.codigoPostal || '',
calle: data.calle || '',
numExterior: data.numExterior || '',
numInterior: data.numInterior || '',
colonia: data.colonia || '',
ciudad: data.ciudad || '',
municipio: data.municipio || '',
estado: data.estado || '',
telefono: data.telefono || '',
});
}
}, [data]);
const handleSave = async () => {
setSaving(true);
setMsg(null);
try {
if (isDespacho && selectedContribuyenteId) {
// Save domicilio to contribuyente
await apiClient.put(`/contribuyentes/${selectedContribuyenteId}`, {
domicilio: form,
codigoPostal: form.codigoPostal,
});
} else {
await apiClient.put('/facturacion/datos-fiscales', form);
}
queryClient.invalidateQueries({ queryKey: ['datos-fiscales', tenantKey, selectedContribuyenteId] });
setMsg('Datos guardados');
setTimeout(() => setMsg(null), 3000);
} catch {
setMsg('Error al guardar');
} finally {
setSaving(false);
}
};
if (isLoading) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4" />
Domicilio Fiscal
</CardTitle>
<CardDescription>
Dirección y teléfono de la empresa. Se usa para facturas al público en general.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Código Postal</Label>
<Input value={form.codigoPostal} onChange={e => setForm({ ...form, codigoPostal: e.target.value.replace(/\D/g, '').slice(0, 5) })} placeholder="06600" maxLength={5} />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Calle</Label>
<Input value={form.calle} onChange={e => setForm({ ...form, calle: e.target.value })} placeholder="Av. Reforma" />
</div>
<div className="space-y-2">
<Label>Num. Exterior</Label>
<Input value={form.numExterior} onChange={e => setForm({ ...form, numExterior: e.target.value })} placeholder="123" />
</div>
<div className="space-y-2">
<Label>Num. Interior</Label>
<Input value={form.numInterior} onChange={e => setForm({ ...form, numInterior: e.target.value })} placeholder="4A" />
</div>
<div className="space-y-2">
<Label>Colonia</Label>
<Input value={form.colonia} onChange={e => setForm({ ...form, colonia: e.target.value })} placeholder="Juárez" />
</div>
<div className="space-y-2">
<Label>Ciudad</Label>
<Input value={form.ciudad} onChange={e => setForm({ ...form, ciudad: e.target.value })} placeholder="Ciudad de México" />
</div>
<div className="space-y-2">
<Label>Municipio</Label>
<Input value={form.municipio} onChange={e => setForm({ ...form, municipio: e.target.value })} placeholder="Cuauhtémoc" />
</div>
<div className="space-y-2">
<Label>Estado</Label>
<Input value={form.estado} onChange={e => setForm({ ...form, estado: e.target.value })} placeholder="CDMX" />
</div>
<div className="space-y-2">
<Label>Teléfono / Celular</Label>
<Input value={form.telefono} onChange={e => setForm({ ...form, telefono: e.target.value.replace(/[^\d+\-() ]/g, '').slice(0, 20) })} placeholder="+52 55 1234 5678" />
</div>
</div>
<div className="flex items-center justify-between">
{msg && <p className={`text-sm ${msg.includes('Error') ? 'text-red-600' : 'text-green-600'}`}>{msg}</p>}
{!msg && <div />}
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</CardContent>
</Card>
);
}
function BancosSection() {
const { data: bancos, isLoading } = useBancos();
const createBanco = useCreateBanco();
const deleteBancoMut = useDeleteBanco();
const [nombre, setNombre] = useState('');
const [terminacion, setTerminacion] = useState('');
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!nombre || !terminacion) return;
try {
await createBanco.mutateAsync({ banco: nombre, terminacionCuenta: terminacion });
setNombre('');
setTerminacion('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear banco');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Eliminar este banco?')) return;
try {
await deleteBancoMut.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
Bancos
</CardTitle>
<CardDescription>Cuentas bancarias para conciliacion</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : bancos && bancos.length > 0 ? (
<div className="divide-y">
{bancos.map((b) => (
<div key={b.id} className="flex items-center justify-between py-2">
<div>
<span className="font-medium">{b.banco}</span>
<span className="text-muted-foreground ml-2">****{b.terminacionCuenta}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => handleDelete(b.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No hay bancos registrados</p>
)}
<form onSubmit={handleAdd} className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label htmlFor="banco-nombre">Banco</Label>
<Input id="banco-nombre" value={nombre} onChange={e => setNombre(e.target.value)} placeholder="BBVA" required />
</div>
<div className="w-32 space-y-1">
<Label htmlFor="banco-term">Terminacion</Label>
<Input id="banco-term" value={terminacion} onChange={e => setTerminacion(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="1234" maxLength={4} required />
</div>
<Button type="submit" disabled={createBanco.isPending}>Agregar</Button>
</form>
</CardContent>
</Card>
);
}
export default function ConfiguracionPage() {
const { theme, setTheme } = useThemeStore();
const { user } = useAuthStore();
const { viewingTenantName } = useTenantViewStore();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } = useContribuyenteStore();
const empresaNombre = viewingTenantName || user?.tenantName;
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const isDespacho = isDespachoTenant(user?.tenantRfc);
return (
<>
<Header title="Configuración" />
<main className="p-6 space-y-6">
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="h-4 w-4" />
Información del Usuario
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Nombre</p>
<p className="font-medium">{user?.nombre}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium">{user?.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rol</p>
<p className="font-medium capitalize">{user?.role}</p>
</div>
</div>
</CardContent>
</Card>
{/* Company Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Información de la Empresa
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Empresa</p>
<p className="font-medium">{empresaNombre}</p>
</div>
</div>
</CardContent>
</Card>
{/* Contribuyente header — shown when despacho has one selected */}
{isDespacho && selectedContribuyenteId && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3 px-5 flex items-center gap-2">
<Building className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Configuración de: {selectedContribuyenteNombre}</span>
<span className="text-xs text-muted-foreground font-mono">{selectedContribuyenteRfc}</span>
</CardContent>
</Card>
)}
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
{(user?.role === 'owner' || user?.role === 'cfo') && (
isDespacho && !selectedContribuyenteId ? (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
<p>Selecciona un contribuyente en el header para ver su configuración fiscal.</p>
</CardContent>
</Card>
) : (
<>
<RegimenesActivosSection />
<DomicilioFiscalSection />
<BancosSection />
</>
)
)}
{/* SAT Configuration */}
<Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4" />
Sincronizacion SAT
</CardTitle>
<CardDescription>
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
</p>
</CardContent>
</Card>
</Link>
{/* Obligaciones Fiscales */}
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
<Link href="/configuracion/obligaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Obligaciones Fiscales
</CardTitle>
<CardDescription>
Gestiona las obligaciones fiscales de tus contribuyentes
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
</p>
</CardContent>
</Card>
</Link>
)}
{/* Notificaciones */}
<Link href="/configuracion/notificaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Notificaciones
</CardTitle>
<CardDescription>
Activa o desactiva los correos informativos por contribuyente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
</p>
</CardContent>
</Card>
</Link>
{/* Preferencias de Facturación (auto-emisión de pagos de suscripción) */}
<Link href="/configuracion/facturacion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Preferencias de Facturación
</CardTitle>
<CardDescription>
Define cómo facturamos los pagos de tu suscripción a Horux 360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Elige si tus facturas salen con tus datos fiscales o como Público en General. Configura el uso CFDI (G03 Gastos en general / S01 Sin obligaciones) y el régimen a usar si tienes varios activos.
</p>
</CardContent>
</Card>
</Link>
{/* Seguridad */}
<Link href="/configuracion/seguridad">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Seguridad
</CardTitle>
<CardDescription>
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
</p>
</CardContent>
</Card>
</Link>
{/* CSD / Facturapi */}
<Link href="/configuracion/csd">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
Configura tu CSD para emitir facturas electrónicas desde Horux360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
</p>
</CardContent>
</Card>
</Link>
{/* Admin global: edición de precios */}
{isGlobalAdmin && (
<>
<Link href="/configuracion/precios-suscripcion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Precios de suscripciones
</CardTitle>
<CardDescription>
Modifica los precios de los planes Starter, Business, Business + IA y Enterprise.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Los cambios aplican a suscripciones nuevas y renovaciones. Las vigentes conservan el precio contratado.
</p>
</CardContent>
</Card>
</Link>
<Link href="/configuracion/precios-timbres">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Precios de timbres adicionales
</CardTitle>
<CardDescription>
Modifica los precios de los paquetes de timbres adicionales (100, 1000, 10000).
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Los cambios aplican a compras nuevas. Los paquetes ya vendidos conservan el precio que pagó el cliente.
</p>
</CardContent>
</Card>
</Link>
<Link href="/configuracion/addons">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4" />
Add-ons del catálogo
</CardTitle>
<CardDescription>
Gestiona los complementos disponibles (Lolita IA, RFCs extra, módulo IA, etc.).
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Modifica nombre, precio y disponibilidad de cada add-on. Los add-ons ya contratados
conservan el precio al que se cobraron; los cambios aplican a contrataciones nuevas.
</p>
</CardContent>
</Card>
</Link>
</>
)}
</main>
</>
);
}