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,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>
);
}