feat: invitaciones trial como pestaña en admin usuarios + sidebar
- Quitado Invitaciones Trial del sidebar (4 layouts) - Agregado tab Invitaciones Trial dentro de /admin/usuarios - Componente reutilizable invitaciones-trial-tab.tsx - Agregada nueva opcion Tareas en el sidebar principal
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
|
||||
import { getTenants } from '@/lib/api/tenants';
|
||||
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface TenantOption {
|
||||
id: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
status: string;
|
||||
token: string;
|
||||
sentAt: string;
|
||||
expiresAt: string;
|
||||
acceptedAt: string | null;
|
||||
tenant: {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function InvitacionesTrialTab() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [durationDays, setDurationDays] = useState('30');
|
||||
const [plan, setPlan] = useState('business_control');
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tenantsData, invitationsData] = await Promise.all([
|
||||
getTenants(),
|
||||
getAllInvitations(),
|
||||
]);
|
||||
setTenants(tenantsData);
|
||||
setInvitations(invitationsData);
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedTenantId || !durationDays) {
|
||||
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await createInvitation({
|
||||
tenantId: selectedTenantId,
|
||||
plan,
|
||||
durationDays: parseInt(durationDays, 10),
|
||||
});
|
||||
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
|
||||
setSelectedTenantId('');
|
||||
setDurationDays('30');
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(id: string) {
|
||||
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
|
||||
try {
|
||||
await cancelInvitation(id);
|
||||
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'Pendiente';
|
||||
case 'accepted': return 'Aceptada';
|
||||
case 'expired': return 'Expirada';
|
||||
case 'cancelled': return 'Cancelada';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast de resultado */}
|
||||
{message && (
|
||||
<div
|
||||
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
|
||||
message.kind === 'ok'
|
||||
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario de creación */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Nueva invitación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Despacho</Label>
|
||||
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un despacho" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.nombre} ({t.rfc})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Select value={plan} onValueChange={setPlan}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business_control">Business Control</SelectItem>
|
||||
<SelectItem value="business_cloud">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Duración (días)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={durationDays}
|
||||
onChange={(e) => setDurationDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
|
||||
Enviar invitación
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabla de invitaciones */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : invitations.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3">Despacho</th>
|
||||
<th className="text-left py-2 px-3">Plan</th>
|
||||
<th className="text-left py-2 px-3">Días</th>
|
||||
<th className="text-left py-2 px-3">Estado</th>
|
||||
<th className="text-left py-2 px-3">Enviado</th>
|
||||
<th className="text-left py-2 px-3">Expira</th>
|
||||
<th className="text-left py-2 px-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((inv) => (
|
||||
<tr key={inv.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
|
||||
</td>
|
||||
<td className="py-2 px-3">{inv.durationDays}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcon(inv.status)}
|
||||
{statusLabel(inv.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(inv.id)}
|
||||
className="text-destructive hover:underline text-xs"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user