280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
'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 { cancelAllApiRequests } from '@/lib/api/client';
|
|
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;
|
|
}
|
|
// Cancela requests pendientes para evitar que intenten refrescar con el
|
|
// token que switchTenant va a invalidar.
|
|
cancelAllApiRequests();
|
|
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>
|
|
);
|
|
}
|