Initial commit - Horux Despachos NL

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

View File

@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useAuditLog } from '@/lib/hooks/use-audit-log';
import { ChevronLeft, ChevronRight, Search, X, FileWarning, ShieldAlert } from 'lucide-react';
const ACTION_GROUPS = [
{ value: '', label: 'Todas las acciones' },
{ value: 'user.', label: 'Usuarios (login, logout, password)' },
{ value: 'subscription.', label: 'Suscripciones (crear, cancelar, cambiar, reactivar)' },
{ value: 'trial.', label: 'Trials' },
{ value: 'price.', label: 'Precios' },
{ value: 'invoice.', label: 'Facturación' },
{ value: 'payment.', label: 'Pagos' },
{ value: 'tenant.', label: 'Tenants' },
{ value: 'fiel.', label: 'FIEL' },
];
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
'user.login': { label: 'Login', color: 'bg-blue-50 text-blue-700' },
'user.logout': { label: 'Logout', color: 'bg-slate-50 text-slate-700' },
'user.password_changed': { label: 'Cambio password', color: 'bg-amber-50 text-amber-700' },
'trial.started': { label: 'Trial iniciado', color: 'bg-sky-50 text-sky-700' },
'subscription.created': { label: 'Suscripción creada',color: 'bg-green-50 text-green-700' },
'subscription.cancelled': { label: 'Suscripción cancelada', color: 'bg-orange-50 text-orange-700' },
'subscription.reactivated': { label: 'Reactivada', color: 'bg-teal-50 text-teal-700' },
'subscription.plan_changed': { label: 'Cambio de plan', color: 'bg-indigo-50 text-indigo-700' },
'price.updated': { label: 'Precio editado', color: 'bg-purple-50 text-purple-700' },
'invoice.emitted_auto': { label: 'Factura auto', color: 'bg-emerald-50 text-emerald-700' },
'invoice.emitted_manual': { label: 'Factura manual', color: 'bg-emerald-50 text-emerald-700' },
'payment.marked_paid_manually': { label: 'Pago marcado manual', color: 'bg-lime-50 text-lime-700' },
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'medium' });
}
function ActionBadge({ action }: { action: string }) {
const cfg = ACTION_LABELS[action] || { label: action, color: 'bg-muted text-muted-foreground' };
return <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${cfg.color}`}>{cfg.label}</span>;
}
export default function AuditLogPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const [filters, setFilters] = useState({
action: '',
tenantId: '',
userId: '',
from: '',
to: '',
page: 1,
limit: 50,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useAuditLog(filters);
if (!isGlobalAdmin) {
return (
<>
<Header title="Audit Log" />
<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 el administrador global puede consultar el audit log.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const clearFilters = () => setFilters({ action: '', tenantId: '', userId: '', from: '', to: '', page: 1, limit: 50 });
return (
<>
<Header title="Audit Log" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileWarning className="h-5 w-5" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="space-y-1">
<Label>Acción</Label>
<Select value={filters.action || 'all'} onValueChange={v => setFilters(f => ({ ...f, action: v === 'all' ? '' : v, page: 1 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ACTION_GROUPS.map(g => (
<SelectItem key={g.value || 'all'} value={g.value || 'all'}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Tenant ID</Label>
<Input value={filters.tenantId} onChange={e => setFilters(f => ({ ...f, tenantId: e.target.value, page: 1 }))} placeholder="UUID del tenant" />
</div>
<div className="space-y-1">
<Label>User ID</Label>
<Input value={filters.userId} onChange={e => setFilters(f => ({ ...f, userId: e.target.value, page: 1 }))} placeholder="UUID del usuario" />
</div>
<div className="space-y-1">
<Label>Desde</Label>
<Input type="datetime-local" value={filters.from} onChange={e => setFilters(f => ({ ...f, from: e.target.value, page: 1 }))} />
</div>
<div className="space-y-1">
<Label>Hasta</Label>
<Input type="datetime-local" value={filters.to} onChange={e => setFilters(f => ({ ...f, to: e.target.value, page: 1 }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
Limpiar filtros
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Eventos {data?.total !== undefined && <span className="text-sm font-normal text-muted-foreground">({data.total.toLocaleString('es-MX')} totales)</span>}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : !data || data.data.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay eventos con estos filtros.</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 font-medium">Fecha</th>
<th className="py-2 pr-4 font-medium">Acción</th>
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant</th>
<th className="py-2 pr-4 font-medium">Entidad</th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{data.data.map(row => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">{formatDateTime(row.createdAt)}</td>
<td className="py-3 pr-4"><ActionBadge action={row.action} /></td>
<td className="py-3 pr-4">
{row.user ? (
<div className="text-xs">
<div className="font-medium">{row.user.nombre}</div>
<div className="text-muted-foreground">{row.user.email}</div>
</div>
) : <span className="text-muted-foreground text-xs">Sistema</span>}
</td>
<td className="py-3 pr-4">
{row.tenant ? (
<div className="text-xs">
<div className="font-medium">{row.tenant.nombre}</div>
<div className="text-muted-foreground font-mono">{row.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="py-3 pr-4 text-xs text-muted-foreground">
{row.entityType ? `${row.entityType}${row.entityId ? ` ${row.entityId.slice(0, 8)}` : ''}` : '—'}
</td>
<td className="py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expandedId === row.id ? null : row.id)}>
{expandedId === row.id ? 'Ocultar' : 'Ver detalle'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{expandedId && (() => {
const row = data.data.find(r => r.id === expandedId);
if (!row) return null;
return (
<Card className="mt-4 bg-muted/20">
<CardContent className="pt-4">
<p className="text-xs text-muted-foreground mb-2">Metadata del evento <code className="font-mono">{row.id}</code></p>
<pre className="text-xs whitespace-pre-wrap break-all bg-background p-3 rounded border">{JSON.stringify(row.metadata, null, 2)}</pre>
</CardContent>
</Card>
);
})()}
{data.totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t mt-4">
<p className="text-sm text-muted-foreground">Página {data.page} de {data.totalPages}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={data.page <= 1} onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={data.page >= data.totalPages} onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff';
import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react';
const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any; color: string }> = {
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
};
const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
export default function StaffPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: staff = [], isLoading } = useStaff();
const grantRole = useGrantRole();
const revokeRole = useRevokeRole();
const [addOpen, setAddOpen] = useState(false);
const [searchQ, setSearchQ] = useState('');
const { data: candidates = [] } = useSearchUsers(searchQ);
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const [pickedRole, setPickedRole] = useState<PlatformRole>('platform_support');
if (!isGlobalAdmin) {
return (
<>
<Header title="Gestión de Staff" />
<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 platform_admin puede gestionar staff.</p>
</CardContent>
</Card>
</main>
</>
);
}
const handleGrant = async () => {
if (!pickedUserId) return;
try {
await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole });
setAddOpen(false);
setSearchQ('');
setPickedUserId(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al asignar rol');
}
};
const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => {
if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return;
try {
await revokeRole.mutateAsync({ userId, role });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al quitar rol');
}
};
return (
<>
<Header title="Gestión de Staff" />
<main className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
Staff interno de Horux 360 con poderes transversales. <code className="font-mono text-xs">platform_admin</code> implica todos los otros roles.
</p>
</div>
<Button onClick={() => setAddOpen(true)}>
<UserPlus className="h-4 w-4 mr-1" />
Agregar staff
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Equipo ({staff.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : staff.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Todavía no hay staff. Agrega al primer miembro con el botón arriba.
</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 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant origen</th>
<th className="py-2 pr-4 font-medium">Roles</th>
<th className="py-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{staff.map(s => (
<tr key={s.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4">
<div className="font-medium">{s.nombre}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</td>
<td className="py-3 pr-4 text-xs">
{s.tenant ? (
<div>
<div>{s.tenant.nombre}</div>
<div className="font-mono text-muted-foreground">{s.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground"></span>}
</td>
<td className="py-3 pr-4">
<div className="flex flex-wrap gap-1.5">
{s.roles.map(r => {
const meta = ROLE_META[r];
const Icon = meta.icon;
return (
<span key={r} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${meta.color}`}>
<Icon className="h-3 w-3" />
{meta.label}
<button
type="button"
onClick={() => handleRevoke(s.id, r, s.email)}
className="hover:opacity-60 ml-1"
title="Quitar"
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
</td>
<td className="py-3 text-right">
<Button size="sm" variant="outline" onClick={() => { setPickedUserId(s.id); setSearchQ(s.email); setAddOpen(true); }}>
Agregar rol
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Shield className="h-4 w-4" />
Descripción de roles
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<div key={r} className={`rounded border p-3 ${m.color}`}>
<div className="flex items-center gap-2 font-medium mb-1">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs opacity-80">{m.desc}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Add staff dialog */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar rol de staff</DialogTitle>
<DialogDescription>
Busca al usuario por email o nombre y asígnale un rol de plataforma.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Buscar usuario</Label>
<div className="relative">
<Search className="h-4 w-4 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQ}
onChange={e => { setSearchQ(e.target.value); setPickedUserId(null); }}
placeholder="email o nombre (min 2 caracteres)"
className="pl-8"
/>
</div>
{searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && (
<div className="border rounded mt-1 max-h-40 overflow-auto">
{candidates.map(c => (
<button
type="button"
key={c.id}
onClick={() => { setPickedUserId(c.id); setSearchQ(c.email); }}
className="w-full text-left px-3 py-2 hover:bg-muted text-sm border-b last:border-0"
>
<div className="font-medium">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.email} · {c.tenant?.rfc || '—'}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Rol a asignar</Label>
<div className="grid gap-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<button
type="button"
key={r}
onClick={() => setPickedRole(r)}
className={`text-left rounded border-2 p-3 transition-all ${pickedRole === r ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}
>
<div className="flex items-center gap-2 font-medium text-sm mb-0.5">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs text-muted-foreground">{m.desc}</p>
</button>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancelar</Button>
<Button onClick={handleGrant} disabled={!pickedUserId || grantRole.isPending}>
{grantRole.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Asignar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
import { cn } from '@horux/shared-ui';
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
// Horux 360 single-tenant; si llega un rol no mapeado (ej. uno nuevo
// agregado en BD sin tocar este archivo), `defaultRoleInfo` previene
// runtime errors al hacer `roleInfo.icon`.
const roleLabels: Record<string, { label: string; icon: typeof Shield; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Briefcase, color: 'text-indigo-600' },
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-blue-600' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-cyan-600' },
cliente: { label: 'Cliente', icon: User, color: 'text-amber-600' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const defaultRoleInfo = { label: 'Sin rol', icon: User, color: 'text-muted-foreground' };
interface EditingUser {
id: string;
nombre: string;
role: 'owner' | 'contador' | 'visor';
tenantId: string;
}
export default function AdminUsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading, error } = useAllUsuarios();
const updateUsuario = useUpdateUsuarioGlobal();
const deleteUsuario = useDeleteUsuarioGlobal();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
getTenants().then(setTenants).catch(console.error);
}, []);
const handleEdit = (usuario: any) => {
setEditingUser({
id: usuario.id,
nombre: usuario.nombre,
role: usuario.role,
tenantId: usuario.tenantId,
});
};
const handleSave = async () => {
if (!editingUser) return;
try {
await updateUsuario.mutateAsync({
id: editingUser.id,
data: {
nombre: editingUser.nombre,
role: editingUser.role,
tenantId: editingUser.tenantId,
},
});
setEditingUser(null);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al actualizar usuario');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Estas seguro de eliminar este usuario?')) return;
try {
await deleteUsuario.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al eliminar usuario');
}
};
const filteredUsuarios = usuarios?.filter(u => {
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
const matchesSearch = !searchTerm ||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
return matchesTenant && matchesSearch;
});
// Agrupar por empresa
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
const key = u.tenantId || 'sin-empresa';
if (!acc[key]) {
acc[key] = {
tenantName: u.tenantName || 'Sin empresa',
users: [],
};
}
acc[key].users.push(u);
return acc;
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
if (error) {
return (
<DashboardShell title="Administracion de Usuarios">
<Card>
<CardContent className="py-8 text-center">
<p className="text-destructive">
No tienes permisos para ver esta pagina o ocurrio un error.
</p>
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell title="Administracion de Usuarios">
<div className="space-y-4">
{/* Filtros */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-[250px]">
<Select value={filterTenant} onValueChange={setFilterTenant}>
<SelectTrigger>
<SelectValue placeholder="Filtrar por empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las empresas</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" />
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
</div>
</div>
{/* Users by tenant */}
{isLoading ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Cargando usuarios...
</CardContent>
</Card>
) : (
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
<Card key={tenantId}>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
{tenantName}
<span className="text-muted-foreground font-normal text-sm">
({users?.length} usuarios)
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{users?.map(usuario => {
const roleInfo = roleLabels[usuario.role] || defaultRoleInfo;
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
const isEditing = editingUser?.id === usuario.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<Input
value={editingUser.nombre}
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
className="h-8"
/>
<div className="flex gap-2">
<Select
value={editingUser.role}
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Dueño</SelectItem>
<SelectItem value="contador">Contador</SelectItem>
<SelectItem value="visor">Visor</SelectItem>
</SelectContent>
</Select>
<Select
value={editingUser.tenantId}
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
>
<SelectTrigger className="h-8 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
{!isEditing && (
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
)}
{!isCurrentUser && (
<div className="flex gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={updateUsuario.isPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingUser(null)}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(usuario)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function CancelacionesPeriodoAnteriorPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery({
queryKey: ['drilldown-cancelaciones-periodo-anterior', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/cancelaciones-periodo-anterior?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cancelaciones-periodo-anterior');
};
return (
<DashboardShell title="Cancelaciones de Periodo Anterior">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados de periodos anteriores</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">
{data.length} CFDIs emitidos en meses anteriores y cancelados este mes
</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function CancelacionesPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['drilldown-cancelaciones'],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/cancelaciones');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-cancelados');
};
return (
<DashboardShell title="CFDIs Cancelados (Ultimos 5 años)">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">{data.length} CFDIs cancelados</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { useTableSort } from '@horux/shared-ui';
export default function ConcentracionClientesPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-concentracion-clientes'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-clientes');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
data,
{
cfdis: (d) => Number(d.cantidad || 0),
total: (d) => Number(d.total || 0),
},
'total',
);
return (
<DashboardShell title="Concentracion de Clientes">
<Card>
<CardHeader>
<CardTitle className="text-base">Participacion por Cliente (Facturas Emitidas)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium text-right">Participacion</th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(d.participacion, 100)}%` }}
/>
</div>
<span className="font-medium w-14 text-right">{d.participacion}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { useTableSort } from '@horux/shared-ui';
export default function ConcentracionProveedoresPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-concentracion-proveedores'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-proveedores');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
data,
{
cfdis: (d) => Number(d.cantidad || 0),
total: (d) => Number(d.total || 0),
},
'total',
);
return (
<DashboardShell title="Concentracion de Proveedores">
<Card>
<CardHeader>
<CardTitle className="text-base">Participacion por Proveedor (Facturas Recibidas)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium text-right">Participacion</th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(d.participacion, 100)}%` }}
/>
</div>
<span className="font-medium w-14 text-right">{d.participacion}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,344 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const TIPO_ALERTA = 'discrepancia-regimen';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 18 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
}));
}
export default function DiscrepanciaRegimenPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [view, setView] = useState<'activos' | 'descartados'>('activos');
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
// Filters
const [fechaDesde, setFechaDesde] = useState('');
const [fechaHasta, setFechaHasta] = useState('');
const [regimenFilter, setRegimenFilter] = useState<string>('');
// Activos (lo que aparece en la alerta)
const activosQ = useQuery({
queryKey: ['drilldown-discrepancia', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/discrepancia-regimen?${params}`);
return res.data;
},
enabled: view === 'activos',
});
// Descartados (lo que ya se marcó para ignorar)
const descartadosQ = useQuery({
queryKey: ['descartados-discrepancia', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
return res.data.data;
},
enabled: view === 'descartados',
});
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
// Extract unique regímenes for the filter dropdown
const regimenesUnicos = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
data.forEach((c: any) => {
const reg = c.regimenReceptor || c.regimenFiscalReceptor;
if (reg) set.add(reg);
});
return [...set].sort();
}, [data]);
// Apply filters: fecha + regimen (descartados already excluded by backend)
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) {
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
}
if (fechaHasta) {
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
}
if (regimenFilter) {
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
}
return filtered;
}, [data, fechaDesde, fechaHasta, regimenFilter]);
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-discrepancia-regimen');
};
const toggleCheck = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!sortedData) return;
if (checked.size === sortedData.length) {
setChecked(new Set());
} else {
setChecked(new Set(sortedData.map(c => String(c.id))));
}
};
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['drilldown-discrepancia'] });
queryClient.invalidateQueries({ queryKey: ['descartados-discrepancia'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
};
const handleDescartar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al descartar');
}
};
const handleRestaurar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al restaurar');
}
};
const handleChangeView = (next: 'activos' | 'descartados') => {
setView(next);
setChecked(new Set());
};
const handleClearFilters = () => {
setFechaDesde('');
setFechaHasta('');
setRegimenFilter('');
};
const hasActiveFilters = fechaDesde || fechaHasta || regimenFilter;
const allChecked = sortedData && sortedData.length > 0 &&
checked.size === sortedData.length;
return (
<DashboardShell title="CFDIs con Discrepancia de Régimen">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{view === 'activos'
? 'Facturas recibidas con régimen fiscal que no coincide con los regímenes activos'
: 'CFDIs descartados manualmente — ignorados en la alerta'}
</CardTitle>
<div className="flex items-center gap-2">
{/* Toggle Activos / Descartados */}
<div className="flex rounded-md border bg-background p-0.5 text-sm">
<button
type="button"
onClick={() => handleChangeView('activos')}
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Activos
</button>
<button
type="button"
onClick={() => handleChangeView('descartados')}
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Descartados
</button>
</div>
{checked.size > 0 && view === 'activos' && (
<Button variant="outline" size="sm" onClick={handleDescartar}>
<EyeOff className="h-4 w-4 mr-1" />
Descartar ({checked.size})
</Button>
)}
{checked.size > 0 && view === 'descartados' && (
<Button variant="outline" size="sm" onClick={handleRestaurar}>
<RotateCcw className="h-4 w-4 mr-1" />
Restaurar ({checked.size})
</Button>
)}
{data && data.length > 0 && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
)}
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
Filtros:
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input
type="date"
value={fechaDesde}
onChange={e => setFechaDesde(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input
type="date"
value={fechaHasta}
onChange={e => setFechaHasta(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Régimen</label>
<select
value={regimenFilter}
onChange={e => setRegimenFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todos</option>
{regimenesUnicos.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
Limpiar
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !sortedData || sortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{hasActiveFilters
? 'No hay resultados con los filtros seleccionados'
: view === 'activos'
? 'No hay discrepancias nuevas'
: 'No hay CFDIs descartados'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 w-8">
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</th>
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">Régimen Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{sortedData.map((cfdi: any) => (
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
<td className="py-3">
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'con discrepancia' : 'descartados'}
{hasActiveFilters && data && ` (de ${data.length} total)`}
</p>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Forma Pago', key: 'formaPago', width: 12 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function EfectivoPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['drilldown-efectivo'],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/efectivo');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-pago-efectivo');
};
return (
<DashboardShell title="CFDIs con Pago en Efectivo">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs con pago en efectivo</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">{data.length} CFDIs con pago en efectivo</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
export default function ListaNegraClientesPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-lista-negra-clientes'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-clientes');
return res.data;
},
});
return (
<DashboardShell title="Clientes en Lista Negra del SAT">
<Card>
<CardHeader>
<CardTitle className="text-base">Clientes a los que has facturado que aparecen en la lista del Art. 69-B</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">Ningun cliente en lista negra</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Situacion SAT</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Total Facturado</th>
</tr>
</thead>
<tbody>
{data.map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
}`}>{d.situacionSat}</span>
</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
export default function ListaNegraProveedoresPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-lista-negra-proveedores'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-proveedores');
return res.data;
},
});
return (
<DashboardShell title="Proveedores en Lista Negra del SAT">
<Card>
<CardHeader>
<CardTitle className="text-base">Proveedores de los que has recibido facturas que aparecen en la lista del Art. 69-B</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">Ningun proveedor en lista negra</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Situacion SAT</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Total Facturado</th>
</tr>
</thead>
<tbody>
{data.map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
}`}>{d.situacionSat}</span>
</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas';
import { apiClient } from '@/lib/api/client';
import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle, ShieldAlert, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
interface AlertaAuto {
id: string;
tipo: string;
titulo: string;
mensaje: string;
prioridad: 'alta' | 'media' | 'baja';
detalle?: string;
valor?: number;
}
const prioridadStyles = {
alta: 'border-l-4 border-l-destructive bg-destructive/5',
media: 'border-l-4 border-l-warning bg-warning/5',
baja: 'border-l-4 border-l-muted bg-muted/5',
};
const prioridadIcons = {
alta: AlertCircle,
media: AlertTriangle,
baja: Info,
};
export default function AlertasPage() {
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes');
const { data: alertas, isLoading } = useAlertas({
resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined,
});
const { data: stats } = useAlertasStats();
const updateAlerta = useUpdateAlerta();
const deleteAlerta = useDeleteAlerta();
const markAllAsRead = useMarkAllAsRead();
const router = useRouter();
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const { data: alertasAuto } = useQuery({
queryKey: ['alertas-automaticas', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<AlertaAuto[]>(`/alertas/automaticas?${params}`);
return res.data;
},
});
const { data: alertasManuales } = useQuery({
queryKey: ['alertas-manuales', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<any[]>(`/alertas/manuales?${params}`);
return res.data;
},
});
const handleResolver = async (id: string) => {
await apiClient.patch(`/alertas/manuales/${id}/resolver`);
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
};
const handleMarkAsRead = (id: number) => {
updateAlerta.mutate({ id, data: { leida: true } });
};
const handleResolve = (id: number) => {
updateAlerta.mutate({ id, data: { resuelta: true } });
};
const handleDelete = (id: number) => {
if (confirm('¿Eliminar esta alerta?')) {
deleteAlerta.mutate(id);
}
};
return (
<DashboardShell title="Alertas">
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Alertas del Sistema</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{alertasAuto?.length || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Obligaciones Pendientes</CardTitle>
<Clock className="h-4 w-4 text-warning" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-warning">{alertasManuales?.length || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Alertas</CardTitle>
<Bell className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{(alertasAuto?.length || 0) + (alertasManuales?.length || 0)}
</div>
</CardContent>
</Card>
</div>
{/* Alertas Automáticas */}
{alertasAuto && alertasAuto.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlert className="h-4 w-4" />
Alertas del Sistema ({alertasAuto.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{alertasAuto.map((alerta) => {
const Icon = alerta.prioridad === 'alta' ? AlertCircle : AlertTriangle;
return (
<div
key={alerta.id}
className={cn(
'p-3 rounded-lg border',
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
alerta.prioridad === 'baja' && 'border-l-4 border-l-muted bg-muted/5',
)}
>
<div className="flex items-start gap-3">
<Icon className={cn(
'h-5 w-5 mt-0.5 flex-shrink-0',
alerta.prioridad === 'alta' && 'text-destructive',
alerta.prioridad === 'media' && 'text-warning',
)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
</div>
</div>
{alerta.detalle && (
<div className="mt-2 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => router.push(alerta.detalle!)}
>
Ver detalle <ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
)}
{/* Obligaciones Fiscales Pendientes */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Obligaciones Fiscales Pendientes ({alertasManuales?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{!alertasManuales || alertasManuales.length === 0 ? (
<div className="py-6 text-center text-muted-foreground">
<CheckCircle className="h-10 w-10 mx-auto mb-3 text-success" />
<p className="text-sm">Todas las obligaciones fiscales estan al dia</p>
</div>
) : (
<div className="space-y-2">
{alertasManuales.map((alerta: any) => {
const esPago = alerta.tipo.startsWith('pago-');
const Icon = prioridadIcons[alerta.prioridad as keyof typeof prioridadIcons] || AlertTriangle;
return (
<div
key={alerta.id}
className={cn(
'p-3 rounded-lg border',
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
)}
>
<div className="flex items-start gap-3">
<Icon className={cn(
'h-5 w-5 mt-0.5 flex-shrink-0',
alerta.prioridad === 'alta' && 'text-destructive',
alerta.prioridad === 'media' && 'text-warning',
)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
</div>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Vencio: {(() => {
const d = new Date(alerta.fechaVencimiento);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' });
})()}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleResolver(alerta.id)}
>
<Check className="h-3 w-3 mr-1" />
{esPago ? 'Marcar como pagado' : 'Marcar como presentada'}
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,341 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const TIPO_ALERTA = 'tipo-relacion-sospechosa';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'TipoRelacion', key: 'cfdiTipoRelacion', width: 14 },
{ header: 'CFDIs Relacionados', key: 'cfdisRelacionados', width: 50 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function TipoRelacionSospechosaPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [view, setView] = useState<'activos' | 'descartados'>('activos');
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const [fechaDesde, setFechaDesde] = useState('');
const [fechaHasta, setFechaHasta] = useState('');
const [tipoRelFilter, setTipoRelFilter] = useState<string>('');
const activosQ = useQuery({
queryKey: ['drilldown-tipo-relacion-sospechosa', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/${TIPO_ALERTA}?${params}`);
return res.data;
},
enabled: view === 'activos',
});
const descartadosQ = useQuery({
queryKey: ['descartados-tipo-relacion-sospechosa', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
return res.data.data;
},
enabled: view === 'descartados',
});
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
const tiposRelUnicos = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
data.forEach((c: any) => {
if (c.cfdiTipoRelacion) set.add(c.cfdiTipoRelacion);
});
return [...set].sort();
}, [data]);
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
return filtered;
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-tipo-relacion-sospechosa');
};
const toggleCheck = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!sortedData) return;
if (checked.size === sortedData.length) {
setChecked(new Set());
} else {
setChecked(new Set(sortedData.map(c => String(c.id))));
}
};
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['drilldown-tipo-relacion-sospechosa'] });
queryClient.invalidateQueries({ queryKey: ['descartados-tipo-relacion-sospechosa'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
};
const handleDescartar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al descartar');
}
};
const handleRestaurar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al restaurar');
}
};
const handleChangeView = (next: 'activos' | 'descartados') => {
setView(next);
setChecked(new Set());
};
const handleClearFilters = () => {
setFechaDesde('');
setFechaHasta('');
setTipoRelFilter('');
};
const hasActiveFilters = fechaDesde || fechaHasta || tipoRelFilter;
const allChecked = sortedData && sortedData.length > 0 && checked.size === sortedData.length;
return (
<DashboardShell title="CFDI con Tipo de Relación sospechoso">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{view === 'activos'
? 'Notas de crédito (E) que referencian un CFDI tratado como anticipo por otra factura — posible error de emisor (debería ser TipoRelacion 07)'
: 'CFDIs descartados manualmente — ignorados en la alerta'}
</CardTitle>
<div className="flex items-center gap-2">
<div className="flex rounded-md border bg-background p-0.5 text-sm">
<button
type="button"
onClick={() => handleChangeView('activos')}
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Activos
</button>
<button
type="button"
onClick={() => handleChangeView('descartados')}
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Descartados
</button>
</div>
{checked.size > 0 && view === 'activos' && (
<Button variant="outline" size="sm" onClick={handleDescartar}>
<EyeOff className="h-4 w-4 mr-1" />
Descartar ({checked.size})
</Button>
)}
{checked.size > 0 && view === 'descartados' && (
<Button variant="outline" size="sm" onClick={handleRestaurar}>
<RotateCcw className="h-4 w-4 mr-1" />
Restaurar ({checked.size})
</Button>
)}
{data && data.length > 0 && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
Filtros:
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input
type="date"
value={fechaDesde}
onChange={e => setFechaDesde(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input
type="date"
value={fechaHasta}
onChange={e => setFechaHasta(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">TipoRelacion</label>
<select
value={tipoRelFilter}
onChange={e => setTipoRelFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todos</option>
{tiposRelUnicos.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
Limpiar
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !sortedData || sortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{hasActiveFilters
? 'No hay resultados con los filtros seleccionados'
: view === 'activos'
? 'No hay CFDIs sospechosos'
: 'No hay CFDIs descartados'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 w-8">
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</th>
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">Emisor</th>
<th className="pb-3 font-medium">Receptor</th>
<th className="pb-3 font-medium">TipoRel</th>
<th className="pb-3 font-medium">Referenciados</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{sortedData.map((cfdi: any) => {
const refs = (cfdi.cfdisRelacionados || '').split('|').filter(Boolean);
return (
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
<td className="py-3">
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>
</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcReceptor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreReceptor}</div>
</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.cfdiTipoRelacion}</td>
<td className="py-3 font-mono text-xs">
{refs.map((u: string) => (
<div key={u}>{u.substring(0, 8)}</div>
))}
</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'sospechosos' : 'descartados'}
{hasActiveFilters && data && ` (de ${data.length} total)`}
</p>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
import { useAuthStore } from '@/stores/auth-store';
import {
Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText,
CreditCard, Plus, X, Pencil, Trash2, Lock, Globe, AlertTriangle,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import type { EventoFiscal } from '@horux/shared';
const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const tipoIcons: Record<string, any> = {
declaracion: FileText,
pago: CreditCard,
obligacion: Clock,
informativa: FileText,
custom: Calendar,
'obligacion-pendiente': Clock,
'obligacion-completada': Check,
'obligacion-atrasada': AlertTriangle,
};
const tipoColors: Record<string, string> = {
declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
informativa: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
custom: 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200',
'obligacion-pendiente': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
'obligacion-completada': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'obligacion-atrasada': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
interface RecordatorioForm {
titulo: string;
descripcion: string;
fechaLimite: string;
notas: string;
privado: boolean;
}
const emptyForm: RecordatorioForm = {
titulo: '',
descripcion: '',
fechaLimite: '',
notas: '',
privado: false,
};
export default function CalendarioPage() {
const [año, setAño] = useState(new Date().getFullYear());
const [mes, setMes] = useState(new Date().getMonth() + 1);
const { data: eventos, isLoading } = useEventos(año);
const createEvento = useCreateEvento();
const updateEvento = useUpdateEvento();
const deleteEvento = useDeleteEvento();
const { user } = useAuthStore();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<RecordatorioForm>(emptyForm);
const canEdit = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(user?.role || '');
const handlePrevMonth = () => {
if (mes === 1) { setMes(12); setAño(año - 1); }
else setMes(mes - 1);
};
const handleNextMonth = () => {
if (mes === 12) { setMes(1); setAño(año + 1); }
else setMes(mes + 1);
};
const handleToggleComplete = (evento: EventoFiscal) => {
if (!evento.id) return;
if (evento.tipo === 'custom') {
updateEvento.mutate({ id: evento.id, data: { completado: !evento.completado } });
}
};
const handleOpenCreate = () => {
setEditingId(null);
const defaultDate = `${año}-${String(mes).padStart(2, '0')}-15`;
setForm({ ...emptyForm, fechaLimite: defaultDate });
setShowForm(true);
};
const handleOpenEdit = (evento: EventoFiscal) => {
if (!evento.id || evento.tipo !== 'custom') return;
setEditingId(evento.id);
setForm({
titulo: evento.titulo,
descripcion: evento.descripcion || '',
fechaLimite: evento.fechaLimite,
notas: evento.notas || '',
privado: (evento as any).privado ?? false,
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingId) {
await updateEvento.mutateAsync({
id: editingId,
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
});
} else {
await createEvento.mutateAsync({
titulo: form.titulo,
descripcion: form.descripcion,
tipo: 'custom',
fechaLimite: form.fechaLimite,
recurrencia: 'unica',
notas: form.notas,
privado: form.privado,
} as any);
}
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
} catch {
alert('Error al guardar recordatorio');
}
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este recordatorio?')) return;
try {
await deleteEvento.mutateAsync(id);
} catch {
alert('Error al eliminar');
}
};
const handleCancelForm = () => {
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
};
// Generate calendar days
const firstDay = new Date(año, mes - 1, 1).getDay();
const daysInMonth = new Date(año, mes, 0).getDate();
const days = Array.from({ length: 42 }, (_, i) => {
const day = i - firstDay + 1;
if (day < 1 || day > daysInMonth) return null;
return day;
});
const getEventosForDay = (day: number) => {
return eventos?.filter(e => {
const fecha = new Date(e.fechaLimite + 'T00:00:00');
return fecha.getFullYear() === año && fecha.getMonth() + 1 === mes && fecha.getDate() === day;
}) || [];
};
const eventosDelMes = eventos?.filter(e => {
const f = new Date(e.fechaLimite + 'T00:00:00');
return f.getFullYear() === año && f.getMonth() + 1 === mes;
}) || [];
return (
<DashboardShell title="Calendario Fiscal">
{/* Modal de crear/editar */}
{showForm && (
<Card className="mb-4">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{editingId ? 'Editar Recordatorio' : 'Nuevo Recordatorio'}
</CardTitle>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="titulo">Título</Label>
<Input
id="titulo"
value={form.titulo}
onChange={e => setForm({ ...form, titulo: e.target.value })}
placeholder="Reunión con contador"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="fechaLimite">Fecha</Label>
<Input
id="fechaLimite"
type="date"
value={form.fechaLimite}
onChange={e => setForm({ ...form, fechaLimite: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripción (opcional)</Label>
<Input
id="descripcion"
value={form.descripcion}
onChange={e => setForm({ ...form, descripcion: e.target.value })}
placeholder="Revisión de declaración mensual"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notas">Notas (opcional)</Label>
<Input
id="notas"
value={form.notas}
onChange={e => setForm({ ...form, notas: e.target.value })}
placeholder="Llevar estados de cuenta"
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setForm({ ...form, privado: !form.privado })}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
form.privado
? 'border-orange-300 bg-orange-50 text-orange-700 dark:border-orange-700 dark:bg-orange-950 dark:text-orange-300'
: 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-300'
)}
>
{form.privado ? <Lock className="h-4 w-4" /> : <Globe className="h-4 w-4" />}
{form.privado ? 'Privado' : 'Público'}
</button>
<span className="text-xs text-muted-foreground">
{form.privado ? 'Solo tú puedes verlo' : 'Visible para todo el equipo'}
</span>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createEvento.isPending || updateEvento.isPending}>
{editingId
? (updateEvento.isPending ? 'Guardando...' : 'Guardar')
: (createEvento.isPending ? 'Creando...' : 'Crear Recordatorio')}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-3">
{/* Calendar */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{meses[mes - 1]} {año}
</CardTitle>
<div className="flex gap-1">
{canEdit && !showForm && (
<Button variant="outline" size="sm" onClick={handleOpenCreate} className="mr-2">
<Plus className="h-4 w-4 mr-1" />
Recordatorio
</Button>
)}
<Button variant="outline" size="icon" onClick={handlePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Leyenda de colores por estado de obligación */}
<div className="flex items-center gap-3 flex-wrap text-xs text-muted-foreground mb-3 pb-2 border-b">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-amber-400" />
Pendiente
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-green-500" />
Completada
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-red-500" />
Atrasada
</span>
<span className="inline-flex items-center gap-1.5 ml-2">
<span className="h-2.5 w-2.5 rounded bg-violet-400" />
Recordatorio custom
</span>
</div>
<div className="grid grid-cols-7 gap-1">
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => (
<div key={d} className="text-center text-sm font-medium text-muted-foreground py-2">
{d}
</div>
))}
{days.map((day, i) => {
const dayEventos = day ? getEventosForDay(day) : [];
const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear();
return (
<div
key={i}
className={cn(
'min-h-[80px] p-1 border rounded-md',
day ? 'bg-background' : 'bg-muted/30',
isToday && 'ring-2 ring-primary'
)}
>
{day && (
<>
<div className={cn('text-sm font-medium', isToday && 'text-primary')}>{day}</div>
<div className="space-y-1 mt-1">
{dayEventos.slice(0, 2).map((e, idx) => {
const Icon = tipoIcons[e.tipo] || Calendar;
return (
<div
key={`${e.id}-${idx}`}
className={cn(
'text-xs px-1 py-0.5 rounded truncate flex items-center gap-1',
tipoColors[e.tipo],
e.completado && 'opacity-50 line-through',
e.tipo === 'custom' && canEdit && 'cursor-pointer'
)}
title={e.titulo}
onClick={() => e.tipo === 'custom' && canEdit && handleOpenEdit(e)}
>
<Icon className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{e.titulo}</span>
</div>
);
})}
{dayEventos.length > 2 && (
<div className="text-xs text-muted-foreground">+{dayEventos.length - 2} más</div>
)}
</div>
</>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Event List */}
<Card>
<CardHeader>
<CardTitle>Eventos del Mes</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-4 text-muted-foreground">Cargando...</div>
) : eventosDelMes.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
) : (
<div className="space-y-3">
{eventosDelMes.map((evento, idx) => {
const Icon = tipoIcons[evento.tipo] || FileText;
const isCustom = evento.tipo === 'custom';
return (
<div
key={`${evento.fechaLimite}-${evento.titulo}-${idx}`}
className={cn(
'p-3 rounded-lg border',
evento.completado && 'opacity-50'
)}
>
<div className="flex items-start gap-2">
<div className={cn('p-1.5 rounded', tipoColors[evento.tipo] || 'bg-muted')}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h4 className={cn('font-medium text-sm truncate', evento.completado && 'line-through')}>
{evento.titulo}
</h4>
{isCustom && (evento as any).privado && (
<Lock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
{evento.descripcion && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{evento.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
})}
</p>
</div>
{isCustom && canEdit && (
<div className="flex gap-1 flex-shrink-0">
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleComplete(evento)}
title={evento.completado ? 'Marcar pendiente' : 'Marcar completado'}
>
<Check className={cn('h-3.5 w-3.5', evento.completado && 'text-green-600')} />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleOpenEdit(evento)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => evento.id && handleDelete(evento.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,539 @@
'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
cn,
} from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query';
import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog,
} from 'lucide-react';
import {
useCarteras, useCreateCartera, useDeleteCartera,
useCarteraEntidades, useSubcarteras, useCreateSubcartera,
useSupervisores,
} from '@/lib/hooks/use-carteras';
import {
addEntidadToCartera, removeEntidadFromCartera,
} from '@/lib/api/carteras';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import type { Cartera } from '@/lib/api/carteras';
/* ------------------------------------------------------------------ */
/* SubcarteraCard */
/* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
sub: Cartera;
usuarios: any[];
contribuyentes: any[];
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const qc = useQueryClient();
const { data: entidadIds, isLoading } = useCarteraEntidades(expanded ? sub.id : null);
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c: any) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id)
);
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', sub.id] });
qc.invalidateQueries({ queryKey: ['subcarteras'] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(sub.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(sub.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
return (
<div className="border rounded-lg p-3 bg-muted/20">
<div className="flex items-center justify-between">
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
<UserCog className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium text-sm">{sub.nombre}</span>
{auxiliarUser && (
<span className="text-xs text-muted-foreground ml-2">({auxiliarUser.nombre})</span>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto mr-2">{sub.entidadesCount} RFCs</span>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<button onClick={onDelete} className="text-muted-foreground hover:text-destructive p-1">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
{!addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Asignar RFC
</Button>
)}
{addingEntidad && (
<div className="flex items-center gap-2">
<select className="flex-1 rounded-md border bg-background px-2 py-1 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map((c: any) => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin RFCs asignados a esta subcartera.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between bg-background rounded px-2 py-1 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground">{info.nombre}</span></> : id}</span>
<button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive"><X className="h-3 w-3" /></button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraDetail */
/* ------------------------------------------------------------------ */
function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }: { cartera: Cartera; canEdit?: boolean; canManageSubcarteras?: boolean }) {
const qc = useQueryClient();
const { data: contribuyentes } = useContribuyentes();
const { data: usuarios } = useUsuarios();
const { data: entidadIds, isLoading: loadingEntidades } = useCarteraEntidades(cartera.id);
const { data: subcarteras, isLoading: loadingSubs } = useSubcarteras(cartera.id);
const createSub = useCreateSubcartera();
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [showCreateSub, setShowCreateSub] = useState(false);
const [subForm, setSubForm] = useState({ nombre: '', auxiliarUserId: '' });
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c) => !(entidadIds ?? []).includes(c.id)
);
// Auxiliares available for subcarteras (those assigned to this supervisor)
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', cartera.id] });
qc.invalidateQueries({ queryKey: ['subcarteras', cartera.id] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(cartera.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(cartera.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
const handleCreateSubcartera = async () => {
if (!subForm.nombre.trim() || !subForm.auxiliarUserId) return;
try {
await createSub.mutateAsync({
carteraId: cartera.id,
nombre: subForm.nombre.trim(),
auxiliarUserId: subForm.auxiliarUserId,
});
setSubForm({ nombre: '', auxiliarUserId: '' });
setShowCreateSub(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear subcartera');
}
};
const handleDeleteSubcartera = async (subId: string) => {
if (!confirm('¿Eliminar esta subcartera?')) return;
try {
const { deleteCartera } = await import('@/lib/api/carteras');
await deleteCartera(subId);
invalidate();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<div className="border-t mt-4 pt-4 space-y-6">
{/* Supervisor info */}
{supervisorUser && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<UserCog className="h-3.5 w-3.5" />
Supervisor: <span className="font-medium text-foreground">{supervisorUser.nombre}</span> ({supervisorUser.email})
</div>
)}
{/* ---- Contribuyentes ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-muted-foreground" />
Contribuyentes ({entidadIds?.length || 0})
</h3>
{canEdit && !addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Agregar
</Button>
)}
</div>
{canEdit && addingEntidad && (
<div className="flex items-center gap-2 mb-3">
<select className="flex-1 rounded-md border bg-background px-3 py-1.5 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map(c => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{loadingEntidades ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin contribuyentes asignados.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-1.5 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground ml-2">{info.nombre}</span></> : <span className="font-mono text-xs">{id}</span>}</span>
{canEdit && <button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive ml-2"><X className="h-3.5 w-3.5" /></button>}
</li>
);
})}
</ul>
)}
</div>
{/* ---- Subcarteras ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
Subcarteras ({subcarteras?.length || 0})
</h3>
{canManageSubcarteras && !showCreateSub && (
<Button variant="ghost" size="sm" onClick={() => setShowCreateSub(true)} className="h-7 gap-1 text-xs">
<FolderPlus className="h-3 w-3" /> Nueva subcartera
</Button>
)}
</div>
{canManageSubcarteras && showCreateSub && (
<div className="border rounded-lg p-3 mb-3 space-y-3 bg-muted/20">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Nombre</Label>
<Input value={subForm.nombre} onChange={e => setSubForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Cartera de María" className="h-8 text-sm mt-1" />
</div>
<div>
<Label className="text-xs">Auxiliar</Label>
<select className="w-full h-8 rounded-md border bg-background px-2 text-sm mt-1" value={subForm.auxiliarUserId} onChange={e => setSubForm(p => ({ ...p, auxiliarUserId: e.target.value }))}>
<option value="">-- Seleccionar auxiliar --</option>
{auxiliares.map((u: any) => <option key={u.id} value={u.id}>{u.nombre} ({u.email})</option>)}
</select>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleCreateSubcartera} disabled={!subForm.nombre.trim() || !subForm.auxiliarUserId || createSub.isPending}>Crear</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowCreateSub(false); setSubForm({ nombre: '', auxiliarUserId: '' }); }}>Cancelar</Button>
</div>
</div>
)}
{loadingSubs ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !subcarteras || subcarteras.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin subcarteras. Crea una para asignar RFCs a un auxiliar.</p>
) : (
<div className="space-y-2">
{subcarteras.map(sub => (
<SubcarteraCard
key={sub.id}
sub={sub}
usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraCard */
/* ------------------------------------------------------------------ */
function CarteraCard({ cartera, expanded, onToggle, onDelete, usuarios, canEdit, canManageSubcarteras }: {
cartera: Cartera;
expanded: boolean;
onToggle: () => void;
onDelete: () => void;
usuarios: any[];
canEdit: boolean;
canManageSubcarteras: boolean;
}) {
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
return (
<Card className={cn('transition-shadow', expanded && 'ring-1 ring-primary/30 shadow-md')}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<button onClick={onToggle} className="flex-1 text-left flex items-center gap-2 group">
<FolderOpen className={cn('h-5 w-5 flex-shrink-0 transition-colors', expanded ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground')} />
<div>
<CardTitle className="text-base">{cartera.nombre}</CardTitle>
{cartera.descripcion && <p className="text-xs text-muted-foreground mt-0.5">{cartera.descripcion}</p>}
</div>
{expanded ? <ChevronUp className="h-4 w-4 text-muted-foreground ml-auto" /> : <ChevronDown className="h-4 w-4 text-muted-foreground ml-auto" />}
</button>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onDelete} className="text-destructive hover:text-destructive flex-shrink-0 h-8 w-8 p-0">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-4 mt-1 pl-7">
{supervisorUser && (
<span className="text-xs text-muted-foreground">
<UserCog className="inline h-3 w-3 mr-1" />
{supervisorUser.nombre}
</span>
)}
<span className="text-xs text-muted-foreground">
<Building2 className="inline h-3 w-3 mr-1" />
{cartera.entidadesCount} RFCs
</span>
<span className="text-xs text-muted-foreground">
<Users className="inline h-3 w-3 mr-1" />
{cartera.subcarterasCount} subcarteras
</span>
</div>
</CardHeader>
{expanded && (
<CardContent className="pt-0">
<CarteraDetail cartera={cartera} canEdit={canEdit} canManageSubcarteras={canManageSubcarteras} />
</CardContent>
)}
</Card>
);
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function CarterasPage() {
const { user } = useAuthStore();
const userRole = user?.role || 'visor';
const canCreate = userRole === 'owner'; // Create top-level carteras
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
const isAuxiliar = userRole === 'auxiliar';
const { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios();
const createMut = useCreateCartera();
const deleteMut = useDeleteCartera();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ nombre: '', descripcion: '', supervisorUserId: '' });
const hasSupervisores = supervisores && supervisores.length > 0;
const resetForm = () => {
setForm({ nombre: '', descripcion: '', supervisorUserId: '' });
setShowCreate(false);
};
const handleCreate = async () => {
if (!form.nombre.trim()) return;
try {
const supervisorUserId = form.supervisorUserId && form.supervisorUserId !== '__self__'
? form.supervisorUserId : undefined;
const cartera = await createMut.mutateAsync({
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim() || undefined,
supervisorUserId,
});
resetForm();
setExpandedId(cartera.id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear cartera');
}
};
const handleDelete = async (cartera: Cartera) => {
if (!confirm(`¿Eliminar la cartera "${cartera.nombre}"? Se eliminarán también sus subcarteras.`)) return;
try {
await deleteMut.mutateAsync(cartera.id);
if (expandedId === cartera.id) setExpandedId(null);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar cartera');
}
};
return (
<DashboardShell title="Carteras">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p>
</div>
{canCreate && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera
</Button>
)}
</div>
{/* List */}
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nueva cartera</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Nombre *</Label>
<Input value={form.nombre} onChange={e => setForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Clientes CDMX" autoFocus />
</div>
<div>
<Label>Descripcion (opcional)</Label>
<Input value={form.descripcion} onChange={e => setForm(p => ({ ...p, descripcion: e.target.value }))} placeholder="Descripcion breve" />
</div>
{hasSupervisores ? (
<div>
<Label>Asignar a supervisor</Label>
<Select value={form.supervisorUserId} onValueChange={v => setForm(p => ({ ...p, supervisorUserId: v }))}>
<SelectTrigger>
<SelectValue placeholder="Yo mismo (Owner)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__self__">Yo mismo (Owner)</SelectItem>
{supervisores!.map(s => (
<SelectItem key={s.userId} value={s.userId}>{s.nombre} ({s.email})</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Si no seleccionas, la cartera se asigna a ti.</p>
</div>
) : (
<p className="text-xs text-muted-foreground border rounded-md p-3 bg-muted/30">
No hay supervisores registrados. La cartera se asignará a ti como owner.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button onClick={handleCreate} disabled={!form.nombre.trim() || createMut.isPending}>
{createMut.isPending ? 'Creando...' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
import type { Tenant } from '@/lib/api/tenants';
import { isGlobalAdminRfc } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
const PLAN_LABELS: Record<string, string> = {
trial: 'Prueba',
custom: 'Custom',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa +',
business_control: 'Business Control',
business_cloud: 'Enterprise',
};
type PlanType = 'trial' | 'custom' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
export default function ClientesPage() {
const { user } = useAuthStore();
const { data: tenants, isLoading } = useTenants();
const createTenant = useCreateTenant();
const updateTenant = useUpdateTenant();
const deleteTenant = useDeleteTenant();
const { setViewingTenant } = useTenantViewStore();
const router = useRouter();
const queryClient = useQueryClient();
// Periodo del KPI: default mes en curso. El admin puede ajustar para ver
// ingresos / no-renovaciones de otros rangos.
const today = new Date();
const defaultFrom = useMemo(() => `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`, []);
const defaultTo = useMemo(() => {
const last = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}`;
}, []);
const [from, setFrom] = useState(defaultFrom);
const [to, setTo] = useState(defaultTo);
const isGlobal = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
// `enabled: !!user` en lugar de `isGlobal` — evita timing donde
// platformRoles no haya llegado aún del store. El backend gatea por
// requireStaff así que un user sin permiso recibe 403 y react-query
// maneja el error sin crash. Lo importante es que el query CORRA al
// montar la página.
const { data: stats } = useQuery({
queryKey: ['admin-clientes-stats', from, to],
queryFn: () => getClientesStats(from, to),
enabled: !!user,
});
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
const usuariosPorTenant = useMemo(() => {
const m = new Map<string, number>();
(stats?.usuariosPorCliente ?? []).forEach(u => m.set(u.tenantId, u.activeUsers));
return m;
}, [stats]);
const [usuariosTenantId, setUsuariosTenantId] = useState<string | null>(null);
const [usuariosTenantNombre, setUsuariosTenantNombre] = useState<string>('');
const { data: usuariosDetalle, isLoading: isUsuariosLoading } = useQuery({
queryKey: ['admin-clientes-usuarios', usuariosTenantId],
queryFn: () => usuariosTenantId ? getTenantUsuarios(usuariosTenantId) : Promise.resolve([] as TenantUsuario[]),
enabled: !!usuariosTenantId,
});
const [showForm, setShowForm] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [formData, setFormData] = useState<{
nombre: string;
rfc: string;
plan: PlanType;
adminEmail: string;
adminNombre: string;
amount: number;
firstPaymentDueAt: string;
}>({
nombre: '',
rfc: '',
plan: 'trial',
adminEmail: '',
adminNombre: '',
amount: 0,
firstPaymentDueAt: '',
});
// Only global admin can access this page
if (!isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) {
return (
<>
<Header title="Clientes" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No tienes permisos para ver esta página.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTenant) {
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
setEditingTenant(null);
} else {
await createTenant.mutateAsync(formData);
}
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
setShowForm(false);
} catch (error) {
console.error('Error:', error);
}
};
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
adminEmail: '',
adminNombre: '',
amount: 0,
firstPaymentDueAt: '',
});
setShowForm(true);
};
const handleDelete = async (tenant: Tenant) => {
if (confirm(`¿Eliminar el cliente "${tenant.nombre}"? Esta acción desactivará el cliente.`)) {
try {
await deleteTenant.mutateAsync(tenant.id);
} catch (error) {
console.error('Error deleting tenant:', error);
}
}
};
const handleCancelForm = () => {
setShowForm(false);
setEditingTenant(null);
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
};
const handleViewClient = (tenantId: string, tenantName: string) => {
setViewingTenant(tenantId, tenantName);
queryClient.invalidateQueries();
router.push('/dashboard');
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// Reuse PLAN_LABELS global (declarado al top del archivo) que cubre todos
// los planes — legacy + despacho + custom. El planColors local se mantiene
// chico con un fallback genérico para planes nuevos.
const planColors: Record<string, string> = {
starter: 'bg-muted text-muted-foreground',
business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
mi_empresa: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
mi_empresa_plus: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
business_control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100',
business_cloud: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
custom: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-100',
};
return (
<>
<Header title="Gestión de Clientes" />
<main className="p-6 space-y-6">
{/* Selector de periodo + acción */}
<Card>
<CardContent className="pt-6 flex flex-col md:flex-row items-start md:items-center gap-4 justify-between">
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Periodo:</span>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="w-[150px]" />
<span className="text-muted-foreground">a</span>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="w-[150px]" />
</div>
</div>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Agregar Cliente
</Button>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Total clientes activos */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-lg">
<Building className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{tenants?.length || 0}</p>
<p className="text-sm text-muted-foreground">Clientes registrados</p>
</div>
</div>
</CardContent>
</Card>
{/* Suscripciones activas con breakdown por plan */}
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-100 dark:bg-emerald-900/30 rounded-lg">
<Users className="h-5 w-5 text-emerald-600" />
</div>
<div>
<p className="text-2xl font-bold">
{(stats?.suscripcionesPorPlan ?? []).reduce((s, p) => s + p.count, 0)}
</p>
<p className="text-sm text-muted-foreground">Suscripciones activas</p>
</div>
</div>
{stats && stats.suscripcionesPorPlan.length > 0 && (
<div className="pt-2 space-y-1 text-xs">
{stats.suscripcionesPorPlan.map(p => (
<div key={p.plan} className="flex justify-between">
<span className="text-muted-foreground">{PLAN_LABELS[p.plan] ?? p.plan}</span>
<span className="font-medium">{p.count}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ingresos del periodo */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<DollarSign className="h-6 w-6 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold">
${(stats?.ingresos.total ?? 0).toLocaleString('es-MX', { maximumFractionDigits: 0 })}
</p>
<p className="text-sm text-muted-foreground">
Ingresos del periodo · {stats?.ingresos.paymentsCount ?? 0} pagos
</p>
</div>
</div>
</CardContent>
</Card>
{/* No renovaciones */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-lg">
<AlertCircle className="h-6 w-6 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.noRenovaciones.length ?? 0}</p>
<p className="text-sm text-muted-foreground">No renovaron en el periodo</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Detalle de no renovaciones */}
{stats && stats.noRenovaciones.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
Clientes que no renovaron en el periodo
</CardTitle>
<CardDescription>
Suscripciones cuyo periodo terminó dentro del rango y quedaron en estado terminal
(cancelada, prueba expirada o pausada).
</CardDescription>
</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">Cliente</th>
<th className="py-2 pr-4">RFC</th>
<th className="py-2 pr-4">Plan</th>
<th className="py-2 pr-4">Vence</th>
<th className="py-2 pr-4">Estado</th>
</tr>
</thead>
<tbody>
{stats.noRenovaciones.map(nr => (
<tr key={nr.tenantId} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-2 pr-4 font-medium">{nr.tenantNombre}</td>
<td className="py-2 pr-4 font-mono text-xs">{nr.rfc}</td>
<td className="py-2 pr-4">{PLAN_LABELS[nr.plan] ?? nr.plan}</td>
<td className="py-2 pr-4 text-xs">
{nr.currentPeriodEnd ? new Date(nr.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}
</td>
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100">
{nr.statusActual}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Modal de usuarios por cliente */}
{usuariosTenantId && (
<div
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setUsuariosTenantId(null)}
>
<div
className="bg-background rounded-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b flex items-center justify-between">
<div>
<h3 className="font-semibold">Usuarios de {usuariosTenantNombre}</h3>
<p className="text-sm text-muted-foreground">
{usuariosDetalle?.length ?? 0} usuarios activos
</p>
</div>
<Button variant="ghost" size="icon" onClick={() => setUsuariosTenantId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-6">
{isUsuariosLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<div className="space-y-2">
{(usuariosDetalle ?? []).map(u => (
<div key={u.userId} className="flex items-center justify-between p-3 rounded border">
<div>
<p className="font-medium text-sm">{u.nombre}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</div>
<div className="flex items-center gap-2">
{u.isOwner && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">Owner</span>
)}
<span className="px-2 py-0.5 rounded-full text-xs bg-muted">{u.rol}</span>
</div>
</div>
))}
{(usuariosDetalle ?? []).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">Sin usuarios activos</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Add/Edit Client Form */}
{showForm && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
</CardTitle>
<CardDescription>
{editingTenant
? 'Modifica los datos del cliente'
: 'Registra un nuevo cliente para gestionar su facturación'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre de la Empresa</Label>
<Input
id="nombre"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
placeholder="Empresa SA de CV"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rfc">RFC</Label>
<Input
id="rfc"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={14}
required
disabled={!!editingTenant} // Can't change RFC after creation
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value) =>
setFormData({ ...formData, plan: value as PlanType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trial">Prueba 30 días, 3 RFCs, 20 timbres</SelectItem>
<SelectItem value="mi_empresa">Mi Empresa 1 RFC, 50 timbres</SelectItem>
<SelectItem value="mi_empresa_plus">Mi Empresa + 1 RFC, 50 timbres, API + Lolita</SelectItem>
<SelectItem value="business_control">Business Control 100 RFCs, BYO-DB</SelectItem>
<SelectItem value="business_cloud">Enterprise 100 RFCs, 3M CFDIs/contrib, BYO-DB</SelectItem>
<SelectItem value="custom">Custom Monto variable (admin asigna)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campos de admin y suscripción — solo al crear */}
{!editingTenant && (
<>
<div className="border-t pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
<Input
id="adminNombre"
value={formData.adminNombre}
onChange={(e) => setFormData({ ...formData, adminNombre: e.target.value })}
placeholder="Juan Pérez"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">Email del Dueño</Label>
<Input
id="adminEmail"
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
required
/>
</div>
</div>
</div>
{formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Input
id="amount"
type="number"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
{formData.plan === 'custom' && (
<p className="text-xs text-muted-foreground">
Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta.
Si pones &gt;$0, se generará Subscription con preapproval MercadoPago mensual.
</p>
)}
</div>
{formData.plan === 'custom' && (
<div className="space-y-2">
<Label htmlFor="firstPaymentDueAt">Primera fecha de pago</Label>
<Input
id="firstPaymentDueAt"
type="date"
min={new Date().toISOString().slice(0, 10)}
value={formData.firstPaymentDueAt}
onChange={(e) => setFormData({ ...formData, firstPaymentDueAt: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Deadline visible al cliente para realizar su primer pago. Opcional.
</p>
</div>
)}
</div>
)}
{formData.plan === 'trial' && (
<p className="text-xs text-muted-foreground">
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
</p>
)}
</>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createTenant.isPending || updateTenant.isPending}>
{editingTenant
? (updateTenant.isPending ? 'Guardando...' : 'Guardar Cambios')
: (createTenant.isPending ? 'Creando...' : 'Crear Cliente')}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Clients List */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lista de Clientes</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : tenants && tenants.length > 0 ? (
<div className="space-y-3">
{tenants.map((tenant) => (
<div
key={tenant.id}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="font-bold text-primary">
{tenant.nombre.substring(0, 2).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{tenant.nombre}</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{tenant.rfc}</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${planColors[tenant.plan] ?? 'bg-muted text-muted-foreground'}`}>
{PLAN_LABELS[tenant.plan] ?? tenant.plan}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<button
type="button"
className="flex items-center gap-1 text-sm hover:text-primary transition-colors"
onClick={() => { setUsuariosTenantId(tenant.id); setUsuariosTenantNombre(tenant.nombre); }}
title="Ver usuarios"
>
<Users className="h-4 w-4 text-muted-foreground" />
<span>
{usuariosPorTenant.get(tenant.id) ?? tenant._count?.memberships ?? 0} usuarios
</span>
<ChevronRight className="h-3 w-3" />
</button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>{formatDate(tenant.createdAt)}</span>
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
>
<Eye className="h-4 w-4 mr-1" />
Ver
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(tenant)}
title="Editar"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tenant)}
title="Eliminar"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No hay clientes registrados
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
import { useState, useEffect } from 'react';
import { useCfdisConConciliacion, useConciliar, useDesconciliar } from '@/lib/hooks/use-conciliacion';
import { useBancos } from '@/lib/hooks/use-bancos';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { Eye, Download, X, CheckCircle } from 'lucide-react';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
export default function ConciliacionPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'EMITIDO' | 'RECIBIDO'>('EMITIDO');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [fechaPago, setFechaPago] = useState('');
const [bancoId, setBancoId] = useState<string>('');
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
const { user } = useAuthStore();
const isVisor = user?.role === 'visor';
// Data
const { data: regimenes, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
const { data: cfdis, isLoading } = useCfdisConConciliacion({
tipo: activeTab,
fechaInicio,
fechaFin,
...(regimenSeleccionado && { regimen: regimenSeleccionado }),
});
const { data: bancos } = useBancos();
const conciliarMut = useConciliar();
const desconciliarMut = useDesconciliar();
// Split data
const pendientes = cfdis?.filter((c) => c.conciliado !== 'true') || [];
const conciliadas = cfdis?.filter((c) => c.conciliado === 'true') || [];
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
const getMonto = (c: any) => Number(c.montoMxn || c.totalMxn || 0);
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
// Reset selection on tab/filter change
useEffect(() => {
setSelected(new Set());
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
// Handlers
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === pendientes.length && pendientes.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(pendientes.map((c) => c.id)));
}
};
const handleConciliar = async () => {
if (selected.size === 0 || !fechaPago || !bancoId) return;
try {
await conciliarMut.mutateAsync({
cfdiIds: Array.from(selected),
fechaDePago: fechaPago,
idBanco: parseInt(bancoId),
});
setSelected(new Set());
setFechaPago('');
setBancoId('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al conciliar');
}
};
const handleDesconciliar = async (conciliacionId: number) => {
if (!confirm('¿Desconciliar este CFDI?')) return;
try {
await desconciliarMut.mutateAsync(conciliacionId);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desconciliar');
}
};
const handleExport = () => {
if (!cfdis?.length) return;
exportToExcel(
cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
_banco: c.conciliacion
? `${c.conciliacion.banco} ****${c.conciliacion.terminacionCuenta}`
: '',
})),
[
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emisión', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Estado', key: '_estado', width: 12 },
{ header: 'Fecha Pago', key: '_fechaPago', width: 15 },
{ header: 'Banco', key: '_banco', width: 20 },
],
`conciliacion-${activeTab.toLowerCase()}`,
);
};
return (
<>
<Header title="Conciliación">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(i, f) => {
setFechaInicio(i);
setFechaFin(f);
}}
/>
</Header>
<main className="p-6 space-y-6">
{/* Regimen selector + Export button */}
<div className="flex items-center justify-between">
<RegimenSelector
regimenes={regimenes || []}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!cfdis?.length}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
</div>
{/* Score cards */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Conciliado</p>
<p className="text-2xl font-bold text-success">{formatCurrency(montoConciliado)}</p>
<p className="text-xs text-muted-foreground mt-1">{conciliadas.length} CFDIs</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Pendiente de Conciliar</p>
<p className="text-2xl font-bold text-destructive">{formatCurrency(montoPendiente)}</p>
<p className="text-xs text-muted-foreground mt-1">{pendientes.length} CFDIs</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2">
{(['EMITIDO', 'RECIBIDO'] as const).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? 'default' : 'outline'}
onClick={() => {
setActiveTab(tab);
setSelected(new Set());
}}
>
{tab === 'EMITIDO' ? 'Emitidas' : 'Recibidas'}
</Button>
))}
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Cargando...</div>
) : (
<>
{/* Por conciliar */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
{pendientes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No hay CFDIs pendientes de conciliar
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
{!isVisor && (
<th className="pb-3 w-8">
<input
type="checkbox"
checked={
selected.size === pendientes.length && pendientes.length > 0
}
onChange={toggleSelectAll}
/>
</th>
)}
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{pendientes.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
<input
type="checkbox"
checked={selected.has(cfdi.id)}
onChange={() => toggleSelect(cfdi.id)}
/>
</td>
)}
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrency(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Action bar - only when items selected */}
{!isVisor && selected.size > 0 && (
<div className="sticky bottom-4 z-10 bg-card border rounded-lg shadow-lg p-4 flex items-center gap-4">
<span className="text-sm font-medium">{selected.size} seleccionados</span>
<Select value={bancoId} onValueChange={setBancoId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Seleccionar banco" />
</SelectTrigger>
<SelectContent>
{bancos?.map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.banco} ****{b.terminacionCuenta}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={fechaPago}
onChange={(e) => setFechaPago(e.target.value)}
className="w-44"
/>
<Button
onClick={handleConciliar}
disabled={!fechaPago || !bancoId || conciliarMut.isPending}
>
<CheckCircle className="h-4 w-4 mr-1" />
Conciliar {selected.size} facturas
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* Conciliadas */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
{conciliadas.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</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-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha Emisión</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">Fecha Pago</th>
<th className="pb-3 font-medium">Banco</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{conciliadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrency(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion?.fechaDePago
? new Date(
cfdi.conciliacion.fechaDePago + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
{!isVisor && cfdi.conciliacion && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDesconciliar(cfdi.conciliacion!.id)}
title="Desconciliar"
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</main>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
'use client';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import { Sparkles, Check, ExternalLink } from 'lucide-react';
import { useMyAddons, useSubscribeAddon, useCancelAddon } from '@/lib/hooks/use-addons';
/**
* Catálogo de add-ons disponibles **por contribuyente**. No se ofrecen aquí
* los add-ons tenant-level (maxRfcs, timbres, etc.) — esos viven en la
* pantalla de suscripción del despacho.
*/
const ADDONS_POR_CONTRIBUYENTE: Array<{
codename: string;
nombre: string;
descripcion: string;
precio: number;
frecuencia: 'mensual';
}> = [
{
codename: 'lolita_ia_contribuyente',
nombre: 'Lolita IA',
descripcion: 'Agente IA fiscal dedicado al RFC. Responde dudas, sugiere optimizaciones, prepara resúmenes.',
precio: 250,
frecuencia: 'mensual',
},
];
export function AddonsDialog({
target,
onClose,
}: {
target: { id: string; nombre: string } | null;
onClose: () => void;
}) {
const { data, isLoading } = useMyAddons(target?.id);
const subscribeMut = useSubscribeAddon();
const cancelMut = useCancelAddon();
const handleSubscribe = async (codename: string) => {
if (!target) return;
try {
const result = await subscribeMut.mutateAsync({
addonCodename: codename,
contribuyenteId: target.id,
});
if (result.paymentUrl) {
window.open(result.paymentUrl, '_blank');
}
} catch (err: any) {
alert(err.response?.data?.message || err.message || 'Error al contratar add-on');
}
};
const handleCancel = async (addonId: string, nombre: string) => {
if (!confirm(`¿Cancelar ${nombre}? Se deja de cobrar al final del período actual.`)) return;
try {
await cancelMut.mutateAsync(addonId);
} catch (err: any) {
alert(err.response?.data?.message || err.message || 'Error al cancelar');
}
};
const fmtMoney = (n: number) => n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 0 });
const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
const activeAddons = data?.addons ?? [];
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-5 w-5 text-amber-500" /> Add-ons {target?.nombre}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
<p className="text-sm text-muted-foreground">
Servicios adicionales de cobro mensual que se contratan por contribuyente. El cobro va en un preapproval MercadoPago independiente se puede cancelar sin afectar la licencia anual del despacho.
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando add-ons activos...</p>
) : (
<div className="space-y-3">
{ADDONS_POR_CONTRIBUYENTE.map((a) => {
const active = activeAddons.find((x) => x.codename === a.codename);
const isActive = active && (active.status === 'authorized' || active.status === 'pending');
return (
<div key={a.codename} className="border rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{a.nombre}</h3>
{isActive && (
<span className={`text-xs px-2 py-0.5 rounded ${active.status === 'authorized' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
{active.status === 'authorized' ? <><Check className="inline h-3 w-3 mr-1" />Activo</> : 'Pendiente de pago'}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">{a.descripcion}</p>
<p className="text-sm font-medium mt-2">{fmtMoney(a.precio)}<span className="text-muted-foreground font-normal"> / mes</span></p>
{isActive && active.currentPeriodEnd && (
<p className="text-xs text-muted-foreground mt-1">Próximo cobro: {fmtDate(active.currentPeriodEnd)}</p>
)}
</div>
<div>
{isActive ? (
<Button
variant="outline"
size="sm"
onClick={() => handleCancel(active.id, a.nombre)}
disabled={cancelMut.isPending}
>
Cancelar
</Button>
) : (
<Button
size="sm"
onClick={() => handleSubscribe(a.codename)}
disabled={subscribeMut.isPending}
className="flex items-center gap-1"
>
Contratar <ExternalLink className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
<p className="text-xs text-muted-foreground border-t pt-3">
Al contratar abre una pestaña de MercadoPago para autorizar el cobro recurrente. Si no tienes una suscripción activa del despacho, el add-on no podrá crearse.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Label, Card, CardContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import { useContribuyentes, useCreateContribuyente, useUpdateContribuyente, useDeactivateContribuyente } from '@/lib/hooks/use-contribuyentes';
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
import { AddonsDialog } from './addons-dialog';
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
export default function ContribuyentesPage() {
const { user } = useAuthStore();
const { data: contribuyentes, isLoading } = useContribuyentes();
const createMut = useCreateContribuyente();
const updateMut = useUpdateContribuyente();
const deactivateMut = useDeactivateContribuyente();
const [showDialog, setShowDialog] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateContribuyenteData>({ rfc: '', razonSocial: '' });
const [assignSelf, setAssignSelf] = useState(true);
const [addonsTarget, setAddonsTarget] = useState<{ id: string; nombre: string } | null>(null);
// Trial gate: durante el periodo de prueba el despacho no puede agregar más
// de 5 contribuyentes activos. El backend valida también; aquí solo se
// deshabilita el botón con tooltip explicativo.
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
const handleSave = async () => {
try {
if (editingId) {
await updateMut.mutateAsync({ id: editingId, data: form });
} else {
const created = await createMut.mutateAsync({
...form,
supervisorUserId: assignSelf ? user?.id : undefined,
});
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
alert(`Se agregó un cobro mensual de $45 por contribuyente adicional (${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'}). Autoriza el pago en MercadoPago.`);
window.open(created.overage.paymentUrl, '_blank');
} else if (created.overage?.action === 'updated') {
alert(`Se actualizó el cobro mensual de contribuyentes adicionales a ${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'} ($${created.overage.overageCount * 45}/mes).`);
}
}
resetForm();
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
};
const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return;
try {
const result = await deactivateMut.mutateAsync(id);
if (result.overage?.action === 'cancelled') {
alert('Se canceló el cobro mensual de contribuyente adicional (volviste a los 3 incluidos).');
} else if (result.overage?.action === 'updated') {
alert(`El cobro de contribuyentes adicionales se actualizó a ${result.overage.overageCount} extra${result.overage.overageCount === 1 ? '' : 's'} ($${result.overage.overageCount * 45}/mes).`);
}
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
};
const openEdit = (c: any) => {
setForm({ rfc: c.rfc, razonSocial: c.nombre });
setEditingId(c.id);
setShowDialog(true);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
</div>
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
<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">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
>
Agregar primer RFC
</Button>
</CardContent></Card>
) : (
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => (
<Card key={c.id}><CardContent className="flex items-center justify-between py-4 px-6">
<div>
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
</div>
</CardContent></Card>
))}</div>
)}
<AddonsDialog target={addonsTarget} onClose={() => setAddonsTarget(null)} />
<Dialog open={showDialog} onOpenChange={() => resetForm()}>
<DialogContent>
<DialogHeader><DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div><Label>RFC</Label><Input value={form.rfc} onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))} placeholder="ABC010203XY1" maxLength={13} disabled={!!editingId} /></div>
<div><Label>Razón social</Label><Input value={form.razonSocial} onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))} placeholder="Empresa SA de CV" /></div>
{!editingId && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="assignSelf"
checked={assignSelf}
onChange={(e) => setAssignSelf(e.target.checked)}
className="h-4 w-4"
/>
<label htmlFor="assignSelf" className="text-sm text-muted-foreground">
Asignarme como supervisor de este RFC
</label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button onClick={handleSave} disabled={createMut.isPending || updateMut.isPending}>{editingId ? 'Guardar' : 'Agregar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,418 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
Wallet,
Receipt,
AlertTriangle,
ShoppingCart,
CheckSquare,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number) {
const s = new Date(fechaInicio + 'T00:00:00');
const e = new Date(fechaFin + 'T00:00:00');
s.setFullYear(s.getFullYear() + delta);
e.setFullYear(e.getFullYear() + delta);
// Ajustar último día del mes si cambió
const lastDay = new Date(e.getFullYear(), e.getMonth() + 1, 0).getDate();
if (e.getDate() > lastDay) e.setDate(lastDay);
return {
fechaInicio: s.toISOString().split('T')[0],
fechaFin: e.toISOString().split('T')[0],
};
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
const { viewingTenantId } = useTenantViewStore();
// Admin global no opera sobre datos de despacho propios — su home natural
// es `/clientes`. EXCEPCIÓN: si está impersonando un tenant (vía botón "Ver"
// en /clientes), sí entra al dashboard para validar lo que ve el cliente.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin && !viewingTenantId) router.replace('/clientes');
}, [isGlobalAdmin, viewingTenantId, router]);
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [conciliacion, setConciliacion] = useState(false);
// Periodo anterior (mismo rango, un año atrás)
const anterior = shiftDatesOneYear(fechaInicio, fechaFin, -1);
// Año del inicio para el chart anual
const añoChart = new Date(fechaInicio + 'T00:00:00').getFullYear();
const mesResumen = new Date(fechaInicio + 'T00:00:00').getMonth() + 1;
const { data: kpis } = useKpis(fechaInicio, fechaFin, conciliacion);
const { data: kpisAnterior } = useKpis(anterior.fechaInicio, anterior.fechaFin, conciliacion);
const { data: chartData } = useIngresosEgresos(añoChart, conciliacion);
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin, conciliacion);
const handlePeriodChange = (inicio: string, fin: string) => {
setFechaInicio(inicio);
setFechaFin(fin);
};
// Filtrar ingresos por régimen seleccionado
const ingresosDisplay = regimenSeleccionado
? kpis?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ingresos || 0;
const ingresosAnterior = regimenSeleccionado
? kpisAnterior?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ingresos || 0;
const ingresosVariacion = ingresosAnterior > 0
? Math.round(((ingresosDisplay - ingresosAnterior) / ingresosAnterior) * 10000) / 100
: null;
// Filtrar egresos por régimen seleccionado
const egresosDisplay = regimenSeleccionado
? kpis?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.egresos || 0;
const egresosAnterior = regimenSeleccionado
? kpisAnterior?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.egresos || 0;
const egresosVariacion = egresosAnterior > 0
? Math.round(((egresosDisplay - egresosAnterior) / egresosAnterior) * 10000) / 100
: null;
// Adquisición de mercancías
const adquisicionDisplay = regimenSeleccionado
? kpis?.adquisicionMercanciasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.adquisicionMercancias || 0;
// Filtrar IVA por régimen seleccionado
const ivaDisplay = regimenSeleccionado
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ivaBalance || 0;
const ivaAnterior = regimenSeleccionado
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ivaBalance || 0;
const ivaVariacion = ivaAnterior !== 0
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
: null;
const utilidadDisplay = ingresosDisplay - egresosDisplay;
const margenDisplay = ingresosDisplay > 0
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
: 0;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
}).format(value);
// Helper para construir URLs de drill-down
const drillUrl = (titulo: string, filters: Record<string, string>) => {
const p = new URLSearchParams({ titulo, fechaInicio, fechaFin, status: 'vigente', ...filters });
// Dashboard no tiene toggles considerarActivos/considerarNCs — siempre
// pasa los defaults true (omitir = backend usa true). Si en el futuro se
// agregan toggles aquí, propagarlos como hace /impuestos.
if (regimenSeleccionado) {
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
// Por bucket — 605 es receptor en bucket=ingresos (nómina recibida).
else if (filters.bucket === 'ingresos') {
if (regimenSeleccionado === '605') p.set('regimenReceptor', '605');
else p.set('regimenEmisor', regimenSeleccionado);
}
else if (filters.bucket === 'causado') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.bucket === 'gastos' || filters.bucket === 'acreditable') p.set('regimenReceptor', regimenSeleccionado);
}
return `/drill-down?${p}`;
};
// Año anterior para labels
const añoAnterior = new Date(anterior.fechaInicio + 'T00:00:00').getFullYear();
// Reset régimen si ya no existe en el periodo
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
return (
<>
<Header title="Dashboard">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={handlePeriodChange}
/>
</Header>
<main className="p-6 space-y-6">
{/* Filtros */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<button
onClick={() => setConciliacion(!conciliacion)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
conciliacion
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
>
<CheckSquare className="h-4 w-4" />
Conciliación
</button>
</div>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
value={ingresosDisplay}
icon={<TrendingUp className="h-4 w-4" />}
trend={ingresosVariacion !== null ? (ingresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
trendValue={
ingresosVariacion !== null
? `${ingresosVariacion >= 0 ? '+' : ''}${ingresosVariacion}% vs ${añoAnterior}`
: 'Sin datos del periodo anterior'
}
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
/>
<KpiCard
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
value={egresosDisplay}
icon={<TrendingDown className="h-4 w-4" />}
trend={egresosVariacion !== null ? (egresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
trendValue={
egresosVariacion !== null
? `${egresosVariacion >= 0 ? '+' : ''}${egresosVariacion}% vs ${añoAnterior}`
: 'Sin datos del periodo anterior'
}
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
/>
<KpiCard
title="Utilidad"
value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen`}
/>
<KpiCard
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
value={ivaDisplay}
icon={<Receipt className="h-4 w-4" />}
trend={ivaDisplay > 0 ? 'up' : ivaDisplay < 0 ? 'down' : 'neutral'}
trendValue={ivaDisplay > 0 ? 'Por pagar' : ivaDisplay < 0 ? 'A favor' : 'Neutro'}
subtitle={
ivaVariacion !== null
? `${ivaVariacion >= 0 ? '+' : ''}${ivaVariacion}% vs ${añoAnterior}`
: undefined
}
href={drillUrl('Balance IVA - CFDIs', {})}
/>
</div>
{/* Desglose por régimen */}
{!regimenSeleccionado && kpis && (
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
{kpis.ingresosPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Ingresos por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ingresosPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.egresosPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Gastos por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.egresosPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.ivaBalancePorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Balance IVA por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ivaBalancePorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className={`text-sm font-semibold ${r.monto > 0 ? 'text-destructive' : 'text-success'}`}>
{formatCurrency(r.monto)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
))}
{/* Charts and Alerts */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<BarChart
title="Ingresos vs Egresos"
data={chartData || []}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-medium">
<AlertTriangle className="h-4 w-4" />
Alertas
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertasLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : alertas?.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay alertas pendientes</p>
) : (
alertas?.map((alerta) => (
<div
key={alerta.id}
className={`p-3 rounded-lg border ${
alerta.prioridad === 'alta'
? 'border-destructive/50 bg-destructive/10'
: 'border-border bg-muted/50'
}`}
>
<p className="text-sm font-medium">{alerta.titulo}</p>
<p className="text-xs text-muted-foreground mt-1">
{alerta.mensaje}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Resumen Fiscal */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Emitidos</p>
<p className="text-2xl font-bold">{
regimenSeleccionado
? kpis?.cfdisEmitidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
: kpis?.cfdisEmitidos || 0
}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Recibidos</p>
<p className="text-2xl font-bold">{
regimenSeleccionado
? kpis?.cfdisRecibidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
: kpis?.cfdisRecibidos || 0
}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Adquisición de Mercancías</p>
</div>
<p className="text-2xl font-bold">{formatCurrency(adquisicionDisplay)}</p>
<p className="text-xs text-muted-foreground mt-1">Uso CFDI G01</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">IVA a Favor ({añoChart})</p>
<p className="text-2xl font-bold text-success">
{formatCurrency(kpis?.ivaAFavorAcumulado || 0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">IVA a Favor Historico</p>
<p className="text-2xl font-bold text-success">
{formatCurrency(kpis?.ivaAFavorHistorico || 0)}
</p>
<p className="text-xs text-muted-foreground mt-1">{añoChart - 5} {añoChart}</p>
</CardContent>
</Card>
</div>
<FiscalDisclaimer />
</main>
</>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
import { PeriodoSelector } from '@/components/periodo-selector';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
interface Stats {
totalContribuyentes: number;
ultimaExtraccion: string | null;
progresoDelMes: number;
declaracionesPresentadas: number;
declaracionesPagadas: number;
declaracionesAtrasadas: number;
tareasAtrasadas: number;
}
export default function DespachoContribuyentesPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role === 'owner' || role === 'cfo';
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { data, isLoading } = useQuery<Stats>({
queryKey: ['despacho-contribuyentes-stats', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-7xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Esta sección solo está disponible para owner.
</CardContent>
</Card>
</main>
</>
);
}
return (
<>
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-7xl mx-auto">
<DespachoSubnav />
{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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Building2 className="h-4 w-4" />
Contribuyentes dados de alta
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.totalContribuyentes ?? 0}</div>
<CardDescription className="mt-1">
Activos en el despacho
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<RefreshCw className="h-4 w-4" />
Última extracción SAT
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data?.ultimaExtraccion
? new Date(data.ultimaExtraccion).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
: '—'}
</div>
<CardDescription className="mt-1">
{data?.ultimaExtraccion
? new Date(data.ultimaExtraccion).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
})
: 'Sin extracciones registradas'}
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<TrendingUp className="h-4 w-4" />
Progreso del mes
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${
(data?.progresoDelMes ?? 0) >= 80 ? 'text-success' :
(data?.progresoDelMes ?? 0) >= 50 ? 'text-amber-600' :
'text-destructive'
}`}>
{data?.progresoDelMes ?? 0}%
</div>
<div className="h-2 mt-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${
(data?.progresoDelMes ?? 0) >= 80 ? 'bg-success' :
(data?.progresoDelMes ?? 0) >= 50 ? 'bg-amber-500' :
'bg-destructive'
}`}
style={{ width: `${data?.progresoDelMes ?? 0}%` }}
/>
</div>
<CardDescription className="mt-1">
Obligaciones y tareas completadas
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileCheck className="h-4 w-4" />
Declaraciones presentadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.declaracionesPresentadas ?? 0}</div>
<CardDescription className="mt-1">
Subidas al sistema este mes
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<DollarSign className="h-4 w-4" />
Declaraciones pagadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.declaracionesPagadas ?? 0}</div>
<CardDescription className="mt-1">
{data?.declaracionesPresentadas
? `${Math.round((data.declaracionesPagadas / data.declaracionesPresentadas) * 100)}% del mes`
: 'Con comprobante de pago'}
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Declaraciones atrasadas
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${(data?.declaracionesAtrasadas ?? 0) > 0 ? 'text-destructive' : ''}`}>
{data?.declaracionesAtrasadas ?? 0}
</div>
<CardDescription className="mt-1">
De periodos anteriores sin presentar
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Tareas atrasadas
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${(data?.tareasAtrasadas ?? 0) > 0 ? 'text-destructive' : ''}`}>
{data?.tareasAtrasadas ?? 0}
</div>
<CardDescription className="mt-1">
De periodos anteriores sin completar
</CardDescription>
</CardContent>
</Card>
</div>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
import { PeriodoSelector } from '@/components/periodo-selector';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { User, AlertTriangle, Loader2, ChevronDown, ChevronRight, CheckCircle2 } from 'lucide-react';
interface Miembro {
userId: string;
nombre: string;
email: string;
rol: 'supervisor' | 'auxiliar';
contribuyentes: number;
obligacionesAtrasadas: number;
tareasAtrasadas: number;
totalPendientes: number;
totalPeriodo: number;
completadasPeriodo: number;
avancePct: number | null;
}
interface SupervisorConAuxiliares extends Miembro {
auxiliares: Miembro[];
}
interface EquipoStatsResponse {
supervisores: SupervisorConAuxiliares[];
huerfanos: Miembro[];
}
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
function AvanceBar({ pct }: { pct: number | null }) {
if (pct === null) return <span className="text-xs text-muted-foreground">Sin datos</span>;
const color =
pct >= 80 ? 'bg-success' :
pct >= 50 ? 'bg-amber-500' :
'bg-destructive';
const text =
pct >= 80 ? 'text-success' :
pct >= 50 ? 'text-amber-600' :
'text-destructive';
return (
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium tabular-nums ${text}`}>{pct}%</span>
</div>
);
}
function AtrasoBadge({ total }: { total: number }) {
if (total === 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs text-muted-foreground bg-muted">
<CheckCircle2 className="h-3 w-3" /> Al día
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
<AlertTriangle className="h-3 w-3" />
{total}
</span>
);
}
export default function EquipoPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_SUPERVISORY.has(role) : false;
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useQuery<EquipoStatsResponse>({
queryKey: ['despacho-equipo-stats', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<EquipoStatsResponse>(
`/despachos/equipo-stats?año=${año}&mes=${mes}`,
);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Equipo"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Esta sección solo está disponible para owner y supervisor.
</CardContent>
</Card>
</main>
</>
);
}
const supervisores = data?.supervisores ?? [];
const huerfanos = data?.huerfanos ?? [];
const sinDatos = !isLoading && supervisores.length === 0 && huerfanos.length === 0;
return (
<>
<Header title="Despacho — Equipo"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto space-y-6">
<DespachoSubnav />
{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>
) : sinDatos ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay supervisores ni auxiliares en el despacho.
</CardContent>
</Card>
) : (
<>
{supervisores.length > 0 && (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="w-10"></th>
<th className="text-left px-4 py-3 font-medium">Miembro</th>
<th className="text-center px-3 py-3 font-medium">Contribuyentes</th>
<th className="text-left px-3 py-3 font-medium">Avance del periodo</th>
<th className="text-center px-3 py-3 font-medium">Atrasos</th>
</tr>
</thead>
<tbody>
{supervisores.map(sup => {
const expanded = expandedId === sup.userId;
const tieneAux = sup.auxiliares.length > 0;
return (
<FilaSupervisor
key={sup.userId}
sup={sup}
expanded={expanded}
tieneAux={tieneAux}
onToggle={() => setExpandedId(expanded ? null : sup.userId)}
/>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
{huerfanos.length > 0 && (
<div>
<h2 className="text-sm font-semibold mb-2 text-amber-700 dark:text-amber-400 uppercase tracking-wide flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Auxiliares sin supervisor asignado
</h2>
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
<th className="text-center px-3 py-3 font-medium">Contribuyentes</th>
<th className="text-left px-3 py-3 font-medium">Avance del periodo</th>
<th className="text-center px-3 py-3 font-medium">Atrasos</th>
</tr>
</thead>
<tbody>
{huerfanos.map(aux => (
<tr key={aux.userId} className="border-b hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{aux.nombre}</div>
<div className="text-xs text-muted-foreground">{aux.email}</div>
</div>
</div>
</td>
<td className="px-3 py-3 text-center text-muted-foreground">{aux.contribuyentes}</td>
<td className="px-3 py-3"><AvanceBar pct={aux.avancePct} /></td>
<td className="px-3 py-3 text-center"><AtrasoBadge total={aux.totalPendientes} /></td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
)}
</>
)}
</main>
</>
);
}
function FilaSupervisor({
sup, expanded, tieneAux, onToggle,
}: {
sup: SupervisorConAuxiliares;
expanded: boolean;
tieneAux: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className={`border-b ${tieneAux ? 'cursor-pointer hover:bg-muted/30' : ''} ${expanded ? 'bg-muted/30' : ''}`}
onClick={tieneAux ? onToggle : undefined}
>
<td className="px-2 py-3 text-center">
{tieneAux ? (
expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground inline" /> : <ChevronRight className="h-4 w-4 text-muted-foreground inline" />
) : null}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-primary" />
<div>
<div className="font-medium">{sup.nombre}</div>
<div className="text-xs text-muted-foreground">
{sup.email} · {sup.auxiliares.length} auxiliar{sup.auxiliares.length === 1 ? '' : 'es'}
</div>
</div>
</div>
</td>
<td className="px-3 py-3 text-center text-muted-foreground">{sup.contribuyentes}</td>
<td className="px-3 py-3"><AvanceBar pct={sup.avancePct} /></td>
<td className="px-3 py-3 text-center"><AtrasoBadge total={sup.totalPendientes} /></td>
</tr>
{expanded && sup.auxiliares.map(aux => (
<tr key={aux.userId} className="border-b bg-muted/10">
<td></td>
<td className="px-4 py-2 pl-10">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm">{aux.nombre}</div>
<div className="text-xs text-muted-foreground">{aux.email}</div>
</div>
</div>
</td>
<td className="px-3 py-2 text-center text-muted-foreground">{aux.contribuyentes}</td>
<td className="px-3 py-2"><AvanceBar pct={aux.avancePct} /></td>
<td className="px-3 py-2 text-center"><AtrasoBadge total={aux.totalPendientes} /></td>
</tr>
))}
{expanded && sup.auxiliares.length === 0 && (
<tr className="border-b bg-muted/10">
<td></td>
<td colSpan={4} className="px-4 py-3 pl-10 text-xs text-muted-foreground italic">
Sin auxiliares asignados.
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
import { PeriodoSelector } from '@/components/periodo-selector';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
interface Asignado {
contribuyenteId: string;
rfc: string;
nombre: string;
carteraNombre: string | null;
obligacionesPendientes: number;
obligacionesAtrasadas: number;
obligacionesCompletadas: number;
tareasPendientes: number;
tareasAtrasadas: number;
tareasCompletadas: number;
}
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
export default function MisAsignadosPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_ASIGNADOS.has(role) : false;
const { setSelectedContribuyente } = useContribuyenteStore();
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { data, isLoading } = useQuery<Asignado[]>({
queryKey: ['despacho-mis-asignados', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Asignado[]>(`/despachos/mis-asignados?año=${año}&mes=${mes}`);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No tienes contribuyentes asignados.
</CardContent>
</Card>
</main>
</>
);
}
const items = data ?? [];
return (
<>
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
{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>
) : items.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No tienes contribuyentes asignados todavía. Pídele al owner que te los asigne en su cartera.
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">Cartera</th>
<th className="text-left px-4 py-3 font-medium w-[180px]">Avance</th>
<th className="text-center px-3 py-3 font-medium" title="Atrasos de periodos anteriores (obligaciones + tareas)">
Atrasos
</th>
<th className="text-center px-3 py-3 font-medium" title="Obligaciones del periodo">
Obl. periodo
</th>
<th className="text-center px-3 py-3 font-medium" title="Tareas del periodo">
Tareas periodo
</th>
</tr>
</thead>
<tbody>
{items.map(it => {
const total =
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
const completadas = it.obligacionesCompletadas + it.tareasCompletadas;
const pct = total > 0 ? Math.round((completadas / total) * 100) : null;
const barColor =
pct === null ? 'bg-muted' :
pct >= 80 ? 'bg-success' :
pct >= 50 ? 'bg-amber-500' :
'bg-destructive';
const totalAtrasos = it.obligacionesAtrasadas + it.tareasAtrasadas;
const tieneAtrasos = totalAtrasos > 0;
return (
<tr
key={it.contribuyenteId}
className={`border-b hover:bg-muted/30 ${tieneAtrasos ? 'bg-red-50/50 dark:bg-red-950/10' : ''}`}
>
<td className="px-4 py-3">
<Link
href="/configuracion/obligaciones"
onClick={() => setSelectedContribuyente(it.contribuyenteId, it.rfc, it.nombre)}
className="hover:underline"
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{it.nombre}</div>
<div className="text-xs font-mono text-muted-foreground">{it.rfc}</div>
</div>
</div>
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">
{it.carteraNombre ?? '—'}
</td>
<td className="px-4 py-3">
{pct === null ? (
<span className="text-xs text-muted-foreground">Sin datos</span>
) : (
<div className="flex items-center gap-2" title={`${completadas} de ${total} completadas`}>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-medium tabular-nums ${
pct >= 80 ? 'text-success' :
pct >= 50 ? 'text-amber-600' :
'text-destructive'
}`}>
{pct}%
</span>
</div>
)}
</td>
<td className="px-3 py-3 text-center">
{tieneAtrasos ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive"
title={`Obligaciones: ${it.obligacionesAtrasadas} · Tareas: ${it.tareasAtrasadas}`}
>
<AlertTriangle className="h-3 w-3" />
{totalAtrasos}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs text-muted-foreground bg-muted">
<CheckCircle2 className="h-3 w-3" /> Al día
</span>
)}
</td>
<td className="px-3 py-3 text-center" title="Completadas / Pendientes">
<span className="text-success">{it.obligacionesCompletadas}</span>
<span className="text-muted-foreground"> / </span>
<span className={it.obligacionesPendientes > 0 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
{it.obligacionesPendientes}
</span>
</td>
<td className="px-3 py-3 text-center" title="Completadas / Pendientes">
<span className="text-success">{it.tareasCompletadas}</span>
<span className="text-muted-foreground"> / </span>
<span className={it.tareasPendientes > 0 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
{it.tareasPendientes}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subnav';
export default function DespachosIndex() {
const router = useRouter();
const role = useAuthStore(s => s.user?.role);
useEffect(() => {
if (!role) return;
router.replace(defaultDespachoPathForRole(role));
}, [role, router]);
return null;
}

View File

@@ -0,0 +1,996 @@
'use client';
import { Fragment, useState } from 'react';
import { useOpiniones, useConsultarOpinion, useDescargarPdf } from '@/lib/hooks/use-documentos';
import {
useDeclaraciones,
useCreateDeclaracion,
useUploadComprobantePago,
useDeleteDeclaracion,
useDownloadDeclaracionPdf,
} from '@/lib/hooks/use-declaraciones';
import { fileToBase64, type Declaracion, type Impuesto, type Periodicidad } from '@/lib/api/declaraciones';
import { useConstancias, useConsultarConstancia, useDescargarConstanciaPdf } from '@/lib/hooks/use-constancias';
import type { Constancia } from '@/lib/api/constancias';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Tabs, TabsList, TabsTrigger, TabsContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Input, Label } from '@horux/shared-ui';
import {
FileCheck, Download, RefreshCw, Loader2, AlertTriangle, CheckCircle2, XCircle, Clock,
IdCard, FileText, Upload, Plus, Trash2, Receipt, FolderOpen, Tag, Briefcase,
} from 'lucide-react';
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual' },
];
const PERIODICIDAD_LABELS: Record<string, string> = {
mensual: 'Mensual', bimestral: 'Bimestral', trimestral: 'Trimestral',
semestral: 'Semestral', anual: 'Anual',
};
/** Returns period options based on periodicidad. Each option has a value (mes start) and a label. */
function getPeriodOptions(periodicidad: Periodicidad): { value: number; label: string }[] {
switch (periodicidad) {
case 'mensual':
return MESES.map((m, i) => ({ value: i + 1, label: m }));
case 'bimestral':
return [
{ value: 1, label: 'Enero Febrero' },
{ value: 3, label: 'Marzo Abril' },
{ value: 5, label: 'Mayo Junio' },
{ value: 7, label: 'Julio Agosto' },
{ value: 9, label: 'Septiembre Octubre' },
{ value: 11, label: 'Noviembre Diciembre' },
];
case 'trimestral':
return [
{ value: 1, label: 'Enero Marzo' },
{ value: 4, label: 'Abril Junio' },
{ value: 7, label: 'Julio Septiembre' },
{ value: 10, label: 'Octubre Diciembre' },
];
case 'semestral':
return [
{ value: 1, label: 'Enero Junio' },
{ value: 7, label: 'Julio Diciembre' },
];
case 'anual':
return [{ value: 1, label: 'Anual' }];
default:
return MESES.map((m, i) => ({ value: i + 1, label: m }));
}
}
/** Returns a display label for a period (mes) given its periodicidad */
function getPeriodLabel(periodicidad: string, mes: number): string {
const options = getPeriodOptions(periodicidad as Periodicidad);
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
}
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
function EstatusBadge({ estatus }: { estatus: string }) {
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
if (estatus === 'Negativa') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> {estatus}</span>;
return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><AlertTriangle className="h-3 w-3" /> {estatus}</span>;
}
export default function DocumentosPage() {
const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
const canSeePapeleria = user?.role !== 'cliente';
return (
<>
<Header title="Documentos" />
<main className="p-6">
<Tabs defaultValue="declaraciones" className="space-y-4">
<TabsList>
<TabsTrigger value="opinion"><FileCheck className="h-4 w-4 mr-1.5" /> Opinión de Cumplimiento</TabsTrigger>
<TabsTrigger value="constancia"><IdCard className="h-4 w-4 mr-1.5" /> Constancia de Situación Fiscal</TabsTrigger>
<TabsTrigger value="declaraciones"><FileText className="h-4 w-4 mr-1.5" /> Declaraciones</TabsTrigger>
<TabsTrigger value="extras"><FolderOpen className="h-4 w-4 mr-1.5" /> Extras</TabsTrigger>
{canSeePapeleria && (
<TabsTrigger value="papeleria"><Briefcase className="h-4 w-4 mr-1.5" /> Papelería de Trabajo</TabsTrigger>
)}
</TabsList>
<TabsContent value="opinion"><OpinionTab canConsultar={canConsultarOpinion} /></TabsContent>
<TabsContent value="constancia"><ConstanciaTab /></TabsContent>
<TabsContent value="declaraciones"><DeclaracionesTab /></TabsContent>
<TabsContent value="extras"><ExtrasTab /></TabsContent>
{canSeePapeleria && (
<TabsContent value="papeleria"><PapeleriaTab /></TabsContent>
)}
</Tabs>
</main>
</>
);
}
// ============================================================================
// Opinión de Cumplimiento
// ============================================================================
function OpinionTab({ canConsultar }: { canConsultar: boolean }) {
const { data: opiniones, isLoading, error } = useOpiniones();
const consultar = useConsultarOpinion();
const descargar = useDescargarPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2"><FileCheck className="h-5 w-5" /> Opinión de Cumplimiento</h2>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error al cargar opiniones: {(error as Error).message}</div>}
{!isLoading && !error && opiniones?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileCheck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay opiniones registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta cada semana.</p>
</div>
)}
{!isLoading && opiniones && opiniones.length > 0 && (
<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">Fecha de consulta</th>
<th className="pb-2 font-medium">Estatus</th>
<th className="pb-2 font-medium">Folio</th>
<th className="pb-2 font-medium">RFC</th>
<th className="pb-2 font-medium text-right">PDF</th>
</tr>
</thead>
<tbody className="divide-y">
{opiniones.map((op) => (
<tr key={op.id} className="hover:bg-muted/50">
<td className="py-3"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(op.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></td>
<td className="py-3"><EstatusBadge estatus={op.estatus} /></td>
<td className="py-3 font-mono text-xs">{op.folio}</td>
<td className="py-3 font-mono text-xs">{op.rfc}</td>
<td className="py-3 text-right"><Button variant="ghost" size="sm" onClick={() => descargar.mutate(op.id)} disabled={descargar.isPending}><Download className="h-3.5 w-3.5 mr-1" /> Descargar</Button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Constancia de Situación Fiscal
// ============================================================================
function ConstanciaTab() {
const user = useAuthStore((s) => s.user);
const canConsultar = user?.role === 'owner' || user?.role === 'cfo';
const { data: constancias, isLoading, error } = useConstancias();
const consultar = useConsultarConstancia();
const descargar = useDescargarConstanciaPdf();
const [expandedId, setExpandedId] = useState<number | null>(null);
const latest = constancias?.[0];
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><IdCard className="h-5 w-5" /> Constancia de Situación Fiscal</h2>
<p className="text-sm text-muted-foreground mt-0.5">Descarga mensual automática del SAT el día 1. También se actualizan el domicilio fiscal y los regímenes activos del tenant.</p>
</div>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && constancias?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<IdCard className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay constancias registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta el 1° de cada mes.</p>
</div>
)}
{latest && (
<ConstanciaDetalle datos={latest.datos} />
)}
</CardContent>
</Card>
{constancias && constancias.length > 0 && (
<Card>
<CardContent className="pt-6">
<h3 className="text-md font-semibold mb-3">Historial (últimas {constancias.length})</h3>
<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 pr-2 font-medium">Fecha de consulta</th>
<th className="pb-2 pr-2 font-medium">Estatus</th>
<th className="pb-2 pr-2 font-medium">RFC</th>
<th className="pb-2 pr-2 font-medium">Régimenes activos</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{constancias.map((c) => {
const activos = c.datos.regimenes.filter(r => !r.fechaFin).length;
const expanded = expandedId === c.id;
return (
<Fragment key={c.id}>
<tr className="hover:bg-muted/50">
<td className="py-3 pr-2"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(c.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })}</div></td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${c.estatusPadron === 'ACTIVO' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{c.estatusPadron || '—'}
</span>
</td>
<td className="py-3 pr-2 font-mono text-xs">{c.rfc}</td>
<td className="py-3 pr-2 text-xs">{activos}</td>
<td className="py-3 pr-2 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expanded ? null : c.id)}>
{expanded ? 'Ocultar' : 'Ver datos'}
</Button>
<Button variant="ghost" size="sm" onClick={() => descargar.mutate(c.id)} disabled={descargar.isPending}>
<Download className="h-3.5 w-3.5 mr-1" /> PDF
</Button>
</td>
</tr>
{expanded && (
<tr><td colSpan={5} className="p-0 bg-muted/30"><div className="p-4"><ConstanciaDetalle datos={c.datos} /></div></td></tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
function ConstanciaDetalle({ datos }: { datos: Constancia['datos'] }) {
const d = datos.domicilio;
const domicilio = [
[d.tipoVialidad, d.nombreVialidad].filter(Boolean).join(' '),
d.numeroExterior && `Ext. ${d.numeroExterior}`,
d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' && `Int. ${d.numeroInterior}`,
d.colonia,
[d.codigoPostal, d.localidad].filter(Boolean).join(' '),
[d.municipio, d.entidadFederativa].filter(Boolean).join(', '),
].filter(Boolean).join(' · ');
const regimenesActivos = datos.regimenes.filter(r => !r.fechaFin);
return (
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Identificación</h4>
<dl className="space-y-1">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">RFC</dt><dd className="font-mono">{datos.rfc}</dd></div>
{datos.curp && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">CURP</dt><dd className="font-mono">{datos.curp}</dd></div>}
{datos.razonSocial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">Razón social</dt><dd className="text-right">{datos.razonSocial}</dd></div>}
{!datos.razonSocial && (datos.nombre || datos.primerApellido) && (
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Nombre</dt><dd className="text-right">{[datos.nombre, datos.primerApellido, datos.segundoApellido].filter(Boolean).join(' ')}</dd></div>
)}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Estatus</dt><dd>{datos.estatusPadron}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Inicio de operaciones</dt><dd>{datos.fechaInicioOperaciones}</dd></div>
</dl>
</div>
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Domicilio fiscal</h4>
<p>{domicilio || '—'}</p>
</div>
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Regímenes activos ({regimenesActivos.length})</h4>
{regimenesActivos.length === 0 ? (
<p className="text-muted-foreground">Sin regímenes activos</p>
) : (
<ul className="space-y-1">
{regimenesActivos.map((r, i) => (
<li key={i} className="flex justify-between gap-2">
<span>{r.nombre}</span>
<span className="text-xs text-muted-foreground">desde {r.fechaInicio}</span>
</li>
))}
</ul>
)}
</div>
{datos.obligaciones.length > 0 && (
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Obligaciones ({datos.obligaciones.filter(o => !o.fechaFin).length} activas)</h4>
<ul className="space-y-1">
{datos.obligaciones.filter(o => !o.fechaFin).map((o, i) => (
<li key={i} className="text-xs">
<span className="font-medium">{o.descripcion}</span>
<span className="text-muted-foreground"> {o.descripcionVencimiento}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ============================================================================
// Declaraciones
// ============================================================================
function DeclaracionesTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
// Default: current month range
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const [fechaDesde, setFechaDesde] = useState(`${y}-${String(m).padStart(2, '0')}-01`);
const [fechaHasta, setFechaHasta] = useState(`${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`);
const [uploadOpen, setUploadOpen] = useState(false);
const [pagoDeclaracion, setPagoDeclaracion] = useState<Declaracion | null>(null);
const { data: declaraciones, isLoading, error } = useDeclaraciones(fechaDesde, fechaHasta, selectedContribuyenteId);
const deleteDecl = useDeleteDeclaracion();
const downloadPdf = useDownloadDeclaracionPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><FileText className="h-5 w-5" /> Declaraciones</h2>
<p className="text-sm text-muted-foreground mt-0.5">Sube el PDF de cada declaración y su comprobante de pago. Al subirla, se desactivan los recordatorios correspondientes.</p>
</div>
{canUpload && <Button size="sm" onClick={() => setUploadOpen(true)}><Plus className="h-4 w-4 mr-1.5" /> Subir declaración</Button>}
</div>
<div className="flex flex-wrap items-end gap-3 mb-4 pb-3 border-b">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input type="date" value={fechaDesde} onChange={(e) => setFechaDesde(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input type="date" value={fechaHasta} onChange={(e) => setFechaHasta(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
</div>
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && declaraciones && declaraciones.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay declaraciones en el rango seleccionado.</p>
{canUpload && <p className="text-sm mt-1">Usa el botón "Subir declaración" para cargar la primera.</p>}
</div>
)}
{!isLoading && declaraciones && declaraciones.length > 0 && (
<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 pr-2 font-medium">Periodo</th>
<th className="pb-2 pr-2 font-medium">Tipo</th>
<th className="pb-2 pr-2 font-medium">Impuestos</th>
<th className="pb-2 pr-2 font-medium text-right">Monto</th>
<th className="pb-2 pr-2 font-medium">Declaración</th>
<th className="pb-2 pr-2 font-medium">Pago</th>
<th className="pb-2 pr-2 font-medium">Fecha subida</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{declaraciones.map((d) => {
const isPaidByAmount = d.montoPago === 0;
const isPaid = d.tienePagoPdf || isPaidByAmount;
return (
<tr key={d.id} className="hover:bg-muted/50">
<td className="py-3 pr-2 font-medium">{getPeriodLabel(d.periodicidad, d.mes)} {d.año}</td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${d.tipo === 'normal' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'}`}>
{d.tipo === 'normal' ? 'Normal' : 'Complementaria'}
</span>
</td>
<td className="py-3 pr-2">
<div className="flex flex-wrap gap-1">
{d.impuestos.map(i => <span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-muted">{i}</span>)}
</div>
</td>
<td className="py-3 pr-2 text-right font-mono text-sm">
{d.montoPago != null ? `$${d.montoPago.toLocaleString('es-MX', { minimumFractionDigits: 2 })}` : '—'}
</td>
<td className="py-3 pr-2">
<div className="flex flex-col gap-1">
{d.pdfFilename && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'declaracion', filename: d.pdfFilename! })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Declaración
</button>
)}
{d.tieneLigaPago && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'liga', filename: d.ligaPagoFilename || `liga-pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Liga de pago
</button>
)}
</div>
</td>
<td className="py-3 pr-2">
{d.tienePagoPdf ? (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'pago', filename: d.pdfPagoFilename || `pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400 hover:underline">
<CheckCircle2 className="h-3 w-3" /> Pagado
</button>
) : isPaidByAmount ? (
<span className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" /> $0 Sin pago
</span>
) : (
<span className="text-xs text-muted-foreground">Sin comprobante</span>
)}
</td>
<td className="py-3 pr-2 text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })}
</td>
<td className="py-3 pr-2">
<div className="flex items-center justify-end gap-1">
{canUpload && !isPaid && (
<Button variant="ghost" size="sm" onClick={() => setPagoDeclaracion(d)} title="Subir comprobante de pago">
<Receipt className="h-3.5 w-3.5" />
</Button>
)}
{canUpload && (
<Button variant="ghost" size="sm" onClick={() => { if (confirm('¿Eliminar esta declaración?')) deleteDecl.mutate(d.id); }} title="Eliminar">
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
{uploadOpen && <UploadDialog onClose={() => setUploadOpen(false)} />}
{pagoDeclaracion && <ComprobantePagoDialog declaracion={pagoDeclaracion} onClose={() => setPagoDeclaracion(null)} />}
</Card>
);
}
// ============================================================================
// Upload Dialog
// ============================================================================
function UploadDialog({ onClose }: { onClose: () => void }) {
const create = useCreateDeclaracion();
const { selectedContribuyenteId } = useContribuyenteStore();
const currentYear = new Date().getFullYear();
const [año, setAño] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
const [montoPago, setMontoPago] = useState('');
const [file, setFile] = useState<File | null>(null);
const [ligaFile, setLigaFile] = useState<File | null>(null);
const [notas, setNotas] = useState('');
const [err, setErr] = useState<string | null>(null);
const periodOptions = getPeriodOptions(periodicidad);
const handlePeriodicidadChange = (p: Periodicidad) => {
setPeriodicidad(p);
// Reset mes to first valid option for the new periodicidad
const opts = getPeriodOptions(p);
if (!opts.find(o => o.value === mes)) {
setMes(opts[0].value);
}
};
const toggleImpuesto = (i: Impuesto) => {
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF de la declaración');
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
try {
const pdfBase64 = await fileToBase64(file);
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
await create.mutateAsync({
año, mes, tipo, periodicidad, impuestos,
montoPago: montoNum,
pdfBase64, pdfFilename: file.name,
ligaPagoBase64,
ligaPagoFilename: ligaFile?.name,
notas: notas.trim() || undefined,
contribuyenteId: selectedContribuyenteId || undefined,
});
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subir declaración</DialogTitle>
<DialogDescription>
Al subir se marcarán como resueltos los recordatorios de declaración correspondientes. Si es complementaria, también los de pago.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className={`grid gap-3 ${periodicidad === 'anual' ? 'grid-cols-1' : 'grid-cols-2'}`}>
<div>
<Label>Periodicidad</Label>
<select value={periodicidad} onChange={(e) => handlePeriodicidadChange(e.target.value as Periodicidad)} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{PERIODICIDADES.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
{periodicidad !== 'anual' && (
<div>
<Label>Periodo</Label>
<select value={mes} onChange={(e) => setMes(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{periodOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Año</Label>
<select value={año} onChange={(e) => setAño(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{yearsOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<Label>Tipo</Label>
<select value={tipo} onChange={(e) => setTipo(e.target.value as 'normal' | 'complementaria')} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
<option value="normal">Normal</option>
<option value="complementaria">Complementaria</option>
</select>
</div>
<div>
<Label>Monto a pagar</Label>
<Input
type="number"
min="0"
step="0.01"
value={montoPago}
onChange={(e) => setMontoPago(e.target.value)}
placeholder="0.00"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">Si es $0.00, se marca como pagado automáticamente.</p>
</div>
</div>
<div>
<Label>Impuestos cubiertos</Label>
<div className="grid grid-cols-3 gap-2 mt-1">
{IMPUESTOS.map(i => (
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
{i}
</label>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p>
</div>
<div>
<Label>PDF de la declaración</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
<div>
<Label>PDF de la liga de pago (opcional)</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setLigaFile(e.target.files?.[0] || null)} className="mt-1" />
<p className="text-xs text-muted-foreground mt-1">Documento con la línea de captura/referencia para pagar la declaración.</p>
</div>
<div>
<Label>Notas (opcional)</Label>
<textarea value={notas} onChange={(e) => setNotas(e.target.value)} rows={2} maxLength={2000} className="w-full px-3 py-2 rounded-md border bg-background text-sm mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Comprobante de Pago Dialog
// ============================================================================
function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declaracion; onClose: () => void }) {
const upload = useUploadComprobantePago();
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF del comprobante de pago');
try {
const pdfBase64 = await fileToBase64(file);
await upload.mutateAsync({ id: declaracion.id, pdfBase64, pdfFilename: file.name });
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir comprobante de pago</DialogTitle>
<DialogDescription>
{MESES[declaracion.mes - 1]} {declaracion.año} {declaracion.tipo === 'normal' ? 'Normal' : 'Complementaria'}. Al subirlo se marcan como pagados los recordatorios de pago correspondientes.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div>
<Label>PDF del comprobante</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
// ============================================================================
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar'];
function ExtrasTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD_EXTRA.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
const qc = useQueryClient();
const [uploadOpen, setUploadOpen] = useState(false);
const [categoriaFiltro, setCategoriaFiltro] = useState<string>('');
const extrasQ = useQuery({
queryKey: ['documentos-extras', selectedContribuyenteId, categoriaFiltro],
queryFn: () => docsApi.listarExtras(selectedContribuyenteId, categoriaFiltro || null),
});
const categoriasQ = useQuery({
queryKey: ['documentos-extras-categorias', selectedContribuyenteId],
queryFn: () => docsApi.listarCategoriasExtras(selectedContribuyenteId),
});
const deleteMut = useMutation({
mutationFn: (id: number) => docsApi.eliminarExtra(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
},
});
const handleDelete = (id: number, nombre: string) => {
if (!confirm(`¿Eliminar "${nombre}"? Esta accion no se puede deshacer.`)) return;
deleteMut.mutate(id);
};
const handleDownload = async (id: number, filename: string) => {
try {
const blob = await docsApi.descargarExtraPdf(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
alert('Error al descargar el documento');
}
};
const extras = extrasQ.data || [];
const categorias = categoriasQ.data || [];
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-lg font-semibold">Documentos extras</h3>
{categorias.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<select
value={categoriaFiltro}
onChange={(e) => setCategoriaFiltro(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todas las categorias</option>
{categorias.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{canUpload && (
<Button onClick={() => setUploadOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-1.5" />
Subir PDF
</Button>
)}
</div>
{extrasQ.isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : extras.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{categoriaFiltro
? `No hay documentos en la categoria "${categoriaFiltro}"`
: 'Aun no hay documentos extras. Sube el primero con el boton de arriba.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Categoría</th>
<th className="pb-3 font-medium">Descripción</th>
<th className="pb-3 font-medium">Subido por</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{extras.map((e) => (
<tr key={e.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{e.nombre}</td>
<td className="py-3">
{e.categoria ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-muted text-muted-foreground">
<Tag className="h-3 w-3" />
{e.categoria}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3 max-w-[300px] truncate text-muted-foreground" title={e.descripcion || ''}>
{e.descripcion || '—'}
</td>
<td className="py-3 text-xs text-muted-foreground">{e.subidoPor || '—'}</td>
<td className="py-3 text-xs text-muted-foreground">
{new Date(e.createdAt).toLocaleDateString('es-MX')}
</td>
<td className="py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(e.id, e.pdfFilename)}
title="Descargar PDF"
>
<Download className="h-4 w-4" />
</Button>
{canUpload && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(e.id, e.nombre)}
disabled={deleteMut.isPending}
title="Eliminar"
className="hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{extras.length} documento{extras.length !== 1 ? 's' : ''}
</p>
</div>
)}
</CardContent>
{uploadOpen && (
<UploadExtraDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
contribuyenteId={selectedContribuyenteId}
categoriasExistentes={categorias}
/>
)}
</Card>
);
}
function UploadExtraDialog({
open, onClose, contribuyenteId, categoriasExistentes,
}: {
open: boolean;
onClose: () => void;
contribuyenteId: string | null;
categoriasExistentes: string[];
}) {
const qc = useQueryClient();
const [nombre, setNombre] = useState('');
const [descripcion, setDescripcion] = useState('');
const [categoria, setCategoria] = useState('');
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const upload = useMutation({
mutationFn: async () => {
if (!file) throw new Error('Selecciona un archivo PDF');
if (!nombre.trim()) throw new Error('El nombre es requerido');
const pdfBase64 = await fileToBase64(file);
return docsApi.crearExtra(
{
nombre: nombre.trim(),
descripcion: descripcion.trim() || undefined,
categoria: categoria.trim() || undefined,
pdfBase64,
pdfFilename: file.name,
},
contribuyenteId,
);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
onClose();
},
onError: (e: any) => {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
upload.mutate();
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir documento extra</DialogTitle>
<DialogDescription>
PDFs libres como acuses del SAT, contratos, poderes notariales, estados de cuenta, etc.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div>
<Label>Nombre *</Label>
<Input
value={nombre}
onChange={(e) => setNombre(e.target.value)}
placeholder="Ej. Acuse declaracion anual 2024"
className="mt-1"
maxLength={255}
/>
</div>
<div>
<Label>Categoría (opcional)</Label>
<Input
value={categoria}
onChange={(e) => setCategoria(e.target.value)}
placeholder="Ej. Acuses SAT, Contratos, Poderes..."
className="mt-1"
list="categorias-extras-list"
maxLength={100}
/>
{categoriasExistentes.length > 0 && (
<datalist id="categorias-extras-list">
{categoriasExistentes.map((c) => <option key={c} value={c} />)}
</datalist>
)}
</div>
<div>
<Label>Descripción (opcional)</Label>
<textarea
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Notas internas sobre el documento"
rows={3}
maxLength={2000}
className="mt-1 flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<Label>PDF *</Label>
<Input
type="file"
accept="application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mt-1"
/>
</div>
{err && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
{err}
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</>
) : (
<><Upload className="h-4 w-4 mr-1.5" /> Subir</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Comprobante', key: 'tipoComprobante', width: 12 },
{ header: 'Fecha Emision', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Monto Pago MXN', key: '_montoPagoMxn', width: 15 },
{ header: 'IVA Trasladado MXN', key: '_ivaMxn', width: 18 },
{ header: 'Metodo Pago', key: 'metodoPago', width: 12 },
{ header: 'Regimen Emisor', key: 'regimenEmisor', width: 15 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
}));
}
export default function DrillDownPage() {
const searchParams = useSearchParams();
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const params = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (key !== 'titulo') params.set(key, value);
}
// Respetar contribuyente seleccionado globalmente — así cualquier drillUrl
// construido desde dashboard/impuestos/etc queda automáticamente filtrado
// sin tener que acordarse de pasarlo en cada call-site. El URLSearchParams
// de entrada gana si el caller sí lo pasó explícitamente.
if (selectedContribuyenteId && !params.has('contribuyenteId')) {
params.set('contribuyenteId', selectedContribuyenteId);
}
const { data, isLoading } = useQuery({
queryKey: ['drill-down', params.toString()],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>(`/cfdi/drill-down?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
},
'fecha',
);
// Total con signo: tipo E resta (es una nota de crédito que reduce el bucket).
// Tipo I/N suman total_mxn; tipo P suma monto_pago_mxn (su total es 0 por convención
// del complemento). Así el total del header coincide con los KPIs del dashboard.
const totalMxn = data?.reduce((s, r) => {
const sign = r.tipoComprobante === 'E' ? -1 : 1;
const amount = r.tipoComprobante === 'P'
? Number(r.montoPagoMxn || 0)
: Number(r.totalMxn || 0);
return s + sign * amount;
}, 0) || 0;
const totalPagos = data?.reduce((s, r) => s + Number(r.montoPagoMxn || 0), 0) || 0;
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'drill-down-cfdis');
};
return (
<DashboardShell title={titulo}>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs que coincidan con los filtros</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">{data.length} CFDIs encontrados</p>
<div className="flex items-center gap-4 text-sm">
<span>Total MXN: <strong>{formatCurrency(totalMxn)}</strong></span>
{totalPagos > 0 && <span>Pagos MXN: <strong>{formatCurrency(totalPagos)}</strong></span>}
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Comp.</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<SortableHeader label="Monto Pago" align="right" active={getSortIndicator('pago')} onClick={() => toggleSort('pago')} />
<SortableHeader label="IVA Trasl." align="right" active={getSortIndicator('iva')} onClick={() => toggleSort('iva')} />
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3 font-medium">Reg. E</th>
<th className="pb-3 font-medium">Reg. R</th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => {
const isNC = cfdi.tipoComprobante === 'E';
return (
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
<td className="py-2 text-xs">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreReceptor}>{cfdi.nombreReceptor}</td>
<td className={cn('py-2 text-right text-xs font-medium', isNC && 'text-red-600 dark:text-red-400')}>{isNC ? '' : ''}{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-2 text-right text-xs">{cfdi.tipoComprobante === 'P' && cfdi.montoPagoMxn ? formatCurrency(Number(cfdi.montoPagoMxn)) : '-'}</td>
<td className="py-2 text-right text-xs">{cfdi.ivaTrasladoMxn ? formatCurrency(Number(cfdi.ivaTrasladoMxn)) : '-'}</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
<td className="py-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button } from '@horux/shared-ui';
import { useTimbres } from '@/lib/hooks/use-facturacion';
import { getPaquetesCatalogo, comprarPaquete } from '@/lib/api/facturacion';
import { formatCurrency } from '@/lib/utils';
import { Receipt, Zap, Package, ArrowLeft, ShoppingCart, Loader2, CheckCircle2, AlertTriangle, Calendar } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
export default function TimbresPage() {
const router = useRouter();
const { user } = useAuthStore();
const { data: timbres, isLoading } = useTimbres();
const { data: catalogo = [] } = useQuery({
queryKey: ['timbres-paquetes-catalogo'],
queryFn: getPaquetesCatalogo,
});
const [buying, setBuying] = useState<number | null>(null);
const canBuy = user?.role === 'owner' || user?.role === 'cfo';
const handleComprar = async (catalogoId: number) => {
if (!canBuy) return;
setBuying(catalogoId);
try {
const { checkoutUrl } = await comprarPaquete(catalogoId);
window.location.href = checkoutUrl;
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al iniciar compra');
setBuying(null);
}
};
const mensualDisp = timbres?.mensual?.disponibles ?? (timbres?.disponibles ?? 0);
const mensualTotal = timbres?.mensual?.limite ?? (timbres?.limite ?? 0);
const adicionales = timbres?.adicionales;
return (
<>
<Header title="Timbres adicionales">
<button onClick={() => router.push('/facturacion')} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" /> Volver a facturación
</button>
</Header>
<main className="p-6 space-y-6">
{/* Status actual */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" /> Plan mensual
</CardTitle>
<CardDescription className="text-xs">Se resetea cada mes. No acumulable.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{mensualDisp}</div>
<p className="text-xs text-muted-foreground">de {mensualTotal} disponibles</p>
{timbres?.mensual?.periodoFin && (
<p className="text-xs text-muted-foreground mt-1">
Renueva: {new Date(timbres.mensual.periodoFin).toLocaleDateString('es-MX')}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4 text-blue-500" /> Adicionales
</CardTitle>
<CardDescription className="text-xs">Comprados. Vigencia 1 año c/u.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{adicionales?.disponibles ?? 0}</div>
<p className="text-xs text-muted-foreground">
de {adicionales?.total ?? 0} ({adicionales?.usados ?? 0} usados)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Receipt className="h-4 w-4 text-green-600" /> Total disponible
</CardTitle>
<CardDescription className="text-xs">Suma mensual + adicionales.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{timbres?.totalDisponibles ?? mensualDisp}</div>
<p className="text-xs text-muted-foreground">timbres listos para emitir</p>
</CardContent>
</Card>
</div>
{/* Explicación de orden de consumo */}
<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>
<strong>Orden de consumo:</strong> cada factura emitida descuenta primero
de tus timbres mensuales del plan. Solo cuando estos se agoten empieza a
consumir de tus paquetes adicionales, comenzando por los más próximos a
vencer para no desperdiciarlos.
</div>
</CardContent>
</Card>
{/* Catálogo de paquetes */}
<div>
<h2 className="text-lg font-semibold mb-3">Comprar paquetes</h2>
{!canBuy && (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3">
Solo el dueño o CFO de la empresa pueden comprar paquetes adicionales.
</div>
)}
<div className="grid gap-4 md:grid-cols-3">
{catalogo.map(p => (
<Card key={p.id} className="flex flex-col">
<CardHeader>
<CardTitle className="text-2xl">
{p.cantidad.toLocaleString('es-MX')}
<span className="text-sm font-normal text-muted-foreground ml-1">timbres</span>
</CardTitle>
<CardDescription>Vigencia 1 año desde la compra</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between">
<div>
<div className="text-3xl font-bold">{formatCurrency(p.precio)}</div>
<p className="text-xs text-muted-foreground">
{formatCurrency(p.precio / p.cantidad)} por timbre · IVA incluido
</p>
</div>
<Button
className="mt-4 w-full"
onClick={() => handleComprar(p.id)}
disabled={!canBuy || buying !== null}
>
{buying === p.id ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Redirigiendo...</>
) : (
<><ShoppingCart className="h-4 w-4 mr-2" /> Comprar</>
)}
</Button>
</CardContent>
</Card>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">
Al completar el pago en MercadoPago, tu factura se emitirá automáticamente.
</p>
</div>
{/* Paquetes vigentes */}
{adicionales && adicionales.paquetes.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">Paquetes vigentes</h2>
<Card>
<CardContent className="pt-6">
<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">Usados</th>
<th className="pb-2 font-medium">Disponibles</th>
<th className="pb-2 font-medium">Adquirido</th>
<th className="pb-2 font-medium">Expira</th>
</tr>
</thead>
<tbody>
{adicionales.paquetes.map(p => {
const expira = new Date(p.expiraEn);
const diasRestantes = Math.floor((expira.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.cantidad.toLocaleString('es-MX')}</td>
<td className="py-2">{p.usados.toLocaleString('es-MX')}</td>
<td className="py-2 font-medium">{p.disponibles.toLocaleString('es-MX')}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(p.adquiridoEn).toLocaleDateString('es-MX')}
</td>
<td className="py-2 text-xs">
<span className={diasRestantes < 30 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
<Calendar className="h-3 w-3 inline mr-1" />
{expira.toLocaleDateString('es-MX')} ({diasRestantes} días)
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)}
{/* Success banner post-MP */}
{typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('timbres') === 'success' && (
<Card className="border-green-200 bg-green-50">
<CardContent className="py-4 flex items-start gap-2 text-sm text-green-800">
<CheckCircle2 className="h-5 w-5 flex-shrink-0" />
<div>
<strong>Pago recibido.</strong> Tu paquete se activará en cuanto MercadoPago confirme la transacción (~1-2 minutos). Recarga la página si no lo ves enseguida.
</div>
</CardContent>
</Card>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,858 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '@horux/shared-ui';
import { KpiCard, PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useResumenIsrDesglosado, useCoeficiente } from '@/lib/hooks/use-impuestos';
import { setCoeficiente as setCoeficienteApi } from '@/lib/api/impuestos';
import { useQueryClient } from '@tanstack/react-query';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { Calculator, TrendingUp, TrendingDown, Receipt, Settings, Wallet, CheckSquare, Download } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
import { exportToExcel } from '@/lib/export-excel';
import { ActivosFijosTab } from '@/components/impuestos/activos-fijos-tab';
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
export default function ImpuestosPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [activeTab, setActiveTab] = useState<'iva' | 'isr' | 'activos-fijos'>('iva');
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [conciliacion, setConciliacion] = useState(false);
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
const mes = new Date(fechaInicio + 'T00:00:00').getMonth() + 1;
const queryClient = useQueryClient();
const [coefInput, setCoefInput] = useState('');
const [savingCoef, setSavingCoef] = useState(false);
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año, conciliacion, considerarActivos, considerarNCs);
const { data: isrMensual, isLoading: isrLoading } = useIsrMensual(año, conciliacion, regimenSeleccionado, considerarActivos, considerarNCs);
const { data: resumenIva } = useResumenIva(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: coefData } = useCoeficiente(año);
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin, conciliacion);
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
const drillUrl = (titulo: string, filters: Record<string, string>) => {
const p = new URLSearchParams({ titulo, fechaInicio, fechaFin, status: 'vigente', ...filters });
// Propaga los toggles de "Considerar activos" / "Considerar NCs" para que
// el drill aplique los mismos filtros que las cards (sin esto, el drill
// mostraba CFDIs ya excluidos del total).
if (!considerarActivos) p.set('considerarActivos', '0');
if (!considerarNCs) p.set('considerarNCs', '0');
if (regimenSeleccionado) {
// Por type explícito (raros — IVA legacy paths)
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
// Por bucket (la mayoría de KPIs nuevos). 605 es receptor en bucket=ingresos
// (nómina recibida); el resto va por emisor para ingresos/causado, receptor
// para gastos/acreditable.
else if (filters.bucket === 'ingresos') {
if (regimenSeleccionado === '605') p.set('regimenReceptor', '605');
else p.set('regimenEmisor', regimenSeleccionado);
}
else if (filters.bucket === 'causado' || filters.bucket === 'ncs_emitidas') p.set('regimenEmisor', regimenSeleccionado);
else if (filters.bucket === 'gastos' || filters.bucket === 'acreditable' || filters.bucket === 'ncs_recibidas' || filters.bucket === 'no_deducibles_efectivo') p.set('regimenReceptor', regimenSeleccionado);
}
return `/drill-down?${p}`;
};
return (
<>
<Header title="Control de Impuestos">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi, ff) => { setFechaInicio(fi); setFechaFin(ff); }}
/>
</Header>
<main className="p-6 space-y-6">
{/* Filtros */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<button
onClick={() => setConciliacion(!conciliacion)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
conciliacion
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
>
<CheckSquare className="h-4 w-4" />
Conciliación
</button>
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
<button
onClick={() => setConsiderarNCs(!considerarNCs)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarNCs
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, se excluyen TODAS las facturas tipo E (cualquier tipo de relación) y se omite la compensación I/07 PPD ↔ E."
>
<CheckSquare className="h-4 w-4" />
Considerar NCs
</button>
</div>
</div>
<div className="flex gap-2">
<Button
variant={activeTab === 'iva' ? 'default' : 'outline'}
onClick={() => setActiveTab('iva')}
>
<Receipt className="h-4 w-4 mr-2" />
IVA
</Button>
<Button
variant={activeTab === 'isr' ? 'default' : 'outline'}
onClick={() => setActiveTab('isr')}
>
<Calculator className="h-4 w-4 mr-2" />
ISR
</Button>
<Button
variant={activeTab === 'activos-fijos' ? 'default' : 'outline'}
onClick={() => setActiveTab('activos-fijos')}
>
<Wallet className="h-4 w-4 mr-2" />
Activos Fijos
</Button>
</div>
{activeTab === 'iva' && (
<>
{/* IVA KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<KpiCard
title={regimenSeleccionado ? `IVA Trasladado (${regimenSeleccionado})` : 'IVA Trasladado'}
value={
regimenSeleccionado
? resumenIva?.trasladadoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.trasladado || 0
}
icon={<TrendingUp className="h-4 w-4" />}
subtitle="Cobrado a clientes"
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
/>
<KpiCard
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
value={
regimenSeleccionado
? resumenIva?.acreditablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.acreditable || 0
}
icon={<TrendingDown className="h-4 w-4" />}
subtitle="Pagado a proveedores"
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
/>
{(() => {
const val = regimenSeleccionado
? resumenIva?.retenidoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.retenido || 0;
return (
<KpiCard
title={regimenSeleccionado ? `IVA Retenido (${regimenSeleccionado})` : 'IVA Retenido'}
value={val}
icon={<Receipt className="h-4 w-4" />}
trend={val > 0 ? 'up' : val < 0 ? 'down' : 'neutral'}
trendValue={val > 0 ? 'A favor' : val < 0 ? 'En contra' : 'Neutro'}
/>
);
})()}
{(() => {
const t = regimenSeleccionado
? resumenIva?.trasladadoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.trasladado || 0;
const a = regimenSeleccionado
? resumenIva?.acreditablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.acreditable || 0;
const ret = regimenSeleccionado
? resumenIva?.retenidoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.retenido || 0;
const res = t - a - ret;
return (
<KpiCard
title={regimenSeleccionado ? `Resultado (${regimenSeleccionado})` : 'Resultado del Periodo'}
value={res}
icon={<Calculator className="h-4 w-4" />}
trend={res > 0 ? 'up' : res < 0 ? 'down' : 'neutral'}
trendValue={res > 0 ? 'Por pagar' : res < 0 ? 'A favor' : 'Neutro'}
/>
);
})()}
<KpiCard
title="Acumulado Anual"
value={resumenIva?.acumuladoAnual || 0}
icon={<Receipt className="h-4 w-4" />}
trend={(resumenIva?.acumuladoAnual || 0) < 0 ? 'up' : 'neutral'}
trendValue={(resumenIva?.acumuladoAnual || 0) < 0 ? 'Saldo a favor' : ''}
/>
{(() => {
const v = regimenSeleccionado
? (resumenIva?.ivaNoAcreditableEfectivoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto ?? 0)
: (resumenIva?.ivaNoAcreditableEfectivo ?? 0);
return (
<KpiCard
title={regimenSeleccionado ? `IVA No Acreditable (${regimenSeleccionado})` : 'IVA No Acreditable'}
value={v}
icon={<TrendingDown className="h-4 w-4" />}
subtitle="Efectivo > $2,000"
/>
);
})()}
</div>
{/* IVA Mensual Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Histórico IVA {año}</CardTitle>
{ivaMensual && ivaMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
ivaMensual.map(r => ({
Mes: meses[r.mes - 1],
Trasladado: r.ivaTrasladado,
Acreditable: r.ivaAcreditable,
Retenido: r.ivaRetenido,
Resultado: r.resultado,
Acumulado: r.acumulado,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Trasladado', key: 'Trasladado', width: 18 },
{ header: 'Acreditable', key: 'Acreditable', width: 18 },
{ header: 'Retenido', key: 'Retenido', width: 18 },
{ header: 'Resultado', key: 'Resultado', width: 18 },
{ header: 'Acumulado', key: 'Acumulado', width: 18 },
],
`iva-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
</CardHeader>
<CardContent>
{ivaLoading ? (
<div className="text-center py-8 text-muted-foreground">
Cargando...
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Trasladado</th>
<th className="pb-3 font-medium text-right">Acreditable</th>
<th className="pb-3 font-medium text-right">Retenido</th>
<th className="pb-3 font-medium text-right">Resultado</th>
<th className="pb-3 font-medium text-right">Acumulado</th>
<th className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="text-sm">
{ivaMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaTrasladado)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaAcreditable)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaRetenido)}
</td>
<td
className={`py-3 text-right font-medium ${
row.resultado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.resultado)}
</td>
<td
className={`py-3 text-right font-medium ${
row.acumulado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.acumulado)}
</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
row.estado === 'declarado'
? 'bg-success/10 text-success'
: 'bg-warning/10 text-warning'
}`}
>
{row.estado === 'declarado' ? 'Declarado' : 'Pendiente'}
</span>
</td>
</tr>
))}
{(!ivaMensual || ivaMensual.length === 0) && (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
No hay registros de IVA para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'isr' && (
<>
{/* ISR KPIs */}
{(() => {
const bg = regimenSeleccionado
? resumenIsr?.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)
: null;
const showUtilidad = true;
const ingSel = regimenSeleccionado
? resumenIsr?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIsr?.ingresosAcumulados || 0;
const dedSel = regimenSeleccionado
? resumenIsr?.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIsr?.deducciones || 0;
// ISR a pagar filtered by regime
const bgSelForKpi = regimenSeleccionado
? resumenIsr?.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)
: null;
const isrCausadoSel = regimenSeleccionado
? (bgSelForKpi?.isrCausado || 0)
: resumenIsr?.isrCausado || 0;
const isrRetenidoSel = regimenSeleccionado ? 0 : resumenIsr?.isrRetenido || 0;
const isrAPagarSel = Math.max(0, isrCausadoSel - isrRetenidoSel);
const ncsEmSel = regimenSeleccionado
? (resumenIsr?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto ?? 0)
: (resumenIsr?.ncsEmitidas ?? 0);
const ncsRecSel = regimenSeleccionado
? (resumenIsr?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto ?? 0)
: (resumenIsr?.ncsRecibidas ?? 0);
const noDedSel = regimenSeleccionado
? (resumenIsr?.gastosNoDeduciblesEfectivoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto ?? 0)
: (resumenIsr?.gastosNoDeduciblesEfectivo ?? 0);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<KpiCard
title={regimenSeleccionado ? `Ingresos ISR (${regimenSeleccionado})` : 'Ingresos Nominales'}
value={ingSel}
icon={<TrendingUp className="h-4 w-4" />}
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
/>
<KpiCard
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
value={ncsEmSel}
icon={<TrendingDown className="h-4 w-4" />}
href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })}
/>
<KpiCard
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
value={dedSel}
icon={<TrendingDown className="h-4 w-4" />}
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
/>
<KpiCard
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
value={ncsRecSel}
icon={<TrendingUp className="h-4 w-4" />}
href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })}
/>
<KpiCard
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
value={regimenSeleccionado ? (bg?.baseGravable ?? 0) : resumenIsr?.baseGravable || 0}
icon={<Calculator className="h-4 w-4" />}
subtitle={bg ? (bg.formula === 'ingresos-deducciones' ? 'Ingresos - Deducciones' : 'Solo ingresos') : undefined}
/>
<KpiCard
title={regimenSeleccionado ? `ISR a Pagar (${regimenSeleccionado})` : 'ISR a Pagar'}
value={isrAPagarSel}
icon={<Receipt className="h-4 w-4" />}
trend={isrAPagarSel > 0 ? 'up' : 'neutral'}
/>
{(() => {
// Utilidad del Periodo: simétrica con la fórmula de base gravable.
// ingresoNeto = ingresos ncsEmitidas
// deducciónNeta = deducciones ncsRecibidas
// utilidad = ingresoNeto deducciónNeta
// = ingresos ncsEm ded + ncsRec
// Sin clamp a 0 — puede ser negativa (refleja pérdida operativa real).
const utilidad = ingSel - ncsEmSel - dedSel + ncsRecSel;
return (
<KpiCard
title={regimenSeleccionado ? `Utilidad del Periodo (${regimenSeleccionado})` : 'Utilidad del Periodo'}
value={utilidad}
icon={<Wallet className="h-4 w-4" />}
trend={utilidad > 0 ? 'up' : 'down'}
/>
);
})()}
<KpiCard
title={regimenSeleccionado ? `No Deducibles (${regimenSeleccionado})` : 'No Deducibles'}
value={noDedSel}
icon={<TrendingDown className="h-4 w-4" />}
subtitle="Efectivo > $2,000"
href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })}
/>
</div>
);
})()}
{/* ISR Info + Coeficiente */}
{(() => {
// Regímenes PF no usan coeficiente de utilidad
const REGIMENES_PF = ['605', '606', '612', '621', '625'];
const isResicoPF = regimenSeleccionado === '626'; // RESICO PF also doesn't use coeficiente
const showCoeficiente = !regimenSeleccionado || (!REGIMENES_PF.includes(regimenSeleccionado) && !isResicoPF);
return (
<div className={`grid gap-4 ${showCoeficiente ? 'lg:grid-cols-3' : ''}`}>
<Card className={showCoeficiente ? 'lg:col-span-2' : ''}>
<CardHeader>
<CardTitle className="text-base">Cálculo de ISR del Periodo</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const desglose = resumenIsrDesglose;
if (!desglose) {
return <div className="text-sm text-muted-foreground">Cargando</div>;
}
const { delPeriodo, anteriores, total, mesFinal, anio } = desglose;
const labelMesFinal = `${meses[mesFinal - 1]} ${anio}`;
const labelAnteriores =
mesFinal === 1
? '(sin meses anteriores)'
: mesFinal === 2
? `(${meses[0]})`
: `(${meses[0]}-${meses[mesFinal - 2]})`;
// Resolver per-régimen si hay régimen seleccionado, igual patrón que antes.
const ingPer = regimenSeleccionado
? delPeriodo.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ingresosAcumulados || 0;
const ingAnt = regimenSeleccionado
? anteriores.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ingresosAcumulados || 0;
const dedPer = regimenSeleccionado
? delPeriodo.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.deducciones || 0;
const dedAnt = regimenSeleccionado
? anteriores.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.deducciones || 0;
const ncsEmPer = regimenSeleccionado
? delPeriodo.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ncsEmitidas || 0;
const ncsEmAnt = regimenSeleccionado
? anteriores.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ncsEmitidas || 0;
const ncsRecPer = regimenSeleccionado
? delPeriodo.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ncsRecibidas || 0;
const ncsRecAnt = regimenSeleccionado
? anteriores.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ncsRecibidas || 0;
const bgTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.baseGravable || 0
: total.baseGravable || 0;
const causadoTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.isrCausado || 0
: total.isrCausado || 0;
const retenido = total.isrRetenido || 0;
const aPagar = Math.max(0, causadoTotal - (regimenSeleccionado ? 0 : retenido));
// Determina si las NCs aplican al cálculo de base gravable.
// Solo regímenes con formula='ingresos-deducciones' (606, 612,
// 626 RESICO PM) ajustan por NCs. Cuando no hay régimen
// seleccionado (vista agregada), mostramos si hay NCs > 0
// porque el total puede mezclar regímenes.
const formulaSel = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.formula
: null;
const showNcs = regimenSeleccionado
? formulaSel === 'ingresos-deducciones'
: (ncsEmPer + ncsEmAnt + ncsRecPer + ncsRecAnt) > 0;
return (
<div className="space-y-1">
{/* Bloque de Ingresos */}
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Ingresos del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ingPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(+) Ingresos acumulados anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ingAnt)}</span>
</div>
{showNcs && (
<>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() NCs Emitidas del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ncsEmPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() NCs Emitidas acumuladas {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ncsEmAnt)}</span>
</div>
</>
)}
<div className="flex justify-between py-2 border-b bg-muted/30 px-2 rounded">
<span className="font-semibold">Total Ingresos</span>
<span className="font-semibold">{formatCurrency(ingPer + ingAnt - (showNcs ? (ncsEmPer + ncsEmAnt) : 0))}</span>
</div>
{/* Bloque de Deducciones */}
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(dedPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones acumuladas anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(dedAnt)}</span>
</div>
{showNcs && (
<>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() NCs Recibidas del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ncsRecPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() NCs Recibidas acumuladas {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ncsRecAnt)}</span>
</div>
</>
)}
<div className="flex justify-between py-2 border-b bg-muted/30 px-2 rounded">
<span className="font-semibold">Total Deducciones</span>
<span className="font-semibold">{formatCurrency(dedPer + dedAnt - (showNcs ? (ncsRecPer + ncsRecAnt) : 0))}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="font-medium">(=) Base gravable acumulada</span>
<span className={cn('font-medium', bgTotal < 0 ? 'text-destructive' : '')}>
{formatCurrency(bgTotal)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">ISR causado (acumulado)</span>
<span className="font-medium">{formatCurrency(causadoTotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() ISR retenido (acumulado)</span>
<span className="font-medium">{formatCurrency(regimenSeleccionado ? 0 : retenido)}</span>
</div>
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg mt-2">
<span className="font-medium">ISR a pagar</span>
<span className="font-bold text-lg">{formatCurrency(aPagar)}</span>
</div>
</div>
);
})()}
</CardContent>
</Card>
{showCoeficiente && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Settings className="h-4 w-4" />
Coeficiente de Utilidad {año}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Se utiliza para calcular el ISR de regimenes como General de Ley (601) y otros.
</p>
<div className="space-y-2">
<label className="text-sm font-medium">Coeficiente actual</label>
{coefData?.coeficiente !== null && coefData?.coeficiente !== undefined ? (
<p className="text-2xl font-bold">{coefData.coeficiente}</p>
) : (
<p className="text-sm text-destructive">No configurado</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Actualizar coeficiente</label>
<div className="flex gap-2">
<Input
type="number"
step="0.0001"
min="0"
max="1"
placeholder="Ej: 0.3521"
value={coefInput}
onChange={(e) => setCoefInput(e.target.value)}
className="h-9"
/>
<Button
size="sm"
disabled={!coefInput || savingCoef}
onClick={async () => {
const val = parseFloat(coefInput);
if (isNaN(val) || val < 0 || val > 1) return;
setSavingCoef(true);
try {
await setCoeficienteApi(año, val);
setCoefInput('');
queryClient.invalidateQueries({ queryKey: ['coeficiente'] });
queryClient.invalidateQueries({ queryKey: ['isr-resumen'] });
} catch {
alert('Error al guardar');
} finally {
setSavingCoef(false);
}
}}
>
{savingCoef ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Este valor se obtiene de la declaracion anual del ejercicio anterior. No se sobrescribe entre años.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
})()}
{/* Tabla 1: Histórico SIN NCs (datos brutos) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Histórico Ingresos y Deducciones sin NCs {año}</CardTitle>
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
'Ingresos Acumulados': r.ingresosAcum,
Deducciones: r.deducciones,
'Deducciones Acumuladas': r.deduccionesAcum,
'Base Gravable Acumulada': r.baseGravableAcum,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
],
`isr-sin-ncs-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
</CardHeader>
<CardContent>
{isrLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
</tr>
</thead>
<tbody className="text-sm">
{isrMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcumulados)}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="py-3 text-right">{formatCurrency(row.deducciones)}</td>
<td className="py-3 text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
'py-3 text-right font-medium',
row.baseGravableAcum < 0 ? 'text-destructive' : ''
)}>
{formatCurrency(row.baseGravableAcum)}
</td>
</tr>
))}
{(!isrMensual || isrMensual.length === 0) && (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
No hay registros de ISR para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Tabla 2: Histórico CON NCs (valores netos: ing ncsEm, ded ncsRec) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Histórico Ingresos y Deducciones {año}</CardTitle>
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados - (r.ncsEmitidas ?? 0),
'Ingresos Acumulados': r.ingresosAcum - (r.ncsEmitidasAcum ?? 0),
Deducciones: r.deducciones - (r.ncsRecibidas ?? 0),
'Deducciones Acumuladas': r.deduccionesAcum - (r.ncsRecibidasAcum ?? 0),
'Base Gravable Acumulada': (r.ingresosAcum - (r.ncsEmitidasAcum ?? 0)) - (r.deduccionesAcum - (r.ncsRecibidasAcum ?? 0)),
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
],
`isr-con-ncs-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
</CardHeader>
<CardContent>
{isrLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
</tr>
</thead>
<tbody className="text-sm">
{isrMensual?.map((row) => {
// `?? 0` defensivo: si el backend (en respuesta cacheada por react-query
// o por algún motivo de versión) no incluye estos campos, retornan 0
// en lugar de NaN al restar.
const ncsEm = row.ncsEmitidas ?? 0;
const ncsRec = row.ncsRecibidas ?? 0;
const ncsEmAcum = row.ncsEmitidasAcum ?? 0;
const ncsRecAcum = row.ncsRecibidasAcum ?? 0;
const ingNet = row.ingresosAcumulados - ncsEm;
const ingAcumNet = row.ingresosAcum - ncsEmAcum;
const dedNet = row.deducciones - ncsRec;
const dedAcumNet = row.deduccionesAcum - ncsRecAcum;
const baseNet = ingAcumNet - dedAcumNet;
return (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">{formatCurrency(ingNet)}</td>
<td className="py-3 text-right">{formatCurrency(ingAcumNet)}</td>
<td className="py-3 text-right">{formatCurrency(dedNet)}</td>
<td className="py-3 text-right">{formatCurrency(dedAcumNet)}</td>
<td className={cn(
'py-3 text-right font-medium',
baseNet < 0 ? 'text-destructive' : ''
)}>
{formatCurrency(baseNet)}
</td>
</tr>
);
})}
{(!isrMensual || isrMensual.length === 0) && (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
No hay registros de ISR para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'activos-fijos' && (
<ActivosFijosTab año={año} mes={mes} />
)}
<FiscalDisclaimer />
</main>
</>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { themes } from '@/themes';
import { Sidebar } from '@/components/layouts/sidebar';
import { TopNav } from '@/components/layouts/topnav';
import { SidebarCompact } from '@/components/layouts/sidebar-compact';
import { SidebarFloating } from '@/components/layouts/sidebar-floating';
import { SubscriptionBanner } from '@/components/subscription-banner';
import { cn } from '@horux/shared-ui';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const { theme } = useThemeStore();
const currentTheme = themes[theme];
const layout = currentTheme.layout;
useEffect(() => {
// Solo verificar autenticación después de que el store se rehidrate
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
// Mostrar loading mientras se rehidrata el store
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Cargando...</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
// Render layout based on theme
const renderNavigation = () => {
switch (layout) {
case 'topnav':
return <TopNav />;
case 'sidebar-compact':
return <SidebarCompact />;
case 'sidebar-floating':
return <SidebarFloating />;
case 'sidebar-standard':
default:
return <Sidebar />;
}
};
const getContentClasses = () => {
switch (layout) {
case 'topnav':
return 'pt-16'; // Top padding for fixed top nav
case 'sidebar-compact':
return 'pl-16'; // Small left padding for compact sidebar
case 'sidebar-floating':
return 'pl-72 pr-4 py-4'; // Padding for floating sidebar
case 'sidebar-standard':
default:
return 'pl-64'; // Standard sidebar width
}
};
return (
<div className={cn(
'min-h-screen bg-background',
layout === 'sidebar-floating' && 'bg-gradient-to-br from-background via-background to-muted/20'
)}>
{renderNavigation()}
<div className={getContentClasses()}>
<SubscriptionBanner />
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { getMyTenants, addMyTenant, type MyTenantDetailed } from '@/lib/api/tenants';
import { switchTenant } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { Building2, Plus, Crown, ArrowRight, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
custom: 'Custom',
enterprise: 'Enterprise',
};
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
authorized: { label: 'Activa', className: 'bg-green-100 text-green-700 border-green-200' },
trial: { label: 'Prueba', className: 'bg-blue-100 text-blue-700 border-blue-200' },
trial_converted: { label: 'Activa', className: 'bg-green-100 text-green-700 border-green-200' },
trial_expired: { label: 'Prueba vencida', className: 'bg-amber-100 text-amber-700 border-amber-200' },
pending: { label: 'Pendiente de pago', className: 'bg-amber-100 text-amber-700 border-amber-200' },
paused: { label: 'Pausada', className: 'bg-slate-100 text-slate-700 border-slate-200' },
cancelled: { label: 'Cancelada', className: 'bg-red-100 text-red-700 border-red-200' },
};
export default function MisEmpresasPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { user, setUser, setTokens } = useAuthStore();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState({ nombre: '', rfc: '', plan: 'mi_empresa' as const });
const [error, setError] = useState<string | null>(null);
const { data: tenants = [], isLoading } = useQuery({
queryKey: ['my-tenants'],
queryFn: getMyTenants,
});
const addMutation = useMutation({
mutationFn: addMyTenant,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-tenants'] });
setAddOpen(false);
setForm({ nombre: '', rfc: '', plan: 'mi_empresa' });
setError(null);
},
onError: (err: any) => {
setError(err?.response?.data?.message || 'Error al agregar empresa');
},
});
const handleSwitch = async (tenantId: string) => {
if (tenantId === user?.tenantId) {
router.push('/dashboard');
return;
}
try {
const res = await switchTenant(tenantId);
setTokens(res.accessToken, res.refreshToken);
setUser(res.user);
queryClient.clear();
router.push('/dashboard');
window.location.reload();
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al cambiar de empresa');
}
};
const handleGoSuscripcion = async (tenantId: string) => {
if (tenantId !== user?.tenantId) {
await handleSwitch(tenantId);
// tras el reload el router pierde control
return;
}
router.push('/configuracion/suscripcion');
};
return (
<>
<Header title="Mis empresas" />
<main className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground max-w-2xl">
Empresas que tienes bajo tu cuenta. Cada empresa tiene su propia suscripción
y datos fiscales. Usa el dropdown del header o el botón "Ir a esta empresa"
para cambiar de contexto.
</p>
<Button onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Agregar empresa
</Button>
</div>
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
) : tenants.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-muted-foreground">No tienes empresas registradas.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{tenants.map(t => (
<TenantCard
key={t.tenantId}
tenant={t}
isActive={t.tenantId === user?.tenantId}
onSwitch={() => handleSwitch(t.tenantId)}
onGoSuscripcion={() => handleGoSuscripcion(t.tenantId)}
/>
))}
</div>
)}
<Dialog open={addOpen} onOpenChange={(open) => { if (!addMutation.isPending) setAddOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar empresa</DialogTitle>
<DialogDescription>
Registra una empresa adicional bajo tu cuenta. Te volverás owner automáticamente.
Al terminar, se te redirigirá a la página de contratación de plan.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
addMutation.mutate(form);
}}
className="space-y-4 py-2"
>
<div className="space-y-1">
<Label htmlFor="nombre">Nombre de la empresa</Label>
<Input
id="nombre"
value={form.nombre}
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
required
minLength={2}
/>
</div>
<div className="space-y-1">
<Label htmlFor="rfc">RFC</Label>
<Input
id="rfc"
value={form.rfc}
onChange={(e) => setForm({ ...form, rfc: e.target.value.toUpperCase() })}
required
minLength={12}
maxLength={13}
className="font-mono uppercase"
placeholder="XAXX010101000"
/>
</div>
<div className="space-y-1">
<Label htmlFor="plan">Plan inicial</Label>
<Select value={form.plan} onValueChange={(v) => setForm({ ...form, plan: v as any })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mi_empresa">Mi Empresa</SelectItem>
<SelectItem value="mi_empresa_plus">Mi Empresa +</SelectItem>
<SelectItem value="business_control">Business Control</SelectItem>
<SelectItem value="business_cloud">Enterprise</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Se contratará desde la nueva empresa. Sin prueba los RFCs adicionales requieren plan directo.</p>
</div>
{error && (
<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>{error}</span>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddOpen(false)} disabled={addMutation.isPending}>
Cancelar
</Button>
<Button type="submit" disabled={addMutation.isPending}>
{addMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Crear empresa
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</>
);
}
interface TenantCardProps {
tenant: MyTenantDetailed;
isActive: boolean;
onSwitch: () => void;
onGoSuscripcion: () => void;
}
function TenantCard({ tenant, isActive, onSwitch, onGoSuscripcion }: TenantCardProps) {
const sub = tenant.subscription;
const statusBadge = sub ? STATUS_BADGES[sub.status] || { label: sub.status, className: 'bg-slate-100 text-slate-700 border-slate-200' } : null;
return (
<Card className={isActive ? 'border-primary/50' : ''}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<span className="truncate">{tenant.nombre}</span>
{tenant.isOwner && <Crown className="h-4 w-4 text-amber-500 flex-shrink-0" />}
</CardTitle>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{tenant.rfc}</p>
</div>
{isActive && (
<span className="text-xs text-primary font-medium flex items-center gap-1 flex-shrink-0">
<CheckCircle2 className="h-3 w-3" /> Activa
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Plan</p>
<p className="font-medium">{PLAN_LABELS[tenant.plan] || tenant.plan}</p>
</div>
{statusBadge && (
<div>
<p className="text-xs text-muted-foreground">Estado</p>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</div>
)}
{sub?.amount && sub.amount > 0 && (
<div>
<p className="text-xs text-muted-foreground">{sub.frequency === 'annual' ? 'Anual' : 'Mensual'}</p>
<p className="font-medium">{formatCurrency(sub.amount)}</p>
</div>
)}
</div>
{sub?.currentPeriodEnd && (
<p className="text-xs text-muted-foreground">
Próximo cobro: {new Date(sub.currentPeriodEnd).toLocaleDateString('es-MX')}
</p>
)}
{sub?.pendingPlan && sub?.pendingEffectiveAt && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
Cambio a {PLAN_LABELS[sub.pendingPlan] || sub.pendingPlan} programado para {new Date(sub.pendingEffectiveAt).toLocaleDateString('es-MX')}
</p>
)}
<div className="flex gap-2 pt-1">
{!isActive && (
<Button size="sm" variant="outline" onClick={onSwitch} className="flex-1">
Ir a esta empresa <ArrowRight className="h-3 w-3 ml-1" />
</Button>
)}
<Button size="sm" variant={isActive ? 'default' : 'ghost'} onClick={onGoSuscripcion} className={isActive ? 'flex-1' : ''}>
Ver suscripción
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Card, CardContent, cn } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { apiClient } from '@/lib/api/client';
import { dismissOnboarding } from '@/lib/api/auth';
import { CheckCircle2, ArrowRight, Building2, Key, FileText, Users, CreditCard } from 'lucide-react';
interface Step {
id: string;
title: string;
description: string;
icon: React.ReactNode;
href: string;
completed: boolean;
optional?: boolean;
}
export default function OnboardingPage() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const { data: contribuyentes } = useContribuyentes();
const router = useRouter();
const [fielDone, setFielDone] = useState(false);
const [csdDone, setCsdDone] = useState(false);
const [dismissed, setDismissed] = useState(false);
const hasContribuyentes = (contribuyentes?.length ?? 0) > 0;
const firstContribId = contribuyentes?.[0]?.id;
// Check FIEL + Facturapi status — try per-contribuyente first, fallback to legacy tenant-level
useEffect(() => {
if (!firstContribId) return;
// FIEL: check per-contribuyente (tenant BD) then legacy (central BD)
apiClient.get(`/contribuyentes/${firstContribId}/fiel/status`)
.then(({ data }) => {
if (data.configured) {
setFielDone(true);
} else {
// Fallback: check legacy tenant-level FIEL
apiClient.get('/fiel/status')
.then(({ data: legacyData }) => setFielDone(legacyData.configured === true))
.catch(() => setFielDone(false));
}
})
.catch(() => {
apiClient.get('/fiel/status')
.then(({ data: legacyData }) => setFielDone(legacyData.configured === true))
.catch(() => setFielDone(false));
});
// Facturapi: check per-contribuyente then legacy
apiClient.get(`/contribuyentes/${firstContribId}/facturapi/status`)
.then(({ data }) => {
if (data.configured) {
setCsdDone(data.hasCsd === true);
} else {
apiClient.get('/facturacion/org/status')
.then(({ data: legacyData }) => setCsdDone(legacyData.configured === true && legacyData.hasCsd === true))
.catch(() => setCsdDone(false));
}
})
.catch(() => setCsdDone(false));
}, [firstContribId]);
const steps: Step[] = [
{
id: 'account',
title: 'Cuenta creada',
description: 'Tu despacho está registrado y listo.',
icon: <CheckCircle2 className="h-5 w-5" />,
href: '#',
completed: true,
},
{
id: 'contribuyente',
title: 'Agregar primer contribuyente',
description: 'Registra el primer RFC que gestionarás.',
icon: <Building2 className="h-5 w-5" />,
href: '/contribuyentes',
completed: hasContribuyentes,
},
{
id: 'fiel',
title: 'Subir FIEL del contribuyente',
description: 'Necesaria para sincronizar con el SAT.',
icon: <Key className="h-5 w-5" />,
href: '/contribuyentes',
completed: fielDone,
},
{
id: 'csd',
title: 'Subir CSD (para emitir facturas)',
description: 'Certificado de Sello Digital para timbrado.',
icon: <FileText className="h-5 w-5" />,
href: '/contribuyentes',
completed: csdDone,
},
{
id: 'team',
title: 'Invitar supervisores o auxiliares',
description: 'Agrega a tu equipo de trabajo.',
icon: <Users className="h-5 w-5" />,
href: '/usuarios',
completed: false,
optional: true,
},
{
id: 'plan',
title: 'Elegir plan de pago',
description: 'Tu trial gratuito dura 30 días.',
icon: <CreditCard className="h-5 w-5" />,
href: '/configuracion/planes-despacho',
completed: false,
optional: true,
},
];
const completedCount = steps.filter((s) => s.completed).length;
const requiredSteps = steps.filter((s) => !s.optional);
const requiredCompleted = requiredSteps.filter((s) => s.completed).length;
const allRequiredDone = requiredCompleted === requiredSteps.length;
// Auto-dismiss cuando todos los pasos requeridos están listos. Idempotente
// del lado backend, pero `dismissed` evita el round-trip si la página se
// re-renderiza (datos refetched).
useEffect(() => {
if (!allRequiredDone || dismissed || !user || user.onboardingDismissedAt) return;
setDismissed(true);
dismissOnboarding()
.then((res) => {
// Sync al store para que el siguiente login vaya directo al dashboard
// sin esperar a que el backend incremente loginCount > threshold.
setUser({ ...user, onboardingDismissedAt: res.onboardingDismissedAt });
})
.catch((err) => {
console.warn('[onboarding] Failed to mark as dismissed:', err);
setDismissed(false); // permite reintentar
});
}, [allRequiredDone, dismissed, user, setUser]);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Bienvenido a Horux Despachos</h1>
<p className="text-muted-foreground">
Configura tu despacho en unos minutos. {completedCount} de {steps.length} pasos completados.
</p>
<div className="w-full bg-muted rounded-full h-2 mt-4">
<div
className="bg-primary rounded-full h-2 transition-all"
style={{ width: `${(completedCount / steps.length) * 100}%` }}
/>
</div>
</div>
<div className="space-y-3">
{steps.map((step) => (
<Card key={step.id} className={cn(step.completed && 'opacity-60')}>
<CardContent className="flex items-center gap-4 py-4 px-6">
<div className={cn(
'flex-shrink-0 rounded-full p-2',
step.completed ? 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400' : 'bg-muted text-muted-foreground'
)}>
{step.completed ? <CheckCircle2 className="h-5 w-5" /> : step.icon}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium flex items-center gap-2">
{step.title}
{step.optional && <span className="text-xs text-muted-foreground font-normal">(opcional)</span>}
</p>
<p className="text-sm text-muted-foreground">{step.description}</p>
</div>
{!step.completed && step.href !== '#' && (
<Link href={step.href}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
Configurar <ArrowRight className="h-3 w-3" />
</Button>
</Link>
)}
</CardContent>
</Card>
))}
</div>
{allRequiredDone && (
<div className="text-center pt-4">
<Button onClick={() => router.push('/dashboard')} size="lg">
Ir al Dashboard
</Button>
</div>
)}
<p className="text-center text-xs text-muted-foreground pt-4">
Puedes completar estos pasos en cualquier orden. Tu trial de 30 días ya comenzó.
</p>
</div>
);
}

View File

@@ -0,0 +1,466 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
import { PeriodSelector } from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { Header } from '@/components/layouts/header';
import {
ClipboardList,
CheckCircle2,
Circle,
Clock,
AlertTriangle,
Building2,
} from 'lucide-react';
interface DeclaracionLink {
id: number;
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
pdfFilename: string | null;
}
interface ObligacionPeriodo {
id: string;
nombre: string;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
periodoCompletado: string | null;
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
/** Cuando la obligación fue completada al subir una declaración, apunta a ella. */
declaracion: DeclaracionLink | null;
}
interface ContribuyenteResumen {
id: string;
rfc: string;
nombre: string;
total: number;
completadas: number;
atrasadas: number;
pendientes: number;
obligaciones: ObligacionPeriodo[];
}
export default function PendientesPage() {
const { selectedContribuyenteId, setSelectedContribuyente } = useContribuyenteStore();
const { data: contribuyentes } = useContribuyentes();
const user = useAuthStore((s) => s.user);
const now = new Date();
const [periodo, setPeriodo] = useState(() => {
const y = now.getFullYear();
const m = now.getMonth() + 1;
return `${y}-${String(m).padStart(2, '0')}`;
});
// Derive fechaInicio/fechaFin for PeriodSelector
const fechaInicio = `${periodo}-01`;
const lastDay = new Date(parseInt(periodo.split('-')[0]), parseInt(periodo.split('-')[1]), 0).getDate();
const fechaFin = `${periodo}-${String(lastDay).padStart(2, '0')}`;
const [resumenes, setResumenes] = useState<ContribuyenteResumen[]>([]);
const [loading, setLoading] = useState(true);
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
const [toggling, setToggling] = useState<string | null>(null);
// Single contribuyente view — fetch period-aware data
useEffect(() => {
if (!selectedContribuyenteId) return;
setLoading(true);
apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`)
.then(({ data }) => setSingleObligaciones(data.data || []))
.catch(() => setSingleObligaciones([]))
.finally(() => setLoading(false));
}, [selectedContribuyenteId, periodo]);
// Portfolio view — fetch period-aware data for all contribuyentes
useEffect(() => {
if (selectedContribuyenteId) return;
if (!contribuyentes || contribuyentes.length === 0) {
setLoading(false);
return;
}
setLoading(true);
Promise.all(
contribuyentes.map(async (c) => {
try {
const { data } = await apiClient.get(`/contribuyentes/${c.id}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
const items: ObligacionPeriodo[] = data.data || [];
return {
id: c.id, rfc: c.rfc, nombre: c.nombre,
total: items.length,
completadas: items.filter((o) => o.periodStatus === 'completada').length,
atrasadas: items.filter((o) => o.periodStatus === 'atrasada').length,
pendientes: items.filter((o) => o.periodStatus === 'pendiente').length,
obligaciones: items,
};
} catch {
return { id: c.id, rfc: c.rfc, nombre: c.nombre, total: 0, completadas: 0, atrasadas: 0, pendientes: 0, obligaciones: [] };
}
})
)
.then(setResumenes)
.finally(() => setLoading(false));
}, [selectedContribuyenteId, contribuyentes, periodo]);
// Filter portfolio: "Mis asignados" shows only the contribuyentes visible to the current user.
// For supervisors: their cartera contribuyentes (already filtered by useContribuyentes).
// For owners: all contribuyentes (no filter needed).
// Since useContribuyentes already filters by role, "Mis asignados" for non-owner
// is effectively the same as "Todos" (they only see their assigned ones).
const filteredResumenes = filter === 'mis' && user && contribuyentes
? resumenes.filter((r) => contribuyentes.some((c) => c.id === r.id))
: resumenes;
// Derived counts for single view
const completadasCount = singleObligaciones.filter((o) => o.periodStatus === 'completada').length;
const atrasadasCount = singleObligaciones.filter((o) => o.periodStatus === 'atrasada').length;
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
if (!selectedContribuyenteId) return;
const key = `${obligacionId}:${periodoAplica}`;
setToggling(key);
try {
if (currentStatus === 'completada') {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
{ periodo: periodoAplica }
);
} else {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
{ periodo: periodoAplica }
);
}
// Refetch
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
setSingleObligaciones(data.data || []);
} catch {
// silent — state stays as-is
} finally {
setToggling(null);
}
};
// Status badge
const statusBadge = (status: string) => {
if (status === 'completada') return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Completada</span>;
if (status === 'atrasada') return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Atrasada</span>;
return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">Pendiente</span>;
};
// Frecuencia badge
const frecBadge = (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',
};
return f ? (
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || 'bg-gray-100 text-gray-700')}>{f}</span>
) : null;
};
// Progress bar for a resumen row
const ProgressBar = ({ r }: { r: ContribuyenteResumen }) => {
const pct = r.total > 0 ? Math.round((r.completadas / r.total) * 100) : 0;
if (r.total === 0) return null;
return (
<div className="w-36">
<div className="flex justify-between text-xs mb-1 text-muted-foreground">
<span>{r.completadas}/{r.total}</span>
<span>{pct}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={cn(
'rounded-full h-2 transition-all',
pct === 100 ? 'bg-green-500' : pct > 50 ? 'bg-blue-500' : 'bg-amber-500'
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
};
return (
<>
<Header title="Pendientes" />
<main className="p-6 space-y-6">
{loading ? (
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
) : !contribuyentes || contribuyentes.length === 0 ? (
<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">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground">Agrega contribuyentes para ver sus pendientes.</p>
</CardContent>
</Card>
) : selectedContribuyenteId ? (
/* =============== SINGLE CONTRIBUYENTE VIEW =============== */
<>
{/* Period selector */}
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Periodo:</span>
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
/>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-900 rounded-full p-2">
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold">{singleObligaciones.length}</p>
<p className="text-xs text-muted-foreground">Total periodo</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-red-100 dark:bg-red-900 rounded-full p-2">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-2xl font-bold">{atrasadasCount}</p>
<p className="text-xs text-muted-foreground">Atrasadas</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{pendientesCount}</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="bg-green-100 dark:bg-green-900 rounded-full p-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">{completadasCount}</p>
<p className="text-xs text-muted-foreground">Completadas</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Obligations by category */}
{singleObligaciones.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-amber-500" />
<p className="font-medium">Sin obligaciones para este periodo</p>
<p className="text-sm mt-1">Ve a Configuración Obligaciones Fiscales para generar recomendaciones.</p>
</CardContent>
</Card>
) : (
categorias.map((cat) => (
<Card key={cat}>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground uppercase tracking-wide">{cat}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{singleObligaciones.filter((o) => (o.categoria || 'Sin categoría') === cat).map((ob) => {
const toggleKey = `${ob.id}:${ob.periodoAplica}`;
return (
<div
key={toggleKey}
className={cn(
'flex items-center justify-between py-2 border-b last:border-0',
ob.periodStatus === 'completada' && 'opacity-60'
)}
>
<div className="flex items-center gap-3">
<button
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
disabled={toggling === toggleKey}
className="shrink-0 focus:outline-none"
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
>
{ob.periodStatus === 'completada' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
)}
</button>
<div>
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<p className="text-xs text-muted-foreground">{ob.fechaLimite}</p>
{ob.periodoAplica !== periodo && (
<span className="text-xs text-red-500 font-medium">({ob.periodoAplica})</span>
)}
{ob.declaracion && (
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/documentos/declaraciones/${ob.declaracion.id}/pdf/declaracion`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-700 hover:underline inline-flex items-center gap-1"
title={`Declaración ${ob.declaracion.tipo} ${String(ob.declaracion.mes).padStart(2, '0')}/${ob.declaracion.año}${ob.declaracion.pdfFilename ?? 'PDF'}`}
onClick={(e) => e.stopPropagation()}
>
Declaración {String(ob.declaracion.mes).padStart(2, '0')}/{ob.declaracion.año}
{ob.declaracion.tipo === 'complementaria' && <span className="text-[10px] uppercase font-semibold">Compl.</span>}
</a>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{frecBadge(ob.frecuencia)}
{statusBadge(ob.periodStatus)}
</div>
</div>
);
})}
</CardContent>
</Card>
))
)}
</>
) : (
/* =============== ALL CONTRIBUYENTES VIEW =============== */
<>
{/* Period selector + filter bar */}
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-muted-foreground">Periodo:</span>
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
/>
<div className="flex rounded-lg border overflow-hidden text-sm ml-2">
<button
onClick={() => setFilter('todos')}
className={cn(
'px-3 py-1.5 transition-colors',
filter === 'todos'
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
)}
>
Todos
</button>
<button
onClick={() => setFilter('mis')}
className={cn(
'px-3 py-1.5 border-l transition-colors',
filter === 'mis'
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
)}
>
Mis asignados
</button>
</div>
<span className="text-sm text-muted-foreground">
{filteredResumenes.length} contribuyente{filteredResumenes.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-sm text-muted-foreground">
Resumen de obligaciones por contribuyente para el periodo seleccionado. Selecciona uno para ver el detalle.
</p>
{filteredResumenes.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Building2 className="h-8 w-8 mx-auto mb-2" />
<p className="font-medium">
{filter === 'mis' ? 'No tienes contribuyentes asignados' : 'Sin contribuyentes'}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredResumenes.map((r) => (
<Card
key={r.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setSelectedContribuyente(r.id, r.rfc, r.nombre)}
>
<CardContent className="flex items-center justify-between py-4 px-6">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center text-sm font-bold">
{r.nombre.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="font-semibold">{r.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{r.rfc}</p>
</div>
</div>
<div className="flex items-center gap-6">
{r.total > 0 ? (
<>
<ProgressBar r={r} />
<div className="flex items-center gap-3 text-right">
{r.atrasadas > 0 && (
<div className="text-right">
<p className="text-lg font-bold text-red-600">{r.atrasadas}</p>
<p className="text-xs text-muted-foreground">atrasadas</p>
</div>
)}
<div className="text-right">
<p className="text-lg font-bold">{r.pendientes}</p>
<p className="text-xs text-muted-foreground">pendientes</p>
</div>
</div>
</>
) : (
<div className="flex items-center gap-1 text-amber-500">
<AlertTriangle className="h-4 w-4" />
<span className="text-xs">Sin configurar</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,442 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector, KpiCard } from '@horux/shared-ui';
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc, useCuentasXPagar, useCuentasXCobrar } from '@/lib/hooks/use-reportes';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { BarChart } from '@/components/charts/bar-chart';
import { formatCurrency } from '@/lib/utils';
import { FileText, TrendingUp, TrendingDown, Users, CreditCard, Banknote } from 'lucide-react';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
export default function ReportesPage() {
const now = new Date();
const [fechaInicio, setFechaInicio] = useState(`${now.getFullYear()}-01-01`);
const [fechaFin, setFechaFin] = useState(`${now.getFullYear()}-12-31`);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin);
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
const { data: flujoEfectivo, isLoading: loadingFE, error: errorFE } = useFlujoEfectivo(fechaInicio, fechaFin);
const { data: comparativo, isLoading: loadingComp, error: errorComp } = useComparativo(año);
const { data: clientes, error: errorClientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
const { data: proveedores, error: errorProveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
const { data: cxp, isLoading: loadingCXP } = useCuentasXPagar(fechaInicio, fechaFin, regimenSeleccionado || undefined);
const { data: cxc, isLoading: loadingCXC } = useCuentasXCobrar(fechaInicio, fechaFin, regimenSeleccionado || undefined);
return (
<DashboardShell
title="Reportes"
headerContent={
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi, ff) => { setFechaInicio(fi); setFechaFin(ff); }}
/>
}
>
<div className="mb-4">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
</div>
<Tabs defaultValue="estado-resultados" className="space-y-4">
<TabsList>
<TabsTrigger value="estado-resultados">Estado de Resultados</TabsTrigger>
<TabsTrigger value="flujo-efectivo">Flujo de Efectivo</TabsTrigger>
<TabsTrigger value="comparativo">Comparativo</TabsTrigger>
<TabsTrigger value="concentrado">Concentrado RFC</TabsTrigger>
<TabsTrigger value="cxp">Cuentas X Pagar</TabsTrigger>
<TabsTrigger value="cxc">Cuentas X Cobrar</TabsTrigger>
</TabsList>
<TabsContent value="estado-resultados" className="space-y-4">
{loadingER ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorER ? (
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
) : !estadoResultados ? (
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
<TrendingUp className="h-4 w-4 text-success" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(estadoResultados.totalIngresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
<TrendingDown className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(estadoResultados.totalEgresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadBruta)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadNeta)}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 10 Ingresos por Cliente</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.ingresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top 10 Egresos por Proveedor</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.egresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</>
)}
</TabsContent>
<TabsContent value="flujo-efectivo" className="space-y-4">
{loadingFE ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorFE ? (
<div className="text-center py-8 text-destructive">Error: {(errorFE as Error).message}</div>
) : !flujoEfectivo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos de flujo de efectivo</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Entradas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(flujoEfectivo.totalEntradas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Salidas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(flujoEfectivo.totalSalidas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Flujo Neto</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${flujoEfectivo.flujoNeto >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(flujoEfectivo.flujoNeto)}
</div>
</CardContent>
</Card>
</div>
<BarChart
title="Flujo de Efectivo Mensual"
data={flujoEfectivo.entradas.map((e, i) => ({
mes: e.concepto,
ingresos: e.monto,
egresos: flujoEfectivo.salidas[i]?.monto || 0,
}))}
/>
</>
)}
</TabsContent>
<TabsContent value="comparativo" className="space-y-4">
{loadingComp ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorComp ? (
<div className="text-center py-8 text-destructive">Error: {(errorComp as Error).message}</div>
) : !comparativo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos comparativos</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Ingresos vs Año Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionIngresos >= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Egresos vs Año Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionEgresos <= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Año Actual</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{año}</div>
</CardContent>
</Card>
</div>
<BarChart
title={`Comparativo Mensual ${año}`}
data={comparativo.periodos.map((mes, i) => ({
mes,
ingresos: comparativo.ingresos[i],
egresos: comparativo.egresos[i],
}))}
/>
</>
)}
</TabsContent>
<TabsContent value="concentrado" className="space-y-4">
{errorClientes || errorProveedores ? (
<div className="text-center py-8 text-destructive">
Error: {((errorClientes || errorProveedores) as Error).message}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Clientes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clientes && clientes.length > 0 ? (
clientes.slice(0, 10).map((c, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
</div>
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Proveedores
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proveedores && proveedores.length > 0 ? (
proveedores.slice(0, 10).map((p, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{p.nombre}</div>
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
</div>
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="cxp" className="space-y-4">
{loadingCXP ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<KpiCard
title="CFDIs con Saldo Pendiente"
value={String(cxp?.cantidadCfdis || 0)}
icon={<FileText className="h-4 w-4" />}
/>
<KpiCard
title="Saldo Pendiente Total"
value={cxp?.saldoTotal || 0}
icon={<CreditCard className="h-4 w-4" />}
trend={(cxp?.saldoTotal || 0) > 0 ? 'up' : 'neutral'}
trendValue="Por pagar"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Saldo por Proveedor</CardTitle>
</CardHeader>
<CardContent>
{cxp?.detalle.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay cuentas por pagar en el periodo</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-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Saldo Pendiente</th>
</tr>
</thead>
<tbody>
{cxp?.detalle.map((d) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
<TabsContent value="cxc" className="space-y-4">
{loadingCXC ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<KpiCard
title="CFDIs con Saldo Pendiente"
value={String(cxc?.cantidadCfdis || 0)}
icon={<FileText className="h-4 w-4" />}
/>
<KpiCard
title="Saldo Pendiente Total"
value={cxc?.saldoTotal || 0}
icon={<Banknote className="h-4 w-4" />}
trend={(cxc?.saldoTotal || 0) > 0 ? 'up' : 'neutral'}
trendValue="Por cobrar"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Saldo por Cliente</CardTitle>
</CardHeader>
<CardContent>
{cxc?.detalle.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay cuentas por cobrar en el periodo</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-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Saldo Pendiente</th>
</tr>
</thead>
<tbody>
{cxc?.detalle.map((d) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
</Tabs>
<FiscalDisclaimer />
</DashboardShell>
);
}

View File

@@ -0,0 +1,511 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { addClienteAcceso } from '@/lib/api/contribuyentes';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const legacyInviteRoles: { value: string; label: string }[] = [
{ value: 'contador', label: 'Contador' },
{ value: 'visor', label: 'Visor' },
{ value: 'auxiliar', label: 'Auxiliar' },
];
// ── Despacho roles ────────────────────────────────────────────────────────────
const despachoRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Owner', icon: Shield, color: 'text-primary' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
cliente: { label: 'Cliente', icon: Building2, color: 'text-muted-foreground' },
};
const despachoInviteRoles: { value: string; label: string; description: string }[] = [
{
value: 'supervisor',
label: 'Supervisor',
description: 'Titular de RFCs, crea carteras y gestiona auxiliares',
},
{
value: 'auxiliar',
label: 'Auxiliar',
description: 'Accede a RFCs asignados vía carteras',
},
{
value: 'cliente',
label: 'Cliente',
description: 'Visor externo — acceso read-only a sus RFCs',
},
];
// ── Fallback for unknown roles ─────────────────────────────────────────────────
function getRoleInfo(
role: string,
isDespacho: boolean,
): { label: string; icon: React.ElementType; color: string } {
if (isDespacho) {
return despachoRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
return legacyRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
export default function UsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading } = useUsuarios();
const { data: contribuyentes } = useContribuyentes();
const inviteUsuario = useInviteUsuario();
const updateUsuario = useUpdateUsuario();
const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: Role; supervisorUserId?: string }>({
email: '',
nombre: '',
role: defaultInviteRole as Role,
});
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
// Edit accesos modal
const [editingAccesosUser, setEditingAccesosUser] = useState<{ id: string; nombre: string } | null>(null);
const [accesosRfcIds, setAccesosRfcIds] = useState<string[]>([]);
const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares)
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false);
const openEditSupervisor = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre });
} catch {
alert('Error al cargar supervisor');
}
};
const handleSaveSupervisor = async () => {
if (!editingSupervisorUser) return;
setSavingSupervisor(true);
try {
await apiClient.put(`/usuarios/${editingSupervisorUser.id}/supervisor`, {
supervisorUserId: selectedSupervisorId || null,
});
setEditingSupervisorUser(null);
} catch {
alert('Error al guardar supervisor');
} finally {
setSavingSupervisor(false);
}
};
const openEditAccesos = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
setAccesosRfcIds(res.data.data);
setEditingAccesosUser({ id: userId, nombre });
} catch {
alert('Error al cargar accesos');
}
};
const handleSaveAccesos = async () => {
if (!editingAccesosUser) return;
setSavingAccesos(true);
try {
await apiClient.post(`/usuarios/${editingAccesosUser.id}/accesos`, { entidadIds: accesosRfcIds });
setEditingAccesosUser(null);
} catch {
alert('Error al guardar accesos');
} finally {
setSavingAccesos(false);
}
};
const toggleAccesoRfc = (id: string) => {
setAccesosRfcIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
// Fetch supervisors for auxiliar invite dropdown
const { data: supervisores } = useQuery({
queryKey: ['cartera-supervisores'],
queryFn: async () => {
const res = await apiClient.get<{ data: Array<{ userId: string; nombre: string; email: string }> }>('/carteras/supervisores');
return res.data.data;
},
enabled: isDespacho && isAdmin,
});
const toggleRfc = (id: string) => {
setSelectedRfcIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (inviteForm.role === 'auxiliar' && !inviteForm.supervisorUserId) {
alert('Debes asignar un supervisor al auxiliar');
return;
}
try {
const newUser = await inviteUsuario.mutateAsync(inviteForm);
// If role is 'cliente' and RFCs were selected, grant access to each
if (inviteForm.role === 'cliente' && selectedRfcIds.length > 0) {
await Promise.all(
selectedRfcIds.map((rfcId) => addClienteAcceso(rfcId, newUser.id)),
);
}
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
setSelectedRfcIds([]);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
}
};
const handleToggleActive = (id: string, active: boolean) => {
updateUsuario.mutate({ id, data: { active: !active } });
};
const handleDelete = (id: string) => {
if (confirm('¿Eliminar este usuario?')) {
deleteUsuario.mutate(id);
}
};
return (
<DashboardShell title="Usuarios">
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Usuarios</h1>
<p className="text-sm text-muted-foreground">Gestiona tu equipo</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && isDespacho && (
<Link href="/carteras">
<Button variant="outline" className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
</Button>
</Link>
)}
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</Button>
)}
</div>
</div>
{/* User count */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>{usuarios?.length || 0} usuarios</span>
</div>
{/* Invite Form */}
{showInvite && (
<Card>
<CardHeader>
<CardTitle>Invitar Nuevo Usuario</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre</Label>
<Input
id="nombre"
value={inviteForm.nombre}
onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as Role, supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>
{'description' in r && r.description && (
<span className="text-xs text-muted-foreground">{r.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Supervisor selector for auxiliar role */}
{isDespacho && inviteForm.role === 'auxiliar' && (
<div className="space-y-2">
<Label>Supervisor asignado</Label>
{supervisores && supervisores.length > 0 ? (
<Select
value={inviteForm.supervisorUserId || ''}
onValueChange={(v) => setInviteForm({ ...inviteForm, supervisorUserId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar supervisor..." />
</SelectTrigger>
<SelectContent>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} ({s.email})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground border rounded-md p-3">
No hay supervisores registrados. Crea uno primero para poder asignar auxiliares.
</p>
)}
</div>
)}
{/* RFC access for cliente role */}
{isDespacho && inviteForm.role === 'cliente' && contribuyentes && contribuyentes.length > 0 && (
<div className="space-y-2">
<Label>RFCs con acceso (opcional)</Label>
<div className="border rounded-md p-3 space-y-2 max-h-48 overflow-y-auto">
{contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`rfc-${c.id}`}
checked={selectedRfcIds.includes(c.id)}
onChange={() => toggleRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`rfc-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={inviteUsuario.isPending}>
{inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
</Button>
<Button type="button" variant="outline" onClick={() => { setShowInvite(false); setSelectedRfcIds([]); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Users List */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="divide-y">
{usuarios?.map(usuario => {
const roleInfo = getRoleInfo(usuario.role, isDespacho);
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"></span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
{isAdmin && !isCurrentUser && (
<div className="flex gap-1">
{isDespacho && usuario.role === 'cliente' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditAccesos(usuario.id, usuario.nombre)}
title="Editar RFCs con acceso"
>
<KeyRound className="h-4 w-4 mr-1" /> Accesos
</Button>
)}
{isDespacho && usuario.role === 'auxiliar' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditSupervisor(usuario.id, usuario.nombre)}
title="Asignar supervisor"
>
<UserCheck className="h-4 w-4 mr-1" /> Supervisor
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(usuario.id, usuario.active)}
>
{usuario.active ? 'Desactivar' : 'Activar'}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Edit Accesos Modal */}
{editingAccesosUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingAccesosUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar accesos {editingAccesosUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-60 overflow-y-auto py-2">
{contribuyentes && contribuyentes.length > 0 ? (
contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`acceso-${c.id}`}
checked={accesosRfcIds.includes(c.id)}
onChange={() => toggleAccesoRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`acceso-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No hay contribuyentes registrados.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingAccesosUser(null)}>Cancelar</Button>
<Button onClick={handleSaveAccesos} disabled={savingAccesos}>
{savingAccesos ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Edit Supervisor Modal (auxiliares) */}
{editingSupervisorUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingSupervisorUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Asignar supervisor {editingSupervisorUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} {s.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No hay supervisores registrados todavía.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSupervisorUser(null)}>Cancelar</Button>
<Button onClick={handleSaveSupervisor} disabled={savingSupervisor}>
{savingSupervisor ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DashboardShell>
);
}