fix: fechaPagoP pipe timestamp, admin redirects, despacho plans, CSF parsing
This commit is contained in:
@@ -7,7 +7,7 @@ import Image from 'next/image';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { login } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
|
||||
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -30,13 +30,7 @@ export default function LoginPage() {
|
||||
setUser(response.user);
|
||||
|
||||
const userRole = response.user?.role;
|
||||
// Admin global aterriza directo en `/clientes` — su home natural es la
|
||||
// gestión de tenants, no el dashboard operativo del despacho.
|
||||
const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles;
|
||||
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
|
||||
if (isGlobalAdmin) {
|
||||
router.push('/clientes');
|
||||
} else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
|
||||
if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
|
||||
// Clients and roles without onboarding go straight to dashboard
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user