Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { apiClient } from '@/lib/api/client';
import { Package, ShieldAlert, Pencil, Loader2, Check as CheckIcon, X as XIcon, AlertTriangle, Power, PowerOff } from 'lucide-react';
interface AddonItem {
id: string;
codename: string;
nombre: string;
verticalProfile: string | null;
precio: number;
frecuencia: string;
active: boolean;
delta: unknown;
createdAt: string;
suscripcionesActivas: number;
}
async function listAddons(): Promise<AddonItem[]> {
const res = await apiClient.get<{ data: AddonItem[] }>('/admin/addons/catalogo');
return res.data.data;
}
async function updateAddon(id: string, data: { nombre?: string; precio?: number; active?: boolean }): Promise<void> {
await apiClient.put(`/admin/addons/catalogo/${id}`, data);
}
export default function AddonsPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const queryClient = useQueryClient();
const { data: addons = [], isLoading } = useQuery({
queryKey: ['admin-addons-catalogo'],
queryFn: listAddons,
enabled: isGlobalAdmin,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { nombre?: string; precio?: number; active?: boolean } }) => updateAddon(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-addons-catalogo'] }),
});
const [editing, setEditing] = useState<{ id: string; nombre: string; precio: string } | null>(null);
if (!isGlobalAdmin) {
return (
<>
<Header title="Add-ons del catálogo" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">Solo admin global puede modificar el catálogo de add-ons.</p>
</CardContent>
</Card>
</main>
</>
);
}
const startEdit = (item: AddonItem) => {
setEditing({ id: item.id, nombre: item.nombre, precio: String(item.precio) });
};
const cancelEdit = () => setEditing(null);
const saveEdit = async () => {
if (!editing) return;
const precio = Number(editing.precio);
if (!Number.isFinite(precio) || precio < 0) {
alert('El precio debe ser un número no negativo');
return;
}
if (!editing.nombre.trim()) {
alert('El nombre no puede estar vacío');
return;
}
try {
await updateMutation.mutateAsync({
id: editing.id,
data: { nombre: editing.nombre.trim(), precio },
});
setEditing(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al guardar');
}
};
const toggleActive = async (item: AddonItem) => {
if (item.active && item.suscripcionesActivas > 0) {
const confirmar = confirm(
`Hay ${item.suscripcionesActivas} suscripción(es) activa(s) usando este add-on. ` +
`Desactivarlo evitará nuevas contrataciones, pero las existentes siguen vigentes hasta su cancelación. ¿Continuar?`,
);
if (!confirmar) return;
}
try {
await updateMutation.mutateAsync({ id: item.id, data: { active: !item.active } });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar estado');
}
};
return (
<>
<Header title="Add-ons del catálogo" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p>
Los cambios de precio aplican <strong>a contrataciones nuevas</strong>. Las
suscripciones de add-on vigentes conservan el precio al que se cobraron.
</p>
<p className="text-muted-foreground mt-1">
Desactivar un add-on lo oculta del catálogo público, pero las suscripciones
activas siguen funcionando hasta su cancelación.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-5 w-5" />
Catálogo de add-ons
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-sm text-muted-foreground py-4">Cargando catálogo...</div>
) : addons.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">Sin add-ons configurados.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Codename</th>
<th className="py-2 pr-4">Nombre</th>
<th className="py-2 pr-4 text-right">Precio (MXN)</th>
<th className="py-2 pr-4">Frecuencia</th>
<th className="py-2 pr-4 text-right">Suscripciones activas</th>
<th className="py-2 pr-4">Estado</th>
<th className="py-2 pr-4"></th>
</tr>
</thead>
<tbody>
{addons.map(item => {
const isEditing = editing?.id === item.id;
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground">{item.codename}</td>
<td className="py-3 pr-4">
{isEditing ? (
<Input
value={editing.nombre}
onChange={(e) => setEditing({ ...editing, nombre: e.target.value })}
className="h-8"
autoFocus
/>
) : (
<span className="font-medium">{item.nombre}</span>
)}
</td>
<td className="py-3 pr-4 text-right">
{isEditing ? (
<Input
type="number"
step="1"
min="0"
value={editing.precio}
onChange={(e) => setEditing({ ...editing, precio: e.target.value })}
className="h-8 w-32 text-right"
/>
) : (
<span className="font-medium">${item.precio.toLocaleString('es-MX')}</span>
)}
</td>
<td className="py-3 pr-4 text-muted-foreground">{item.frecuencia}</td>
<td className="py-3 pr-4 text-right">
<span className={item.suscripcionesActivas > 0 ? 'font-medium' : 'text-muted-foreground'}>
{item.suscripcionesActivas}
</span>
</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
item.active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
: 'bg-muted text-muted-foreground'
}`}>
{item.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="py-3 pr-4">
<div className="flex items-center justify-end gap-1">
{isEditing ? (
<>
<Button
size="icon"
variant="ghost"
onClick={saveEdit}
disabled={updateMutation.isPending}
title="Guardar"
>
{updateMutation.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <CheckIcon className="h-4 w-4 text-green-600" />}
</Button>
<Button size="icon" variant="ghost" onClick={cancelEdit} title="Cancelar">
<XIcon className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
size="icon"
variant="ghost"
onClick={() => startEdit(item)}
title="Editar nombre y precio"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => toggleActive(item)}
disabled={updateMutation.isPending}
title={item.active ? 'Desactivar' : 'Activar'}
>
{item.active
? <PowerOff className="h-3.5 w-3.5 text-red-600" />
: <Power className="h-3.5 w-3.5 text-green-600" />}
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useTimbres } from '@/lib/hooks/use-facturacion';
import { apiClient } from '@/lib/api/client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image } from 'lucide-react';
function CustomizationSection() {
const queryClient = useQueryClient();
const [logoUploading, setLogoUploading] = useState(false);
const [colorSaving, setColorSaving] = useState(false);
const [color, setColor] = useState('#75A4FF');
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const { data: customization } = useQuery({
queryKey: ['facturapi-customization'],
queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data),
});
useState(() => {
if (customization?.color) setColor(`#${customization.color}`);
});
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validar tipo y tamaño
if (!file.type.startsWith('image/')) {
setMsg({ type: 'error', text: 'Solo se permiten imágenes (PNG, JPG)' });
return;
}
if (file.size > 2 * 1024 * 1024) {
setMsg({ type: 'error', text: 'El logo no debe superar 2MB' });
return;
}
setLogoUploading(true);
setMsg(null);
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1];
try {
await apiClient.post('/facturacion/logo', { logo: base64 });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
setMsg({ type: 'success', text: 'Logo subido correctamente' });
} catch {
setMsg({ type: 'error', text: 'Error al subir logo' });
} finally {
setLogoUploading(false);
}
};
reader.readAsDataURL(file);
};
const handleColorSave = async () => {
setColorSaving(true);
setMsg(null);
try {
await apiClient.put('/facturacion/color', { color: color.replace('#', '') });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
setMsg({ type: 'success', text: 'Color actualizado' });
} catch {
setMsg({ type: 'error', text: 'Error al actualizar color' });
} finally {
setColorSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Palette className="h-4 w-4" />
Personalización de Factura
</CardTitle>
<CardDescription>
Logo y color que aparecerán en los PDFs de tus facturas
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Logo */}
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Image className="h-4 w-4" />
Logo de la empresa
</Label>
<div className="flex items-center gap-4">
{customization?.logoUrl && (
<img
src={customization.logoUrl}
alt="Logo"
className="h-16 w-16 object-contain rounded-lg border"
/>
)}
<div className="flex-1">
<Input
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={handleLogoUpload}
disabled={logoUploading}
/>
<p className="text-xs text-muted-foreground mt-1">
PNG o JPG, máximo 2MB. Recomendado: fondo transparente, 400x400px
</p>
</div>
</div>
</div>
{/* Color */}
<div className="space-y-3">
<Label>Color principal</Label>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
className="h-10 w-14 rounded cursor-pointer border"
/>
<Input
value={color}
onChange={e => setColor(e.target.value)}
placeholder="#75A4FF"
className="w-32 font-mono"
maxLength={7}
/>
<div className="h-10 flex-1 rounded-lg border" style={{ backgroundColor: color }} />
<Button onClick={handleColorSave} disabled={colorSaving} size="sm">
{colorSaving ? 'Guardando...' : 'Guardar color'}
</Button>
</div>
</div>
{/* Mensaje */}
{msg && (
<div className={`p-2 rounded text-sm ${msg.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'}`}>
{msg.text}
</div>
)}
</CardContent>
</Card>
);
}
export default function CsdConfigPage() {
const { selectedContribuyenteId } = useContribuyenteStore();
const { data: orgStatus, isLoading } = useQuery({
queryKey: ['facturapi-org-contrib', selectedContribuyenteId],
queryFn: () => selectedContribuyenteId
? apiClient.get(`/contribuyentes/${selectedContribuyenteId}/facturapi/status`).then(r => r.data)
: apiClient.get('/facturacion/org/status').then(r => r.data),
});
const { data: timbres } = useTimbres();
const queryClient = useQueryClient();
const [uploading, setUploading] = useState(false);
const [cerFile, setCerFile] = useState<string>('');
const [keyFile, setKeyFile] = useState<string>('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleFileChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
setter(base64);
};
reader.readAsDataURL(file);
};
const handleCreateOrg = async () => {
try {
if (selectedContribuyenteId) {
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
} else {
await apiClient.post('/facturacion/org');
}
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
}
};
const handleUploadCsd = async (e: React.FormEvent) => {
e.preventDefault();
if (!cerFile || !keyFile || !password) {
setMessage({ type: 'error', text: 'Todos los campos son requeridos' });
return;
}
setUploading(true);
setMessage(null);
try {
if (selectedContribuyenteId) {
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/csd`, { cerFile, keyFile, password });
} else {
await apiClient.post('/facturacion/csd', { cerFile, keyFile, password });
}
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
setMessage({ type: 'success', text: 'CSD subido correctamente. Ya puedes emitir facturas.' });
setCerFile('');
setKeyFile('');
setPassword('');
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al subir CSD' });
} finally {
setUploading(false);
}
};
if (isLoading) {
return (
<>
<Header title="Configuración CSD" />
<main className="p-6"><p>Cargando...</p></main>
</>
);
}
return (
<>
<Header title="Configuración CSD" />
<main className="p-6 space-y-6">
{/* Estado de la organización */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-4 w-4" />
Organización Facturapi
</CardTitle>
<CardDescription>
Necesaria para emitir facturas electrónicas (CFDI)
</CardDescription>
</CardHeader>
<CardContent>
{!orgStatus?.configured ? (
<div className="text-center py-4 space-y-3">
<p className="text-sm text-muted-foreground">
No hay organización configurada para este tenant.
</p>
<Button onClick={handleCreateOrg}>Crear Organización</Button>
</div>
) : (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">ID Organización</span>
<span className="font-mono text-xs">{orgStatus.orgId}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CSD</span>
<span className={orgStatus.hasCsd ? 'text-green-600' : 'text-orange-600'}>
{orgStatus.hasCsd ? '✓ Configurado' : '✗ Pendiente'}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Subir CSD */}
{orgStatus?.configured && !orgStatus.hasCsd && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Upload className="h-4 w-4" />
Subir Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
El CSD es diferente a la FIEL. Se usa exclusivamente para timbrar facturas.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUploadCsd} className="space-y-4">
<div className="space-y-2">
<Label>Certificado (.cer)</Label>
<Input type="file" accept=".cer" onChange={handleFileChange(setCerFile)} required />
</div>
<div className="space-y-2">
<Label>Llave privada (.key)</Label>
<Input type="file" accept=".key" onChange={handleFileChange(setKeyFile)} required />
</div>
<div className="space-y-2">
<Label>Contraseña de la llave</Label>
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<Button type="submit" disabled={uploading} className="w-full">
{uploading ? 'Subiendo...' : 'Subir CSD'}
</Button>
</form>
</CardContent>
</Card>
)}
{/* CSD ya configurado */}
{orgStatus?.configured && orgStatus.hasCsd && (
<Card>
<CardContent className="pt-6 text-center space-y-2">
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mx-auto">
<Check className="h-6 w-6 text-green-600" />
</div>
<p className="font-medium">CSD Configurado</p>
<p className="text-sm text-muted-foreground">
Tu Certificado de Sello Digital está activo. Puedes emitir facturas.
</p>
</CardContent>
</Card>
)}
{/* Personalización de factura */}
{orgStatus?.configured && <CustomizationSection />}
{/* Timbres */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Timbres
</CardTitle>
</CardHeader>
<CardContent>
{timbres?.configured ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="capitalize">{timbres.tipo}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Usados</span>
<span>{timbres.usados} / {timbres.limite}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Disponibles</span>
<span className="font-bold">{timbres.disponibles}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Vence</span>
<span>{timbres.periodoFin}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mt-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(100, ((timbres.usados ?? 0) / (timbres.limite ?? 1)) * 100)}%` }}
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No hay suscripción de timbres configurada. Contacta al dueño de la cuenta.
</p>
)}
</CardContent>
</Card>
{/* Mensajes */}
{message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${
message.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200'
: 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'
}`}>
{message.type === 'success' ? <Check className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
{message.text}
</div>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Label } from '@horux/shared-ui';
import { Receipt, Save, AlertCircle } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { getUsosCfdi } from '@/lib/api/catalogos';
interface PreferenciasFacturacion {
factPreferencia: 'publico_general' | 'mis_datos';
factUsoCfdi: string;
factRegimenPreferido: string | null;
regimenesActivos: { clave: string; descripcion: string }[];
}
export default function PreferenciasFacturacionPage() {
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const { data: prefs, isLoading } = useQuery<PreferenciasFacturacion>({
queryKey: ['preferencias-facturacion'],
queryFn: () => apiClient.get<PreferenciasFacturacion>('/facturacion/preferencias-facturacion').then(r => r.data),
});
const { data: usosCfdiAll = [] } = useQuery({
queryKey: ['usos-cfdi'],
queryFn: getUsosCfdi,
});
// Solo se permiten 2 usos para auto-facturación de pagos del SaaS:
// G03 (Gastos en general) — el más común para servicios deducibles.
// S01 (Sin efectos fiscales) — para clientes que no requieren deducir.
const ALLOWED_USOS = ['G03', 'S01'];
const usosCfdi = usosCfdiAll.filter(u => ALLOWED_USOS.includes(u.clave));
const [form, setForm] = useState<PreferenciasFacturacion>({
factPreferencia: 'mis_datos',
factUsoCfdi: 'G03',
factRegimenPreferido: null,
regimenesActivos: [],
});
useEffect(() => {
if (prefs) setForm(prefs);
}, [prefs]);
const onSave = async () => {
setSaving(true);
setMessage(null);
try {
await apiClient.put('/facturacion/preferencias-facturacion', {
factPreferencia: form.factPreferencia,
factUsoCfdi: form.factUsoCfdi,
factRegimenPreferido: form.factRegimenPreferido,
});
queryClient.invalidateQueries({ queryKey: ['preferencias-facturacion'] });
setMessage({ kind: 'ok', text: 'Preferencias guardadas. Aplicará a futuros pagos auto-facturados.' });
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al guardar' });
} finally {
setSaving(false);
}
};
const isPubGen = form.factPreferencia === 'publico_general';
const tieneRegimenes = form.regimenesActivos.length > 0;
const advertirSinRegimenes = form.factPreferencia === 'mis_datos' && !tieneRegimenes;
return (
<>
<Header title="Preferencias de Facturación" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Auto-facturación de pagos de suscripción
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-6">
Cuando MercadoPago confirma un pago de tu suscripción, Horux 360 emite automáticamente
un CFDI. Aquí defines a qué nombre y con qué uso CFDI.
</p>
{isLoading ? (
<div className="text-muted-foreground text-sm">Cargando</div>
) : (
<div className="space-y-6">
{/* Toggle preferencia */}
<div className="space-y-2">
<Label>Receptor de la factura</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setForm(f => ({ ...f, factPreferencia: 'mis_datos' }))}
className={`text-left rounded-lg border-2 p-4 transition-colors ${
form.factPreferencia === 'mis_datos'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="font-medium">Mis datos fiscales</div>
<p className="text-xs text-muted-foreground mt-1">
Usa el RFC, razón social, CP y régimen registrados en tu CSF.
Recomendado para que los pagos sean deducibles para tu empresa.
</p>
</button>
<button
type="button"
onClick={() => setForm(f => ({ ...f, factPreferencia: 'publico_general' }))}
className={`text-left rounded-lg border-2 p-4 transition-colors ${
form.factPreferencia === 'publico_general'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="font-medium">Público en general</div>
<p className="text-xs text-muted-foreground mt-1">
Factura genérica (XAXX010101000) sin datos fiscales del receptor.
No deducible para tu empresa.
</p>
</button>
</div>
</div>
{/* Advertencia si sin CSF y eligió "mis datos" */}
{advertirSinRegimenes && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3">
<AlertCircle className="h-4 w-4 text-amber-700 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800">
No tienes regímenes fiscales registrados. Sube tu FIEL en
<strong> /configuracion</strong> para que el sistema descargue tu CSF y
sincronice automáticamente. Mientras tanto, las facturas saldrán a
Público en General aunque la preferencia esté en "Mis datos".
</div>
</div>
)}
{/* Uso CFDI (solo si "mis datos") */}
{!isPubGen && (
<div className="space-y-2">
<Label>Uso CFDI</Label>
<select
value={form.factUsoCfdi}
onChange={e => setForm(f => ({ ...f, factUsoCfdi: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{usosCfdi.map(u => (
<option key={u.clave} value={u.clave}>
{u.clave} {u.descripcion}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Por defecto G03 (Gastos en general). Tu contador puede recomendarte otro.
</p>
</div>
)}
{/* Régimen preferido (solo si "mis datos" Y tiene varios) */}
{!isPubGen && form.regimenesActivos.length > 1 && (
<div className="space-y-2">
<Label>Régimen fiscal a usar</Label>
<select
value={form.factRegimenPreferido || ''}
onChange={e => setForm(f => ({
...f,
factRegimenPreferido: e.target.value || null,
}))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Automático (primer régimen activo)</option>
{form.regimenesActivos.map(r => (
<option key={r.clave} value={r.clave}>
{r.clave} {r.descripcion}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Tienes varios regímenes activos. Elige cuál usar al facturar tus pagos.
</p>
</div>
)}
{!isPubGen && form.regimenesActivos.length === 1 && (
<div className="text-sm text-muted-foreground">
Régimen fiscal:{' '}
<strong>
{form.regimenesActivos[0].clave} {form.regimenesActivos[0].descripcion}
</strong>
</div>
)}
{/* Mensaje + botón */}
{message && (
<div className={`rounded-lg px-3 py-2 text-sm ${
message.kind === 'ok'
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-red-50 border border-red-200 text-red-800'
}`}>
{message.text}
</div>
)}
<div className="flex justify-end">
<Button onClick={onSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Guardando…' : 'Guardar preferencias'}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Bell, Loader2 } from 'lucide-react';
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
documento_subido: {
label: 'Documento subido',
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
status: 'active',
},
weekly_update: {
label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'pending',
},
subscription_expiring: {
label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'pending',
},
recordatorio_fiscal: {
label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending',
},
};
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse {
emailTypes: string[];
data: ContribuyentePrefs[];
}
export default function NotificacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'],
queryFn: async () => {
const res = await apiClient.get<ListResponse>('/notificaciones');
return res.data;
},
});
// Aplica el filtro del selector global de contribuyente. Si hay uno
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
const visibles = useMemo(() => {
if (!data) return [];
if (!selectedContribuyenteId) return data.data;
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
}, [data, selectedContribuyenteId]);
const mutation = useMutation({
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', {
contribuyenteId,
preferences: { [emailType]: enabled },
});
},
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
if (previous) {
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
...previous,
data: previous.data.map(c =>
c.contribuyenteId === contribuyenteId
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
: c,
),
});
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['notification-preferences'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
},
});
return (
<>
<Header title="Notificaciones" />
<main className="p-6 space-y-6 max-w-5xl mx-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Correos informativos por contribuyente
</CardTitle>
<CardDescription>
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
</CardDescription>
</CardHeader>
</Card>
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : visibles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{selectedContribuyenteId
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
: 'No hay contribuyentes en este despacho.'}
</CardContent>
</Card>
) : (
visibles.map(contrib => (
<Card key={contrib.contribuyenteId}>
<CardHeader>
<CardTitle className="text-sm font-medium">
{contrib.nombre}
</CardTitle>
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.emailTypes ?? []).map(type => {
const meta = EMAIL_LABELS[type];
if (!meta) return null;
const checked = contrib.preferences[type] !== false;
const isPending = meta.status === 'pending';
return (
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{meta.label}</span>
{isPending && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
Próximamente
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={e =>
mutation.mutate({
contribuyenteId: contrib.contribuyenteId,
emailType: type,
enabled: e.target.checked,
})
}
/>
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</main>
</>
);
}

View File

@@ -0,0 +1,499 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
cn,
} from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { apiClient } from '@/lib/api/client';
import { Header } from '@/components/layouts/header';
import { TareasTab } from '@/components/obligaciones/tareas-tab';
import { Plus, Trash2, RotateCcw, Sparkles, ChevronDown, Building2 } from 'lucide-react';
interface Obligacion {
id: string;
catalogoId: string | null;
nombre: string;
fundamento: string | null;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
esCustom: boolean;
}
interface CatalogoItem {
id: string;
nombre: string;
fundamento: string;
frecuencia: string;
fechaLimite: string;
categoria: string;
aplica: string;
}
export default function ObligacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } =
useContribuyenteStore();
const [obligaciones, setObligaciones] = useState<Obligacion[]>([]);
const [catalogo, setCatalogo] = useState<CatalogoItem[]>([]);
const [loading, setLoading] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [showRemoved, setShowRemoved] = useState(false);
const [addMode, setAddMode] = useState<'catalogo' | 'custom'>('catalogo');
const [customForm, setCustomForm] = useState({
nombre: '',
fundamento: '',
frecuencia: '',
fechaLimite: '',
categoria: '',
});
const [selectedCatalogoId, setSelectedCatalogoId] = useState('');
const [activeTab, setActiveTab] = useState<'obligaciones' | 'tareas'>('obligaciones');
const fetchObligaciones = useCallback(async () => {
if (!selectedContribuyenteId) return;
setLoading(true);
try {
const { data } = await apiClient.get(
`/contribuyentes/${selectedContribuyenteId}/obligaciones`
);
setObligaciones(data.data);
} catch {
setObligaciones([]);
} finally {
setLoading(false);
}
}, [selectedContribuyenteId]);
useEffect(() => {
fetchObligaciones();
}, [fetchObligaciones]);
useEffect(() => {
apiClient
.get('/contribuyentes/catalogo-obligaciones')
.then(({ data }) => setCatalogo(data.data))
.catch(() => {});
}, []);
const handleInit = async () => {
if (!selectedContribuyenteId || !selectedContribuyenteRfc) return;
try {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/init`,
{
rfc: selectedContribuyenteRfc,
regimenes: [],
tieneNomina: false,
}
);
await fetchObligaciones();
invalidateRelated();
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'Error al generar recomendaciones');
}
};
const handleAdd = async () => {
if (!selectedContribuyenteId) return;
try {
if (addMode === 'catalogo' && selectedCatalogoId) {
const item = catalogo.find((c) => c.id === selectedCatalogoId);
if (!item) return;
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/obligaciones`, {
catalogoId: item.id,
nombre: item.nombre,
fundamento: item.fundamento,
frecuencia: item.frecuencia,
fechaLimite: item.fechaLimite,
categoria: item.categoria,
});
} else if (addMode === 'custom' && customForm.nombre) {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones`,
customForm
);
}
setShowAdd(false);
setSelectedCatalogoId('');
setCustomForm({ nombre: '', fundamento: '', frecuencia: '', fechaLimite: '', categoria: '' });
await fetchObligaciones();
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'Error al agregar obligación');
}
};
const invalidateRelated = () => {
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['eventos'] });
};
const handleRemove = async (id: string) => {
await apiClient.delete(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}`
);
await fetchObligaciones();
invalidateRelated();
};
const handleRestore = async (id: string) => {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}/restore`
);
await fetchObligaciones();
invalidateRelated();
};
if (!selectedContribuyenteId) {
return (
<div className="p-6 max-w-4xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Selecciona un contribuyente</h3>
<p className="text-sm text-muted-foreground mt-1">
Usa el selector de RFCs en el header para elegir un contribuyente.
</p>
</CardContent>
</Card>
</div>
);
}
const activas = obligaciones.filter((o) => o.activa);
const removidas = obligaciones.filter((o) => !o.activa);
const categorias = [...new Set(activas.map((o) => o.categoria || 'Sin categoría'))];
const frecuenciaBadge = (f: string | null) => {
const colors: Record<string, string> = {
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
return f ? (
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || colors['eventual'])}>
{f}
</span>
) : null;
};
return (
<>
<Header title="Obligaciones Fiscales" />
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Subtítulo */}
<p className="text-sm text-muted-foreground">
{selectedContribuyenteNombre} {selectedContribuyenteRfc}
</p>
{/* Tabs */}
<div className="flex border-b">
<button
onClick={() => setActiveTab('obligaciones')}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'obligaciones'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
Obligaciones
</button>
<button
onClick={() => setActiveTab('tareas')}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'tareas'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
Tareas
</button>
</div>
{activeTab === 'tareas' ? (
<TareasTab contribuyenteId={selectedContribuyenteId ?? null} />
) : (
<>
<div className="flex items-center justify-end gap-2">
{activas.length === 0 && (
<Button
onClick={handleInit}
variant="outline"
className="flex items-center gap-2"
>
<Sparkles className="h-4 w-4" /> Generar recomendaciones
</Button>
)}
<Button onClick={() => setShowAdd(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Agregar
</Button>
</div>
{/* Active obligations */}
{loading ? (
<p className="text-muted-foreground">Cargando...</p>
) : activas.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin obligaciones configuradas</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Importa las obligaciones desde la Constancia de Situación Fiscal (CSF) o agrega manualmente.
</p>
<Button onClick={handleInit} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" /> Generar recomendaciones
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{categorias.map((cat) => (
<div key={cat}>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{cat}
</h2>
<div className="space-y-2">
{activas
.filter((o) => (o.categoria || 'Sin categoría') === cat)
.map((ob) => (
<Card key={ob.id}>
<CardContent className="flex items-center justify-between py-3 px-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm">{ob.nombre}</p>
{frecuenciaBadge(ob.frecuencia)}
{ob.esRecomendada && (
<span className="text-xs text-amber-600 dark:text-amber-400">
Recomendada
</span>
)}
{ob.esCustom && (
<span className="text-xs text-purple-600 dark:text-purple-400">
Custom
</span>
)}
</div>
<div className="flex gap-4 mt-1">
{ob.fundamento && (
<p className="text-xs text-muted-foreground">{ob.fundamento}</p>
)}
{ob.fechaLimite && (
<p className="text-xs text-muted-foreground">
📅 {ob.fechaLimite}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(ob.id)}
className="text-destructive hover:text-destructive ml-2 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)}
{/* Removed obligations */}
{removidas.length > 0 && (
<div>
<button
onClick={() => setShowRemoved(!showRemoved)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', showRemoved && 'rotate-180')}
/>
{removidas.length} obligaciones desactivadas
</button>
{showRemoved && (
<div className="mt-2 space-y-2">
{removidas.map((ob) => (
<Card key={ob.id} className="opacity-50">
<CardContent className="flex items-center justify-between py-3 px-5">
<div>
<p className="font-medium text-sm line-through">{ob.nombre}</p>
<p className="text-xs text-muted-foreground">{ob.categoria}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(ob.id)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{/* Add dialog */}
<Dialog open={showAdd} onOpenChange={setShowAdd}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar obligación fiscal</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={addMode === 'catalogo' ? 'default' : 'outline'}
size="sm"
onClick={() => setAddMode('catalogo')}
>
Del catálogo
</Button>
<Button
variant={addMode === 'custom' ? 'default' : 'outline'}
size="sm"
onClick={() => setAddMode('custom')}
>
Personalizada
</Button>
</div>
{addMode === 'catalogo' ? (
<div className="max-h-64 overflow-y-auto space-y-1 border rounded-md p-1">
{catalogo.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Cargando catálogo...
</p>
) : (
catalogo.map((item) => {
const yaAgregada = obligaciones.some(
(o) => o.catalogoId === item.id && o.activa
);
return (
<button
key={item.id}
disabled={yaAgregada}
onClick={() => setSelectedCatalogoId(item.id)}
className={cn(
'w-full text-left p-3 rounded-md text-sm transition-colors',
yaAgregada
? 'opacity-30 cursor-not-allowed'
: selectedCatalogoId === item.id
? 'bg-primary/10 border border-primary'
: 'hover:bg-accent'
)}
>
<p className="font-medium">{item.nombre}</p>
<p className="text-xs text-muted-foreground">
{item.categoria} · {item.frecuencia} · {item.fechaLimite}
</p>
</button>
);
})
)}
</div>
) : (
<div className="space-y-3">
<div>
<Label>Nombre *</Label>
<Input
value={customForm.nombre}
onChange={(e) =>
setCustomForm((p) => ({ ...p, nombre: e.target.value }))
}
placeholder="Nombre de la obligación"
/>
</div>
<div>
<Label>Fundamento legal</Label>
<Input
value={customForm.fundamento}
onChange={(e) =>
setCustomForm((p) => ({ ...p, fundamento: e.target.value }))
}
placeholder="Art. X LISR"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Frecuencia</Label>
<Input
value={customForm.frecuencia}
onChange={(e) =>
setCustomForm((p) => ({ ...p, frecuencia: e.target.value }))
}
placeholder="mensual, anual..."
/>
</div>
<div>
<Label>Fecha límite</Label>
<Input
value={customForm.fechaLimite}
onChange={(e) =>
setCustomForm((p) => ({ ...p, fechaLimite: e.target.value }))
}
placeholder="Día 17 del mes..."
/>
</div>
</div>
<div>
<Label>Categoría</Label>
<Input
value={customForm.categoria}
onChange={(e) =>
setCustomForm((p) => ({ ...p, categoria: e.target.value }))
}
placeholder="Federal mensual, Anual..."
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAdd(false)}>
Cancelar
</Button>
<Button
onClick={handleAdd}
disabled={
addMode === 'catalogo' ? !selectedCatalogoId : !customForm.nombre
}
>
Agregar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,647 @@
'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 => catalogo.find(c => 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') && (
<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>
</>
);
}

View File

@@ -0,0 +1,483 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
import { useAuthStore } from '@/stores/auth-store';
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
interface SubscriptionInfo {
status: string;
plan: string;
amount: number;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
}
interface PlanInfo {
plan: Despachoplan;
dbMode: string;
trialEndsAt: string | null;
isTrialActive: boolean;
subscription: SubscriptionInfo | null;
}
function daysUntil(isoDate: string): number {
const diff = new Date(isoDate).getTime() - Date.now();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
type Frequency = 'monthly' | 'annual';
export default function PlanesDespachoPage() {
const { user } = useAuthStore();
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now'>(null);
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
// Control y Enterprise siempre se cobran anual. Default monthly para
// bajar friction inicial; el descuento del 17% al pagar anual se
// muestra como CTA secundario.
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const fetchPlan = () => {
apiClient.get<PlanInfo>('/despachos/me/plan')
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null));
};
useEffect(() => {
setLoading(true);
apiClient.get<PlanInfo>('/despachos/me/plan')
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null))
.finally(() => setLoading(false));
}, []);
const currentPlan = planInfo?.plan ?? null;
const trialDaysLeft = planInfo?.trialEndsAt ? daysUntil(planInfo.trialEndsAt) : 0;
const hasPaidPlan = currentPlan === 'business_control' || currentPlan === 'business_cloud' || currentPlan === 'mi_empresa' || currentPlan === 'mi_empresa_plus';
const isCustomPlan = currentPlan === 'custom';
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
const subStatus = planInfo?.subscription?.status ?? null;
const hasActiveSub = subStatus != null
&& subStatus !== 'cancelled'
&& subStatus !== 'trial_expired';
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
* propio toggle; el resto (business_*) siempre annual. */
function frequencyFor(plan: PaidPlan): Frequency {
if (plan === 'mi_empresa') return meFreq;
if (plan === 'mi_empresa_plus') return mePlusFreq;
return 'annual';
}
async function handleContratar(plan: PaidPlan) {
const frequency = frequencyFor(plan);
setBusy(plan);
setMessage(null);
try {
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
const result = await subscribeMe({ plan, frequency });
window.open(result.paymentUrl, '_blank');
setMessage({ kind: 'ok', text: 'Abrimos el pago de MercadoPago en otra pestaña. Al completar regresa aquí.' });
} catch (err: any) {
const msg: string = err?.response?.data?.message || err?.message || '';
if (!/Ya existe una suscripci/i.test(msg)) {
setMessage({ kind: 'err', text: msg || 'Error al contratar el plan' });
setBusy(null);
return;
}
// Hay sub activa en otro plan. Intentamos upgrade (prorrateado, MP) primero
// — si el backend determina que es downgrade o misma frecuencia más barata,
// rechaza y caemos a cambio programado para fin de período.
try {
const upgradeResult = await upgradeMe(plan);
window.open(upgradeResult.checkoutUrl, '_blank');
const monto = Number(upgradeResult.proratedAmount).toLocaleString('es-MX', { minimumFractionDigits: 2 });
setMessage({ kind: 'ok', text: `Upgrade a ${plan} — abrimos el cobro prorrateado de $${monto} en MercadoPago.` });
} catch (upErr: any) {
try {
await changeMyPlan({ plan, frequency });
setMessage({ kind: 'ok', text: 'Cambio de plan programado para el final del período actual (sin cobro inmediato).' });
fetchPlan();
} catch (changeErr: any) {
setMessage({ kind: 'err', text: changeErr?.response?.data?.message || changeErr?.message || 'Error al cambiar el plan' });
}
}
} finally {
setBusy(null);
}
}
/**
* Genera un link de pago one-off en MercadoPago para el monto vigente de la
* suscripción actual. Útil cuando: (a) el usuario quiere pagar el período
* actual antes de que venza, (b) la sub está en `pending` y nunca se ejecutó
* el primer cobro, (c) custom plans con monto manual.
*/
async function handlePagarAhora() {
if (!user?.tenantId) return;
setBusy('pay-now');
setMessage(null);
try {
const result = await generatePaymentLink(user.tenantId);
window.open(result.paymentUrl, '_blank');
setMessage({ kind: 'ok', text: 'Abrimos el link de pago de MercadoPago en otra pestaña. Al completar regresa aquí.' });
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al generar el link de pago' });
} finally {
setBusy(null);
}
}
async function handleCancelar() {
if (!confirm('Seguro que quieres cancelar la suscripcion? Conservaras acceso hasta el final del periodo pagado.')) return;
setBusy('cancel');
setMessage(null);
try {
await cancelMySubscription();
setMessage({ kind: 'ok', text: 'Suscripcion cancelada. Acceso activo hasta el final del periodo actual.' });
fetchPlan();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al cancelar' });
} finally {
setBusy(null);
}
}
function ActiveBadge() {
return (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
Plan actual
</div>
);
}
/**
* Toggle binario Mensual/Anual. La opción anual va resaltada con un
* pequeño badge "17%" para enfocar el descuento.
*/
function FrequencyToggle({ value, onChange }: { value: Frequency; onChange: (v: Frequency) => void }) {
return (
<div className="flex bg-muted rounded-lg p-1 text-xs font-medium">
<button
type="button"
onClick={() => onChange('monthly')}
className={`flex-1 py-1.5 rounded-md transition-colors ${value === 'monthly' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Mensual
</button>
<button
type="button"
onClick={() => onChange('annual')}
className={`flex-1 py-1.5 rounded-md transition-colors flex items-center justify-center gap-1 ${value === 'annual' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Anual <span className="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">17%</span>
</button>
</div>
);
}
function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan;
if (isCurrent) {
return <Button disabled className="w-full">Plan actual</Button>;
}
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
{label}
</>
)}
</Button>
);
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Planes Horux Despachos</h1>
<p className="text-muted-foreground">Tres planes: Mi Empresa para usuarios individuales, Business Control y Enterprise para despachos.</p>
</div>
{/* Banner Custom — plan asignado por admin, sin cobro */}
{!loading && isCustomPlan && (
<div className="flex items-start gap-3 bg-pink-50 dark:bg-pink-950 border border-pink-200 dark:border-pink-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-pink-600 dark:text-pink-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-pink-800 dark:text-pink-300">
Plan Custom sin cobro, vigencia indefinida
</div>
<div className="text-pink-700 dark:text-pink-400">
Tu cuenta está bajo un plan especial asignado por tu administrador.
Contacta a soporte si necesitas cambiar de plan.
</div>
</div>
</div>
)}
{/* Trial banner */}
{!loading && planInfo?.isTrialActive && (
<div className="flex items-center gap-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm">
<span className="font-semibold text-amber-800 dark:text-amber-300">Periodo de prueba activo</span>
<span className="text-amber-700 dark:text-amber-400"> {trialDaysLeft} {trialDaysLeft === 1 ? 'dia restante' : 'dias restantes'}</span>
</div>
</div>
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
const sub = planInfo.subscription;
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFormato = periodEndDate
? periodEndDate.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null;
const montoFmt = sub.amount.toLocaleString('es-MX');
return (
<div className="flex items-start gap-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-green-800 dark:text-green-300">
Suscripcion activa {
sub.plan === 'business_control' ? 'Business Control'
: sub.plan === 'business_cloud' ? 'Enterprise'
: sub.plan === 'mi_empresa_plus' ? 'Mi Empresa +'
: 'Mi Empresa'
}
</div>
<div className="text-green-700 dark:text-green-400">
Proxima renovacion{fechaFormato ? ` el ${fechaFormato}` : ''}: <strong>${montoFmt}/año</strong>
</div>
</div>
</div>
);
})()}
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
del cobro automático o cuando no hay preapproval recurrente activo. */}
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
const sub = planInfo.subscription!;
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFmt = periodEnd
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null;
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="font-semibold text-blue-900 dark:text-blue-200">
Pagar mi período actual ${montoFmt}
</div>
<div className="text-blue-700 dark:text-blue-400">
{dias != null && dias > 0
? `Tu período termina ${dias === 1 ? 'mañana' : `en ${dias} días`}${fechaFmt ? ` (${fechaFmt})` : ''}.`
: fechaFmt
? `Tu período terminó el ${fechaFmt}.`
: 'Renueva tu suscripción.'}
</div>
</div>
<Button
onClick={handlePagarAhora}
disabled={busy === 'pay-now'}
className="w-full sm:w-auto"
>
{busy === 'pay-now' ? 'Generando link…' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar ahora
</>
)}
</Button>
</div>
);
})()}
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl mx-auto rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{/* Mi Empresa */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa' && <ActiveBadge />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
</div>
<CardTitle className="text-xl">Mi Empresa</CardTitle>
<p className="text-sm text-muted-foreground">Para una sola empresa</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={meFreq} onChange={setMeFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${meFreq === 'monthly' ? '580' : '5,800'}</div>
<p className="text-sm text-muted-foreground">{meFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{meFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa" /></div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<CardTitle className="text-xl">Mi Empresa +</CardTitle>
<p className="text-sm text-muted-foreground">Mi Empresa con API y Lolita IA</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={mePlusFreq} onChange={setMePlusFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${mePlusFreq === 'monthly' ? '900' : '9,000'}</div>
<p className="text-sm text-muted-foreground">{mePlusFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{mePlusFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>API REST</strong> incluida</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>Lolita IA</strong> agente fiscal</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa_plus" /></div>
</CardContent>
</Card>
{/* Business Control */}
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
{currentPlan === 'business_control'
? <ActiveBadge />
: (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
)
}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Server className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-xl">Business Control</CardTitle>
<p className="text-sm text-muted-foreground">Tu servidor, tus datos</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$25,850</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Control total de tus datos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_control" /></div>
</CardContent>
</Card>
{/* Enterprise (key interna: business_cloud) */}
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'business_cloud' && <ActiveBadge />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-xl">Enterprise</CardTitle>
<p className="text-sm text-muted-foreground">Despachos grandes con alto volumen</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$43,000</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 3,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_cloud" /></div>
</CardContent>
</Card>
</div>
{/* Cancelar — visible para cualquier suscripción aún corriendo (paid, trial, custom).
No se muestra si ya está cancelada o expirada. */}
{hasActiveSub && (
<div className="text-center pt-4">
<button
type="button"
onClick={handleCancelar}
disabled={busy === 'cancel'}
className="text-sm text-muted-foreground hover:text-destructive underline underline-offset-4 disabled:opacity-50"
>
{busy === 'cancel' ? 'Cancelando...' : 'Cancelar suscripción'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc, DESPACHO_OVERAGE_PRICE_MENSUAL } from '@horux/shared';
import { Tags, ShieldAlert, Info, AlertTriangle, Check, Loader2, Pencil, X } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
interface DespachoPlanLimits {
plan: string;
nombre: string;
monthly: number | null;
firstYear: number | null;
renewal: number | null;
permiteMonthly: boolean;
maxRfcs: number;
maxUsers: number;
timbresIncluidosMes: number;
dbMode: 'BYO' | 'MANAGED';
permiteServidorBackup: boolean;
permiteSatIncremental: boolean;
}
const PLAN_ORDER = ['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud', 'custom'];
function fmtCurrency(n: number | null): string {
if (n == null) return '—';
return `$${n.toLocaleString('es-MX')}`;
}
async function listDespachoCatalogo(): Promise<DespachoPlanLimits[]> {
const res = await apiClient.get<{ data: DespachoPlanLimits[] }>('/planes/despacho');
return res.data.data;
}
async function updateDespachoCatalogo(plan: string, patch: Partial<DespachoPlanLimits>): Promise<DespachoPlanLimits> {
const res = await apiClient.patch<DespachoPlanLimits>(`/planes/despacho/${plan}`, patch);
return res.data;
}
export default function PreciosSuscripcionPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const [editingPlan, setEditingPlan] = useState<string | null>(null);
const [draft, setDraft] = useState<Partial<DespachoPlanLimits>>({});
const { data: plans = [], isLoading } = useQuery({
queryKey: ['despacho-catalogo'],
queryFn: listDespachoCatalogo,
enabled: isGlobalAdmin,
});
const updateMutation = useMutation({
mutationFn: ({ plan, patch }: { plan: string; patch: Partial<DespachoPlanLimits> }) =>
updateDespachoCatalogo(plan, patch),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['despacho-catalogo'] });
setEditingPlan(null);
setDraft({});
},
});
if (!isGlobalAdmin) {
return (
<>
<Header title="Precios de suscripciones" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">Solo admin global puede consultar el catálogo de precios.</p>
</CardContent>
</Card>
</main>
</>
);
}
const startEdit = (p: DespachoPlanLimits) => {
setEditingPlan(p.plan);
setDraft({
nombre: p.nombre,
monthly: p.monthly,
firstYear: p.firstYear,
renewal: p.renewal,
permiteMonthly: p.permiteMonthly,
maxRfcs: p.maxRfcs,
maxUsers: p.maxUsers,
timbresIncluidosMes: p.timbresIncluidosMes,
dbMode: p.dbMode,
permiteServidorBackup: p.permiteServidorBackup,
permiteSatIncremental: p.permiteSatIncremental,
});
};
const cancelEdit = () => {
setEditingPlan(null);
setDraft({});
};
const saveEdit = (plan: string) => {
updateMutation.mutate({ plan, patch: draft });
};
const sorted = [...plans].sort((a, b) => PLAN_ORDER.indexOf(a.plan) - PLAN_ORDER.indexOf(b.plan));
return (
<>
<Header title="Precios de suscripciones" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
Los planes despacho se almacenan en la tabla <code className="text-xs bg-muted px-1 py-0.5 rounded">despacho_plan_prices</code>.
Puedes editar precios y limits desde aquí los cambios aplican a contrataciones nuevas y renovaciones; las suscripciones vigentes
conservan su precio. Las <strong>features</strong> de cada plan siguen versionadas en código.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-5 w-5" />
Planes despacho
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
Cargando catálogo
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Plan</th>
<th className="py-2 pr-4 text-right">Mensual</th>
<th className="py-2 pr-4 text-right">Anual 1°</th>
<th className="py-2 pr-4 text-right">Renovación</th>
<th className="py-2 pr-4 text-right">RFCs</th>
<th className="py-2 pr-4 text-right">Usuarios</th>
<th className="py-2 pr-4 text-right">Timbres</th>
<th className="py-2 pr-4">DB</th>
<th className="py-2 pr-4">Backup</th>
<th className="py-2 pr-4">SAT Inc</th>
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody>
{sorted.map((p) => {
const editing = editingPlan === p.plan;
return (
<tr key={p.plan} className="border-b last:border-0 hover:bg-muted/40 align-middle">
<td className="py-2 pr-4 font-medium">
{editing ? (
<input className="w-32 px-2 py-1 border rounded text-sm bg-background"
value={draft.nombre ?? ''}
onChange={(e) => setDraft({ ...draft, nombre: e.target.value })} />
) : (
<>{p.nombre} <span className="text-xs text-muted-foreground font-normal">({p.plan})</span></>
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-20 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.monthly ?? ''}
onChange={(e) => setDraft({ ...draft, monthly: e.target.value === '' ? null : Number(e.target.value) })} />
) : (
fmtCurrency(p.monthly)
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-24 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.firstYear ?? ''}
onChange={(e) => setDraft({ ...draft, firstYear: e.target.value === '' ? null : Number(e.target.value) })} />
) : (
fmtCurrency(p.firstYear)
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-24 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.renewal ?? ''}
onChange={(e) => setDraft({ ...draft, renewal: e.target.value === '' ? null : Number(e.target.value) })} />
) : (
fmtCurrency(p.renewal)
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.maxRfcs ?? 0}
onChange={(e) => setDraft({ ...draft, maxRfcs: Number(e.target.value) })} />
) : (
p.maxRfcs
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.maxUsers ?? 0}
onChange={(e) => setDraft({ ...draft, maxUsers: Number(e.target.value) })} />
) : (
p.maxUsers === -1 ? '∞' : p.maxUsers
)}
</td>
<td className="py-2 pr-4 text-right">
{editing ? (
<input type="number" className="w-16 px-2 py-1 border rounded text-sm text-right bg-background"
value={draft.timbresIncluidosMes ?? 0}
onChange={(e) => setDraft({ ...draft, timbresIncluidosMes: Number(e.target.value) })} />
) : (
p.timbresIncluidosMes
)}
</td>
<td className="py-2 pr-4">
{editing ? (
<select className="px-2 py-1 border rounded text-sm bg-background"
value={draft.dbMode ?? 'MANAGED'}
onChange={(e) => setDraft({ ...draft, dbMode: e.target.value as 'BYO' | 'MANAGED' })}>
<option value="MANAGED">MANAGED</option>
<option value="BYO">BYO</option>
</select>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-muted">{p.dbMode}</span>
)}
</td>
<td className="py-2 pr-4 text-center">
{editing ? (
<input type="checkbox"
checked={draft.permiteServidorBackup ?? false}
onChange={(e) => setDraft({ ...draft, permiteServidorBackup: e.target.checked })} />
) : (
p.permiteServidorBackup ? <Check className="h-4 w-4 text-green-600 inline" /> : <span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-4 text-center">
{editing ? (
<input type="checkbox"
checked={draft.permiteSatIncremental ?? false}
onChange={(e) => setDraft({ ...draft, permiteSatIncremental: e.target.checked })} />
) : (
p.permiteSatIncremental ? <Check className="h-4 w-4 text-green-600 inline" /> : <span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2 pr-2">
{editing ? (
<div className="flex gap-1 justify-end">
<Button size="sm" variant="default" onClick={() => saveEdit(p.plan)} disabled={updateMutation.isPending}>
{updateMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit} disabled={updateMutation.isPending}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<Button size="sm" variant="ghost" onClick={() => startEdit(p)}>
<Pencil className="h-3 w-3" />
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{updateMutation.isError && (
<Card className="bg-red-50 dark:bg-red-950/30 border-red-200">
<CardContent className="py-3 text-sm text-red-700 dark:text-red-400">
Error guardando: {String((updateMutation.error as any)?.response?.data?.message || updateMutation.error)}
</CardContent>
</Card>
)}
<Card className="bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900/50">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p>
<strong>Cobro adicional por RFC extra:</strong>{' '}
<span className="font-mono">${DESPACHO_OVERAGE_PRICE_MENSUAL}/mes</span> por
cada contribuyente que exceda el límite. Solo aplica a
<strong> Business Control</strong> y <strong>Enterprise</strong>; los planes
Mi Empresa tienen límite duro de 1 RFC.
</p>
<p className="text-muted-foreground">
<strong>maxUsers = -1</strong> significa ilimitado. <strong>trial</strong> y <strong>custom</strong> no tienen precio fijo
(trial es gratis 30 días; custom se asigna con monto variable al provisionar).
</p>
<p className="text-muted-foreground">
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
vs ~24h con solo el daily.
</p>
</div>
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
getPaquetesCatalogoAdmin,
updatePaqueteCatalogo,
type PaqueteCatalogoAdmin,
} from '@/lib/api/facturacion';
import { formatCurrency } from '@/lib/utils';
import { Package, ShieldAlert, Loader2, CheckCircle2, AlertTriangle, Save } from 'lucide-react';
export default function TimbresCatalogoPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: catalogo = [], isLoading } = useQuery({
queryKey: ['timbres-paquetes-catalogo-admin'],
queryFn: getPaquetesCatalogoAdmin,
enabled: isGlobalAdmin,
});
if (!isGlobalAdmin) {
return (
<>
<Header title="Catálogo de timbres" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">Solo admin global puede editar el catálogo.</p>
</CardContent>
</Card>
</main>
</>
);
}
return (
<>
<Header title="Catálogo de timbres adicionales" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
Los cambios de precio aplican <strong>sólo a compras nuevas</strong>.
Los paquetes ya vendidos conservan el precio que pagó el cliente (snapshot).
Desactivar un paquete lo oculta del catálogo público pero no afecta
paquetes vigentes.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Paquetes en el catálogo
</CardTitle>
<CardDescription>Edita precio o da de baja. Orden por cantidad ascendente.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : catalogo.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay paquetes en el catálogo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Cantidad</th>
<th className="pb-2 font-medium">Precio actual</th>
<th className="pb-2 font-medium">Precio por timbre</th>
<th className="pb-2 font-medium">Estado</th>
<th className="pb-2 font-medium">Última actualización</th>
<th className="pb-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{catalogo.map((p) => (
<PaqueteRow key={p.id} paquete={p} onSaved={() => queryClient.invalidateQueries({ queryKey: ['timbres-paquetes-catalogo-admin'] })} />
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}
function PaqueteRow({ paquete, onSaved }: { paquete: PaqueteCatalogoAdmin; onSaved: () => void }) {
const [editing, setEditing] = useState(false);
const [precio, setPrecio] = useState(paquete.precio.toString());
const [active, setActive] = useState(paquete.active);
const [saved, setSaved] = useState(false);
const mutation = useMutation({
mutationFn: () => updatePaqueteCatalogo(paquete.id, {
precio: Number(precio),
active,
}),
onSuccess: () => {
setEditing(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
onSaved();
},
onError: (err: any) => {
alert(err?.response?.data?.message || 'Error al guardar');
},
});
const precioNum = Number(precio);
const precioValido = precioNum > 0 && !isNaN(precioNum);
const hasChanges = Number(precio) !== paquete.precio || active !== paquete.active;
return (
<tr className="border-b last:border-0">
<td className="py-3 font-medium">{paquete.cantidad.toLocaleString('es-MX')}</td>
<td className="py-3">
{editing ? (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">$</span>
<Input
type="number"
step="0.01"
value={precio}
onChange={(e) => setPrecio(e.target.value)}
className="w-28 h-8"
/>
</div>
) : (
<span className="font-medium">{formatCurrency(paquete.precio)}</span>
)}
</td>
<td className="py-3 text-muted-foreground">
{precioValido ? formatCurrency(precioNum / paquete.cantidad) : '—'}
</td>
<td className="py-3">
{editing ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={active}
onChange={(e) => setActive(e.target.checked)}
className="h-4 w-4 accent-primary"
/>
<span className="text-xs">{active ? 'Activo' : 'Inactivo'}</span>
</label>
) : (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${paquete.active ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
{paquete.active ? 'Activo' : 'Inactivo'}
</span>
)}
</td>
<td className="py-3 text-xs text-muted-foreground">
{new Date(paquete.updatedAt).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="py-3 text-right">
{saved ? (
<span className="text-xs text-green-700 inline-flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" /> Guardado
</span>
) : editing ? (
<div className="flex items-center gap-1 justify-end">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditing(false);
setPrecio(paquete.precio.toString());
setActive(paquete.active);
}}
disabled={mutation.isPending}
>
Cancelar
</Button>
<Button
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !precioValido || !hasChanges}
>
{mutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3 mr-1" />}
Guardar
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Editar
</Button>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { FielUploadModal } from '@/components/sat/FielUploadModal';
import { SyncStatus } from '@/components/sat/SyncStatus';
import { SyncHistory } from '@/components/sat/SyncHistory';
import { Header } from '@/components/layouts/header';
import { getFielStatus, deleteFiel } from '@/lib/api/fiel';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { useAuthStore } from '@/stores/auth-store';
import { isDespachoTenant } from '@horux/shared';
import { Building2 } from 'lucide-react';
import type { FielStatus } from '@horux/shared';
export default function SatConfigPage() {
const [fielStatus, setFielStatus] = useState<FielStatus | null>(null);
const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
// For despachos, use per-contribuyente FIEL; for Horux360, use tenant-level
const contribId = isDespacho ? selectedContribuyenteId : null;
const fetchFielStatus = async () => {
setLoading(true);
try {
const status = await getFielStatus(contribId);
setFielStatus(status);
} catch (err) {
console.error('Error fetching FIEL status:', err);
setFielStatus({ configured: false });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFielStatus();
}, [viewingTenantId, selectedContribuyenteId]);
const handleUploadSuccess = (status: FielStatus) => {
setFielStatus(status);
setShowUploadModal(false);
};
const handleDelete = async () => {
if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) {
return;
}
setDeleting(true);
try {
await deleteFiel(contribId);
setFielStatus({ configured: false });
} catch (err) {
console.error('Error deleting FIEL:', err);
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<>
<Header title="Configuración SAT" />
<div className="p-6">
<p>Cargando...</p>
</div>
</>
);
}
return (
<>
<Header title="Configuración SAT" />
<main className="p-6 space-y-6">
{/* Despacho: show which contribuyente or prompt to select */}
{isDespacho && !selectedContribuyenteId && (
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/20">
<CardContent className="py-4 flex items-center gap-3">
<Building2 className="h-5 w-5 text-amber-600" />
<p className="text-sm text-amber-800 dark:text-amber-300">Selecciona un contribuyente en el header para ver y configurar su FIEL.</p>
</CardContent>
</Card>
)}
{isDespacho && selectedContribuyenteId && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3 px-5 flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">FIEL de: {selectedContribuyenteNombre}</span>
<span className="text-xs text-muted-foreground font-mono">{selectedContribuyenteRfc}</span>
</CardContent>
</Card>
)}
{/* For despachos without RFC selected, hide everything below the banner */}
{isDespacho && !selectedContribuyenteId ? null : (
<>
{/* Estado de la FIEL */}
<Card>
<CardHeader>
<CardTitle>FIEL (e.firma)</CardTitle>
<CardDescription>
Tu firma electronica para autenticarte con el SAT
</CardDescription>
</CardHeader>
<CardContent>
{fielStatus?.configured ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">RFC</p>
<p className="font-medium">{fielStatus.rfc}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">No. Serie</p>
<p className="font-medium text-xs">{fielStatus.serialNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Vigente hasta</p>
<p className="font-medium">
{fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<p className={`font-medium ${fielStatus.isExpired ? 'text-red-500' : 'text-green-500'}`}>
{fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`}
</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowUploadModal(true)}
>
Actualizar FIEL
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Eliminando...' : 'Eliminar FIEL'}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground">
No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar
la sincronizacion automatica de CFDIs con el SAT.
</p>
<Button onClick={() => setShowUploadModal(true)}>
Configurar FIEL
</Button>
</div>
)}
</CardContent>
</Card>
{/* Estado de Sincronizacion */}
<SyncStatus
fielConfigured={fielStatus?.configured || false}
onSyncStarted={fetchFielStatus}
contribuyenteId={contribId}
/>
{/* Historial */}
<SyncHistory
fielConfigured={fielStatus?.configured || false}
contribuyenteId={contribId}
/>
{/* Modal de carga */}
{showUploadModal && (
<FielUploadModal
onSuccess={handleUploadSuccess}
onClose={() => setShowUploadModal(false)}
contribuyenteId={contribId}
/>
)}
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { changePassword, logoutAll } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { KeyRound, LogOut, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
export default function SeguridadPage() {
const router = useRouter();
const { logout } = useAuthStore();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changing, setChanging] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null);
const [changeOk, setChangeOk] = useState<string | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangeError(null);
setChangeOk(null);
if (newPassword.length < 8) {
setChangeError('La nueva contraseña debe tener al menos 8 caracteres');
return;
}
if (newPassword !== confirmPassword) {
setChangeError('Las contraseñas no coinciden');
return;
}
if (currentPassword === newPassword) {
setChangeError('La nueva contraseña debe ser distinta a la actual');
return;
}
setChanging(true);
try {
const res = await changePassword(currentPassword, newPassword);
setChangeOk(res.message);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => {
logout();
router.push('/login');
}, 2500);
} catch (err: any) {
setChangeError(err?.response?.data?.message || 'Error al cambiar contraseña');
} finally {
setChanging(false);
}
};
const handleLogoutAll = async () => {
if (!confirm('Esto cerrará todas tus sesiones activas, incluyendo esta. Tendrás que iniciar sesión de nuevo. ¿Continuar?')) return;
setLoggingOut(true);
try {
await logoutAll();
logout();
router.push('/login');
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al cerrar sesiones');
setLoggingOut(false);
}
};
return (
<>
<Header title="Seguridad" />
<main className="p-6 space-y-6 max-w-3xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Cambiar contraseña
</CardTitle>
<CardDescription>
Al cambiar tu contraseña, todas tus sesiones (incluyendo esta) serán cerradas por seguridad.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="current">Contraseña actual</Label>
<Input
id="current"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="new">Nueva contraseña</Label>
<Input
id="new"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">Mínimo 8 caracteres.</p>
</div>
<div className="space-y-1">
<Label htmlFor="confirm">Confirmar nueva contraseña</Label>
<Input
id="confirm"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{changeError && (
<div className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{changeError}</span>
</div>
)}
{changeOk && (
<div className="flex items-start gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{changeOk}</span>
</div>
)}
<Button type="submit" disabled={changing || !!changeOk}>
{changing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Actualizar contraseña
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<LogOut className="h-4 w-4" />
Cerrar todas las sesiones
</CardTitle>
<CardDescription>
Útil si perdiste un dispositivo o sospechas que alguien accedió a tu cuenta. Tendrás que iniciar sesión de nuevo en todos tus dispositivos, incluyendo este.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={handleLogoutAll} disabled={loggingOut}>
{loggingOut && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Cerrar todas mis sesiones
</Button>
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import {
CheckCircle,
AlertCircle,
Clock,
XCircle,
Building,
Sparkles,
} from 'lucide-react';
// ============================================================================
// Helpers
// ============================================================================
const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
trial: { label: 'Prueba gratis', color: 'text-blue-700', bgColor: 'bg-blue-50 border-blue-200', icon: Sparkles },
trial_expired: { label: 'Prueba vencida', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
trial_converted: { label: 'Prueba convertida', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
};
// ============================================================================
// Admin global: vista de todas las suscripciones
// ============================================================================
// Edición de precios de planes movida a /configuracion/precios-suscripcion.
// Vista self-serve para clientes movida a /configuracion/planes-despacho.
function AdminGlobalSubscriptions() {
const { data: subscriptions, isLoading } = useQuery({
queryKey: ['all-subscriptions'],
queryFn: () => apiClient.get('/subscriptions').then(r => r.data),
});
if (isLoading) return <div className="text-center py-8 text-muted-foreground">Cargando...</div>;
const subs = (subscriptions || []) as any[];
const activas = subs.filter((s: any) => s.status === 'authorized' || s.status === 'active');
const pendientes = subs.filter((s: any) => s.status === 'pending');
const canceladas = subs.filter((s: any) => s.status === 'cancelled' || s.status === 'paused');
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-primary/10 rounded-lg"><Building className="h-5 w-5 text-primary" /></div><div><p className="text-2xl font-bold">{subs.length}</p><p className="text-xs text-muted-foreground">Total</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-green-100 rounded-lg"><CheckCircle className="h-5 w-5 text-green-600" /></div><div><p className="text-2xl font-bold">{activas.length}</p><p className="text-xs text-muted-foreground">Activas</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-yellow-100 rounded-lg"><Clock className="h-5 w-5 text-yellow-600" /></div><div><p className="text-2xl font-bold">{pendientes.length}</p><p className="text-xs text-muted-foreground">Pendientes</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-red-100 rounded-lg"><XCircle className="h-5 w-5 text-red-600" /></div><div><p className="text-2xl font-bold">{canceladas.length}</p><p className="text-xs text-muted-foreground">Canceladas</p></div></div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Todas las Suscripciones</CardTitle></CardHeader>
<CardContent>
{subs.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay suscripciones</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Cliente</th><th className="py-2 pr-4">RFC</th><th className="py-2 pr-4">Plan</th><th className="py-2 pr-4">Estado</th><th className="py-2 pr-4 text-right">Monto</th><th className="py-2 pr-4">Frecuencia</th><th className="py-2 pr-4">Siguiente pago</th><th className="py-2">Creada</th>
</tr>
</thead>
<tbody>
{subs.map((s: any) => {
const st = statusConfig[s.status] || statusConfig.pending;
const StIcon = st.icon;
return (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-muted/50">
<td className="py-3 pr-4 font-medium">{s.tenant?.nombre || '—'}</td>
<td className="py-3 pr-4 font-mono text-xs">{s.tenant?.rfc || '—'}</td>
<td className="py-3 pr-4"><span className="px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">{s.plan}</span></td>
<td className="py-3 pr-4"><span className={`inline-flex items-center gap-1 text-xs font-medium ${st.color}`}><StIcon className="h-3 w-3" />{st.label}</span></td>
<td className="py-3 pr-4 text-right font-medium">${Number(s.amount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 pr-4 text-muted-foreground capitalize">{s.frequency}</td>
<td className="py-3 pr-4">{s.currentPeriodEnd ? new Date(s.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}</td>
<td className="py-3 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString('es-MX')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ============================================================================
// Frequency Toggle
// ============================================================================
function FrequencyToggle({ value, onChange }: { value: 'monthly' | 'annual'; onChange: (v: 'monthly' | 'annual') => void }) {
return (
<div className="inline-flex items-center rounded-lg border bg-card p-1 text-sm">
<button type="button" onClick={() => onChange('monthly')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Mensual</button>
<button type="button" onClick={() => onChange('annual')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'annual' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Anual <span className="ml-1 text-xs opacity-75">(ahorra 17%)</span></button>
</div>
);
}
// ============================================================================
// Main Page
// ============================================================================
export default function SuscripcionPage() {
const { user } = useAuthStore();
const router = useRouter();
const isAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
// Para clientes la gestión de suscripción vive en /configuracion/planes-despacho.
// Esta página queda como panel agregado del admin global (ver TODAS las suscripciones).
// Si por algún link viejo cae un cliente regular, lo enviamos a Planes.
useEffect(() => {
if (!isAdmin) router.replace('/configuracion/planes-despacho');
}, [isAdmin, router]);
if (!isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
Redirigiendo a Planes
</div>
);
}
// Admin global: vista agregada de todas las suscripciones de la plataforma.
return (
<>
<Header title="Suscripciones" />
<main className="p-6"><AdminGlobalSubscriptions /></main>
</>
);
}