Initial commit: Horux Despachos project
This commit is contained in:
275
apps/web/app/(dashboard)/mis-empresas/page.tsx
Normal file
275
apps/web/app/(dashboard)/mis-empresas/page.tsx
Normal 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: 'starter' 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: 'starter' });
|
||||
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="starter">Starter</SelectItem>
|
||||
<SelectItem value="business">Business</SelectItem>
|
||||
<SelectItem value="business_ia">Business + IA</SelectItem>
|
||||
<SelectItem value="enterprise">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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user