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>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, TabsContent } from '@horux/shared-ui';
|
||||
import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
|
||||
|
||||
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
||||
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
|
||||
@@ -43,6 +44,7 @@ export default function AdminUsuariosPage() {
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('usuarios');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createFormData, setCreateFormData] = useState({
|
||||
email: '',
|
||||
@@ -152,6 +154,13 @@ export default function AdminUsuariosPage() {
|
||||
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="usuarios" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="usuarios">Usuarios</TabsTrigger>
|
||||
<TabsTrigger value="invitaciones-trial">Invitaciones Trial</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="usuarios">
|
||||
<div className="space-y-4">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
@@ -425,6 +434,12 @@ export default function AdminUsuariosPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invitaciones-trial">
|
||||
<InvitacionesTrialTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -55,6 +56,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -63,7 +65,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
@@ -59,6 +60,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -67,7 +69,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user