Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 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,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,627 @@
'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>
{/* 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,404 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription } from '@/lib/api/subscription';
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 [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel'>(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';
// Plan Custom: asignado por administrador, sin cobro, sin fecha fin.
// Cuando es activo, ocultamos las cards de planes pagables (no hay opción
// de auto-cambio — el contador debe contactar soporte si quiere cambiar).
const isCustomPlan = currentPlan === 'custom';
/** 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 {
const result = await subscribeMe({ plan, frequency });
window.open(result.paymentUrl, '_blank');
setMessage({ kind: 'ok', text: 'Abrimos el pago de MercadoPago en otra pestana. Al completar regresa aqui.' });
} catch (err: any) {
const msg: string = err?.response?.data?.message || err?.message || '';
if (/Ya existe una suscripci/i.test(msg)) {
// Ya hay sub activa/pendiente en otro plan — tratar como cambio
try {
await changeMyPlan({ plan, frequency });
setMessage({ kind: 'ok', text: 'Cambio de plan programado para el final del periodo actual.' });
fetchPlan();
} catch (changeErr: any) {
setMessage({ kind: 'err', text: changeErr?.response?.data?.message || changeErr?.message || 'Error al cambiar el plan' });
}
} else {
setMessage({ kind: 'err', text: msg || 'Error al contratar el plan' });
}
} 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 = hasPaidPlan ? '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>
);
})()}
{/* 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>
)}
{!isCustomPlan && (
<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 solo si tiene plan pagable activo */}
{hasPaidPlan && (
<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 suscripcion'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import {
isGlobalAdminRfc,
DESPACHO_PLAN_PRICES,
DESPACHO_PLANS,
DESPACHO_OVERAGE_PRICE_MENSUAL,
type DespachoPaidPlan,
} from '@horux/shared';
import { Tags, ShieldAlert, Info, AlertTriangle } from 'lucide-react';
const PLAN_ORDER: DespachoPaidPlan[] = [
'mi_empresa',
'mi_empresa_plus',
'business_control',
'business_cloud',
];
function fmtCurrency(n: number | null): string {
if (n == null) return '—';
return `$${n.toLocaleString('es-MX')}`;
}
export default function PreciosSuscripcionPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
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>
</>
);
}
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 están configurados en{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">packages/shared/src/constants/despacho-plans.ts</code>.
Para modificar precios, edita ese archivo y redespliega los cambios aplican
a contrataciones nuevas y renovaciones; las suscripciones vigentes
conservan su precio.
</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>
<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 (MXN)</th>
<th className="py-2 pr-4 text-right">Anual primer año</th>
<th className="py-2 pr-4 text-right">Anual renovación</th>
<th className="py-2 pr-4 text-right">RFCs incluidos</th>
<th className="py-2 pr-4 text-right">Timbres/mes</th>
</tr>
</thead>
<tbody>
{PLAN_ORDER.map((plan) => {
const price = DESPACHO_PLAN_PRICES[plan];
const limits = DESPACHO_PLANS[plan];
return (
<tr key={plan} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 font-medium">{limits.name}</td>
<td className="py-3 pr-4 text-right">
{price.permiteMonthly
? <span className="font-medium">{fmtCurrency(price.monthly)}</span>
: <span className="text-muted-foreground">No aplica</span>}
</td>
<td className="py-3 pr-4 text-right font-medium">{fmtCurrency(price.firstYear)}</td>
<td className="py-3 pr-4 text-right">
{price.firstYear !== price.renewal
? <span className="font-medium">{fmtCurrency(price.renewal)}</span>
: <span className="text-muted-foreground">{fmtCurrency(price.renewal)}</span>}
</td>
<td className="py-3 pr-4 text-right text-muted-foreground">{limits.maxRfcs}</td>
<td className="py-3 pr-4 text-right text-muted-foreground">{limits.timbresIncluidosMes}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</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 los 100 incluidos. 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">
Mi Empresa y Mi Empresa+ permiten facturación mensual o anual; al pagar
anual se cobra el equivalente a 10 meses (descuento del 17%).
</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,727 @@
'use client';
import { useMemo, useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } 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 {
useSubscription,
usePaymentHistory,
useGeneratePaymentLink,
usePlans,
useStartTrial,
useSubscribeMe,
useChangeMyPlan,
useCancelMySubscription,
useUpgradeMe,
useCancelPendingUpgrade,
useReactivateMe,
} from '@/lib/hooks/use-subscription';
import {
CreditCard,
Calendar,
CheckCircle,
AlertCircle,
Clock,
XCircle,
ExternalLink,
Loader2,
AlertTriangle,
CalendarClock,
Building,
Sparkles,
ArrowRight,
Gift,
} from 'lucide-react';
// ============================================================================
// Helpers
// ============================================================================
const PLAN_ORDER = ['starter', 'business', 'business_ia', 'enterprise'] as const;
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
custom: 'Custom',
enterprise: 'Enterprise',
};
const PLAN_FEATURES: Record<string, string[]> = {
starter: ['Dashboard básico', 'CFDI manual', 'Cálculo IVA/ISR'],
business: ['Todo Starter', '50 CFDIs / mes', '3 usuarios', 'Reportes avanzados', 'Alertas fiscales', 'Conciliación bancaria', 'Sincronización SAT', 'Documentos (Opinión Cumplimiento)'],
business_ia: ['Todo Business', 'Lolita — agente IA fiscal'],
enterprise: ['Todo Business', 'Lolita — agente IA fiscal', '100 CFDIs / mes', 'Usuarios ilimitados', 'API de integración'],
};
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 },
};
function getDaysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null;
const diff = new Date(dateStr).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' });
}
function formatAmount(amount: number | string): string {
return `$${Number(amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} MXN`;
}
// ============================================================================
// Plan Grid — componente reusable para picker y modal
// ============================================================================
interface PlanGridProps {
frequency: 'monthly' | 'annual';
selectedPlan: string | null;
currentPlan?: string | null; // resalta el plan actual cuando está en modal
onSelect: (plan: string) => void;
prices: Array<{ plan: string; frequency: string; amount: string }>;
}
function PlanGrid({ frequency, selectedPlan, currentPlan, onSelect, prices }: PlanGridProps) {
const priceByPlan = useMemo(() => {
const m = new Map<string, number>();
for (const p of prices) {
if (p.frequency === frequency) m.set(p.plan, Number(p.amount));
}
return m;
}, [prices, frequency]);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{PLAN_ORDER.map((plan) => {
const price = priceByPlan.get(plan) ?? 0;
const isSelected = selectedPlan === plan;
const isCurrent = currentPlan === plan;
const features = PLAN_FEATURES[plan] || [];
return (
<button
key={plan}
type="button"
onClick={() => onSelect(plan)}
className={`text-left rounded-lg border-2 p-5 transition-all ${
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border hover:border-primary/50 hover:bg-muted/40'
} ${isCurrent ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-background' : ''}`}
>
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-bold text-lg">{PLAN_LABELS[plan]}</h3>
{isCurrent && (
<span className="text-xs font-medium text-blue-700 bg-blue-50 px-2 py-0.5 rounded-full">Actual</span>
)}
</div>
<div className="mb-4">
<span className="text-3xl font-bold">{formatAmount(price)}</span>
<span className="text-sm text-muted-foreground ml-1">/ {frequency === 'monthly' ? 'mes' : 'año'}</span>
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{features.map((f) => (
<li key={f} className="flex items-start gap-1.5">
<CheckCircle className="h-3.5 w-3.5 text-green-600 mt-0.5 shrink-0" />
<span>{f}</span>
</li>
))}
</ul>
</button>
);
})}
</div>
);
}
// ============================================================================
// Admin global: vista de todas las suscripciones
// ============================================================================
// Edición de precios de planes movida a /configuracion/precios-suscripcion.
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();
// Admin global ve todas las suscripciones
if (isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) {
return (
<>
<Header title="Suscripciones" />
<main className="p-6"><AdminGlobalSubscriptions /></main>
</>
);
}
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: plans = [] } = usePlans();
const { data: payments } = usePaymentHistory(user?.tenantId);
const startTrial = useStartTrial();
const subscribeMe = useSubscribeMe();
const changePlan = useChangeMyPlan();
const cancelSub = useCancelMySubscription();
const generateLink = useGeneratePaymentLink();
const upgradeMe = useUpgradeMe();
const cancelUpgrade = useCancelPendingUpgrade();
const reactivateSub = useReactivateMe();
const [pickerFrequency, setPickerFrequency] = useState<'monthly' | 'annual'>('monthly');
const [pickerSelected, setPickerSelected] = useState<string | null>(null);
const [changeModalOpen, setChangeModalOpen] = useState(false);
const [changeFreq, setChangeFreq] = useState<'monthly' | 'annual'>('monthly');
const [changeSelected, setChangeSelected] = useState<string | null>(null);
const [cancelModalOpen, setCancelModalOpen] = useState(false);
// Estado derivado
const status = subscription?.status || null;
const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd);
const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0;
const isTrial = status === 'trial' && !isExpired;
const isTrialExpired = status === 'trial_expired' || (status === 'trial' && isExpired);
const isCancelledInPeriod = status === 'cancelled' && !isExpired;
const isCancelledExpired = status === 'cancelled' && isExpired;
const isActive = status === 'authorized' && !isExpired;
const isPending = status === 'pending';
const hasUsedTrial = !!subscription && ['trial', 'trial_expired', 'trial_converted'].includes(status || '');
const needsNewSubscription = !subscription || isTrialExpired || isCancelledExpired;
const hasPendingChange = !!subscription?.pendingPlan && !!subscription?.pendingEffectiveAt;
const hasPendingUpgrade = !!subscription?.upgradePreferenceId && !!subscription?.upgradeTargetPlan;
// Handlers
const handleStartTrial = async () => {
if (!pickerSelected) return;
try {
await startTrial.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al iniciar trial');
}
};
const handleSubscribe = async () => {
if (!pickerSelected) return;
try {
const result = await subscribeMe.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al crear suscripción');
}
};
const openChangeModal = () => {
setChangeFreq((subscription?.frequency as 'monthly' | 'annual') || 'monthly');
setChangeSelected(subscription?.plan || null);
setChangeModalOpen(true);
};
// Clasifica el cambio para decidir endpoint: upgrade inmediato (con cobro prorateado)
// vs scheduled change (al próximo período). Upgrade aplica solo si se mantiene la
// frecuencia actual Y el nuevo plan es más caro que el actual para esa frecuencia.
const classifyChange = (plan: string, freq: 'monthly' | 'annual'): 'upgrade' | 'scheduled' | 'noop' => {
if (!subscription) return 'scheduled';
if (plan === subscription.plan && freq === subscription.frequency) return 'noop';
if (freq !== subscription.frequency) return 'scheduled';
const newPrice = Number(plans.find((p) => p.plan === plan && p.frequency === freq)?.amount ?? 0);
const currentPrice = Number(subscription.amount);
return newPrice > currentPrice ? 'upgrade' : 'scheduled';
};
const handleConfirmChange = async () => {
if (!changeSelected) return;
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'noop') {
setChangeModalOpen(false);
return;
}
try {
if (kind === 'upgrade') {
const result = await upgradeMe.mutateAsync(changeSelected);
setChangeModalOpen(false);
window.open(result.checkoutUrl, '_blank');
} else {
await changePlan.mutateAsync({ plan: changeSelected, frequency: changeFreq });
setChangeModalOpen(false);
}
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar plan');
}
};
const handleCancelPendingUpgrade = async () => {
try {
await cancelUpgrade.mutateAsync();
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar upgrade');
}
};
const handleReactivate = async () => {
try {
const result = await reactivateSub.mutateAsync();
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al reactivar suscripción');
}
};
const handleCancel = async () => {
try {
await cancelSub.mutateAsync();
setCancelModalOpen(false);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar');
}
};
const handleGeneratePaymentLink = async () => {
if (!user?.tenantId) return;
try {
const result = await generateLink.mutateAsync(user.tenantId);
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al generar link');
}
};
// ========================================================================
// Render
// ========================================================================
return (
<>
<Header title="Suscripción" />
<main className="p-6 space-y-6">
{isLoading && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Cargando...</CardContent></Card>
)}
{!isLoading && (
<>
{/* Banners de estado */}
{isTrial && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<Sparkles className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Estás en prueba gratuita</p>
<p className="text-sm text-blue-700 mt-1">
Te quedan <strong>{daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</strong> para probar todas las funciones. Contrata un plan antes del {formatDate(subscription?.currentPeriodEnd)} para continuar sin interrupciones.
</p>
</div>
</div>
)}
{isTrialExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-red-800">Tu prueba gratuita terminó</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para continuar usando Horux360.</p>
</div>
</div>
)}
{isCancelledInPeriod && (
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-orange-800">Suscripción cancelada</p>
<p className="text-sm text-orange-700 mt-1">
Seguirás teniendo acceso hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Puedes reactivarla antes de esa fecha para no perder la continuidad; el primer cobro se hará al iniciar el próximo período.
</p>
<Button size="sm" className="mt-3" onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-2" />}
Reactivar suscripción
</Button>
</div>
</div>
)}
{isCancelledExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-red-800">Suscripción vencida</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para reactivar tu cuenta.</p>
</div>
</div>
)}
{hasPendingChange && (
<div className="flex items-start gap-3 rounded-lg border border-purple-300 bg-purple-50 p-4">
<CalendarClock className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-purple-800">Cambio de plan programado</p>
<p className="text-sm text-purple-700 mt-1">
Tu plan cambiará a <strong>{PLAN_LABELS[subscription!.pendingPlan!]}</strong> ({subscription!.pendingFrequency === 'annual' ? 'anual' : 'mensual'}) el <strong>{formatDate(subscription!.pendingEffectiveAt)}</strong>.
</p>
</div>
</div>
)}
{hasPendingUpgrade && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<ArrowRight className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Upgrade pendiente de pago</p>
<p className="text-sm text-blue-700 mt-1">
Estás por cambiar a <strong>{PLAN_LABELS[subscription!.upgradeTargetPlan!]}</strong>. Completa el pago prorateado en MercadoPago para activar el plan nuevo de inmediato.
</p>
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCancelPendingUpgrade}
disabled={cancelUpgrade.isPending}
>
{cancelUpgrade.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Cancelar upgrade
</Button>
</div>
</div>
</div>
)}
{isPending && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<Clock className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">Tu suscripción está creada pero aún no autorizaste el pago en MercadoPago.</p>
<Button variant="default" size="sm" className="mt-3" onClick={handleGeneratePaymentLink} disabled={generateLink.isPending}>
{generateLink.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ExternalLink className="h-4 w-4 mr-2" />}
Completar pago
</Button>
</div>
</div>
)}
{/* Current subscription card — solo cuando hay sub activa/en-periodo */}
{subscription && !needsNewSubscription && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Tu Suscripción
</CardTitle>
{status && (
(() => {
const st = statusConfig[status] || statusConfig.pending;
const StIcon = st.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${st.bgColor} ${st.color}`}>
<StIcon className="h-4 w-4" />
{st.label}
</span>
);
})()
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="text-xl font-bold">{PLAN_LABELS[subscription.plan] || subscription.plan}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Monto</p>
<p className="text-xl font-bold">
{Number(subscription.amount) === 0 ? 'Gratis' : formatAmount(subscription.amount)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-xl font-bold">{subscription.frequency === 'annual' ? 'Anual' : 'Mensual'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-4 border-t">
{isTrial && (
<Button onClick={() => setPickerSelected(subscription.plan)}>
<ArrowRight className="h-4 w-4 mr-1" />
Contratar ahora
</Button>
)}
{isCancelledInPeriod && (
<Button onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-1" />}
Reactivar suscripción
</Button>
)}
{(isActive || isPending) && (
<Button variant="outline" onClick={openChangeModal}>Cambiar plan</Button>
)}
{(isActive || isPending || isTrial) && (
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setCancelModalOpen(true)}>
Cancelar suscripción
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Billing period */}
{subscription && subscription.currentPeriodEnd && !needsNewSubscription && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><CalendarClock className="h-5 w-5" />Período de Facturación</CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Inicio del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Fin del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{isTrial ? 'Termina trial en' : 'Próximo pago'}</p>
{daysUntilEnd !== null && daysUntilEnd > 0 ? (
<p className="font-medium">En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</p>
) : (
<p className="font-medium text-red-600">Vencido</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Picker — primera vez O después de trial/cancel vencido */}
{needsNewSubscription && (
<Card>
<CardHeader>
<CardTitle>{subscription ? 'Elige un plan para continuar' : 'Elige tu plan'}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Todos los planes incluyen acceso completo a la plataforma. Puedes cambiar o cancelar cuando quieras.
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center">
<FrequencyToggle value={pickerFrequency} onChange={setPickerFrequency} />
</div>
<PlanGrid frequency={pickerFrequency} selectedPlan={pickerSelected} onSelect={setPickerSelected} prices={plans} />
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4 border-t">
{!hasUsedTrial && (
<Button variant="outline" size="lg" onClick={handleStartTrial} disabled={!pickerSelected || startTrial.isPending} className="flex-1 sm:flex-initial">
{startTrial.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Gift className="h-4 w-4 mr-2" />}
Probar 30 días gratis
</Button>
)}
<Button size="lg" onClick={handleSubscribe} disabled={!pickerSelected || subscribeMe.isPending} className="flex-1 sm:flex-initial">
{subscribeMe.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CreditCard className="h-4 w-4 mr-2" />}
Contratar {pickerSelected ? PLAN_LABELS[pickerSelected] : 'plan'}
</Button>
</div>
{!pickerSelected && (
<p className="text-center text-sm text-muted-foreground">Selecciona un plan arriba para continuar</p>
)}
</CardContent>
</Card>
)}
{/* Payment history — siempre visible si hay pagos */}
{payments && payments.length > 0 && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5" />Historial de Pagos</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2.5 px-3">{new Date(p.createdAt).toLocaleDateString('es-MX')}</td>
<td className="py-2.5 px-3 font-medium">{formatAmount(p.amount)}</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${p.status === 'approved' ? 'bg-green-50 text-green-700' : p.status === 'rejected' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'}`}>
{p.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{p.status === 'rejected' && <XCircle className="h-3 w-3" />}
{p.status !== 'approved' && p.status !== 'rejected' && <Clock className="h-3 w-3" />}
{p.status === 'approved' ? 'Aprobado' : p.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span>
</td>
<td className="py-2.5 px-3 text-muted-foreground capitalize">{p.paymentMethod === 'bank_transfer' ? 'Transferencia' : p.paymentMethod || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
)}
{/* Change plan modal */}
<Dialog open={changeModalOpen} onOpenChange={setChangeModalOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Cambiar plan</DialogTitle>
<DialogDescription>
Los <strong>upgrades</strong> (plan más caro, misma frecuencia) se cobran ahora por la diferencia prorateada y se activan de inmediato. Los <strong>downgrades</strong> y cambios de frecuencia se aplican al iniciar tu próximo período ({formatDate(subscription?.currentPeriodEnd)}).
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex justify-center">
<FrequencyToggle value={changeFreq} onChange={setChangeFreq} />
</div>
<PlanGrid frequency={changeFreq} selectedPlan={changeSelected} currentPlan={subscription?.plan} onSelect={setChangeSelected} prices={plans} />
{changeSelected && (() => {
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'upgrade') {
return (
<div className="rounded-md border border-blue-300 bg-blue-50 p-3 text-sm text-blue-800">
<strong>Este cambio es un upgrade.</strong> Al confirmar, abriremos MercadoPago para cobrar el monto prorateado de los días restantes del período actual. El plan nuevo se activa en cuanto se confirma el pago.
</div>
);
}
if (kind === 'scheduled') {
return (
<div className="rounded-md border border-purple-300 bg-purple-50 p-3 text-sm text-purple-800">
Este cambio se aplicará el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Sin cargo adicional ahora.
</div>
);
}
return null;
})()}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setChangeModalOpen(false)}>Cancelar</Button>
<Button onClick={handleConfirmChange} disabled={!changeSelected || changePlan.isPending || upgradeMe.isPending}>
{(changePlan.isPending || upgradeMe.isPending) ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
{changeSelected && classifyChange(changeSelected, changeFreq) === 'upgrade' ? 'Pagar y activar' : 'Programar cambio'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel confirmation modal */}
<Dialog open={cancelModalOpen} onOpenChange={setCancelModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>¿Cancelar suscripción?</DialogTitle>
<DialogDescription>
Conservarás acceso a todas las funciones hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Después de esa fecha tendrás que elegir un nuevo plan para seguir usando Horux360.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelModalOpen(false)}>No, mantener</Button>
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={handleCancel} disabled={cancelSub.isPending}>
{cancelSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
, cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}