fix: fechaPagoP pipe timestamp, admin redirects, despacho plans, CSF parsing

This commit is contained in:
Horux Dev
2026-04-28 22:24:30 +00:00
parent 3eb0f33f3b
commit 066ba7deda
10 changed files with 322 additions and 118 deletions

View File

@@ -10,22 +10,24 @@ 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 { isGlobalAdminRfc, DESPACHO_PLAN_PRICES, permiteFrecuenciaMensual } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
enterprise: 'Enterprise',
custom: 'Custom',
trial: 'Trial Gratuito',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa +',
business_control: 'Business Control',
business_cloud: 'Enterprise (Despacho)',
custom: 'Custom',
// Legacy labels kept for display
starter: 'Starter (legacy)',
business: 'Business (legacy)',
business_ia: 'Business + IA (legacy)',
enterprise: 'Enterprise (legacy)',
};
type PlanType = 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud' | 'custom' | 'starter' | 'business' | 'business_ia' | 'enterprise';
export default function ClientesPage() {
const { user } = useAuthStore();
@@ -81,13 +83,17 @@ export default function ClientesPage() {
nombre: string;
rfc: string;
plan: PlanType;
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount: number;
}>({
nombre: '',
rfc: '',
plan: 'starter',
plan: 'trial',
verticalProfile: 'CONTABLE',
frequency: 'annual',
adminEmail: '',
adminNombre: '',
amount: 0,
@@ -116,12 +122,20 @@ export default function ClientesPage() {
try {
if (editingTenant) {
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
await updateTenant.mutateAsync({
id: editingTenant.id,
data: {
nombre: formData.nombre,
rfc: formData.rfc,
plan: formData.plan,
verticalProfile: formData.verticalProfile,
},
});
setEditingTenant(null);
} else {
await createTenant.mutateAsync(formData);
}
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 });
setShowForm(false);
} catch (error) {
console.error('Error:', error);
@@ -133,7 +147,9 @@ export default function ClientesPage() {
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
plan: (tenant.plan as PlanType) || 'trial',
verticalProfile: 'CONTABLE',
frequency: 'annual',
adminEmail: '',
adminNombre: '',
amount: 0,
@@ -154,7 +170,7 @@ export default function ClientesPage() {
const handleCancelForm = () => {
setShowForm(false);
setEditingTenant(null);
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 });
};
const handleViewClient = (tenantId: string, tenantName: string) => {
@@ -175,15 +191,16 @@ export default function ClientesPage() {
// 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',
trial: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-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',
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',
};
return (
@@ -393,12 +410,12 @@ export default function ClientesPage() {
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
{editingTenant ? 'Editar Despacho' : 'Nuevo Despacho'}
</CardTitle>
<CardDescription>
{editingTenant
? 'Modifica los datos del cliente'
: 'Registra un nuevo cliente para gestionar su facturación'}
? 'Modifica los datos del despacho'
: 'Registra un nuevo despacho en Horux'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
@@ -410,54 +427,112 @@ export default function ClientesPage() {
<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>
<Label htmlFor="nombre">Nombre del Despacho</Label>
<Input
id="nombre"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
placeholder="Empresa SA de CV"
placeholder="Despacho Pérez y Asociados"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rfc">RFC</Label>
<Label htmlFor="rfc">RFC del Despacho</Label>
<Input
id="rfc"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={14}
placeholder="ABC010101ABC"
maxLength={13}
required
disabled={!!editingTenant} // Can't change RFC after creation
disabled={!!editingTenant}
/>
</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="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitados</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
</Select>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="verticalProfile">Perfil Profesional</Label>
<Select
value={formData.verticalProfile}
onValueChange={(value) =>
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTABLE">📊 Contable Fiscal, CFDI, IVA/ISR</SelectItem>
<SelectItem value="JURIDICO"> Jurídico Próximamente</SelectItem>
<SelectItem value="ARQUITECTURA">🏗 Arquitectura Próximamente</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value) => {
const plan = value as PlanType;
const isCustom = plan === 'custom';
const isTrial = plan === 'trial';
let amount = 0;
if (!isCustom && !isTrial) {
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[plan];
amount = priceInfo?.firstYear ?? 0;
}
setFormData({ ...formData, plan, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trial">Trial Gratuito 30 días, 3 RFCs, 20 timbres</SelectItem>
<SelectItem value="mi_empresa">Mi Empresa 1 RFC, 3 usuarios, 50 timbres/mes</SelectItem>
<SelectItem value="mi_empresa_plus">Mi Empresa + Con API y Lolita IA</SelectItem>
<SelectItem value="business_control">Business Control 100 RFCs, BYO server</SelectItem>
<SelectItem value="business_cloud">Enterprise 100 RFCs, 3M CFDIs, BYO</SelectItem>
<SelectItem value="custom">Custom Sin cobro, asignado por admin</SelectItem>
<hr className="my-1" />
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitados</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Frequency selector for plans that allow monthly */}
{formData.plan !== 'custom' && formData.plan !== 'trial' && permiteFrecuenciaMensual(formData.plan) && (
<div className="space-y-2">
<Label htmlFor="frequency">Frecuencia de Pago</Label>
<Select
value={formData.frequency}
onValueChange={(value) => {
const freq = value as 'monthly' | 'annual';
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[formData.plan];
const amount = freq === 'monthly' ? (priceInfo?.monthly ?? 0) : (priceInfo?.firstYear ?? 0);
setFormData({ ...formData, frequency: freq, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Mensual</SelectItem>
<SelectItem value="annual">Anual (ahorra ~17%)</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>
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Despacho</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
@@ -476,16 +551,16 @@ export default function ClientesPage() {
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
placeholder="admin@despacho.com"
required
/>
</div>
</div>
</div>
{formData.plan !== 'custom' && (
{formData.plan !== 'custom' && formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Label htmlFor="amount">Monto (MXN)</Label>
<Input
id="amount"
type="number"
@@ -495,12 +570,17 @@ export default function ClientesPage() {
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
Precio sugerido según catálogo. Puedes ajustarlo para descuentos especiales.
</p>
</div>
</div>
)}
{formData.plan === 'custom' && (
{(formData.plan === 'custom' || formData.plan === 'trial') && (
<p className="text-xs text-muted-foreground">
Plan Custom no genera cobro ni suscripción. Vigencia indefinida.
{formData.plan === 'custom'
? 'Plan Custom no genera cobro ni suscripción. Vigencia indefinida.'
: 'Trial gratuito por 30 días. No requiere tarjeta.'}
</p>
)}
</>

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
@@ -9,7 +8,7 @@ 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 { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
@@ -44,14 +43,7 @@ function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number)
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
// Admin global no opera sobre datos de despacho — su home natural es
// `/clientes` (gestión de tenants). Redirige al primer render.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin) router.replace('/clientes');
}, [isGlobalAdmin, router]);
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);