Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/clientes/page.tsx
Horux Dev 66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00

683 lines
30 KiB
TypeScript

'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 { usePagosSinFactura } from '@/lib/hooks/use-pagos-sin-factura';
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, Receipt } 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,
});
const { data: pagosSinFactura } = usePagosSinFactura();
// 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;
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
}>({
nombre: '',
rfc: '',
plan: 'trial',
adminEmail: '',
adminNombre: '',
amount: 0,
firstPaymentDueAt: '',
verticalProfile: 'CONTABLE',
});
// 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: '', verticalProfile: 'CONTABLE' });
setShowForm(false);
} catch (error) {
console.error('Error:', error);
}
};
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
const sub = tenant.subscriptions?.[0];
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
adminEmail: '',
adminNombre: '',
amount: sub ? Number(sub.amount) : 0,
firstPaymentDueAt: sub?.currentPeriodEnd
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
: '',
verticalProfile: tenant.verticalProfile || 'CONTABLE',
});
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: '', verticalProfile: 'CONTABLE' });
};
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-3 xl:grid-cols-5">
{/* 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>
{/* Facturas pendientes */}
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => router.push('/admin/facturas-pendientes')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Receipt className="h-6 w-6 text-orange-600" />
</div>
<div>
<p className="text-2xl font-bold">{pagosSinFactura?.length ?? 0}</p>
<p className="text-sm text-muted-foreground">Facturas pendientes</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>
<div className="space-y-2">
<Label htmlFor="verticalProfile">Tipo de Despacho</Label>
<Select
value={formData.verticalProfile}
onValueChange={(value) =>
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTABLE">Contable</SelectItem>
<SelectItem value="JURIDICO">Jurídico</SelectItem>
<SelectItem value="ARQUITECTURA">Arquitectura</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campos de admin — 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>
)}
{/* Campos de suscripción — crear y editar (solo planes pagados / custom) */}
{formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2 border-t pt-4">
<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>
</>
);
}