265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Header } from '@/components/layouts/header';
|
|
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
|
|
import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff';
|
|
import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react';
|
|
|
|
const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any; color: string }> = {
|
|
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
|
|
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
|
|
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
|
platform_sales: { label: 'Vendedor', desc: 'Enviar invitaciones a nuevos despachos', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
|
|
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
};
|
|
|
|
const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
|
|
|
|
export default function StaffPage() {
|
|
const { user } = useAuthStore();
|
|
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
|
|
|
const { data: staff = [], isLoading } = useStaff();
|
|
const grantRole = useGrantRole();
|
|
const revokeRole = useRevokeRole();
|
|
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [searchQ, setSearchQ] = useState('');
|
|
const { data: candidates = [] } = useSearchUsers(searchQ);
|
|
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
|
const [pickedRole, setPickedRole] = useState<PlatformRole>('platform_support');
|
|
|
|
if (!isGlobalAdmin) {
|
|
return (
|
|
<>
|
|
<Header title="Gestión de Staff" />
|
|
<main className="p-6">
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
|
|
<p className="font-semibold">Acceso restringido</p>
|
|
<p className="text-sm text-muted-foreground mt-1">Solo platform_admin puede gestionar staff.</p>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const handleGrant = async () => {
|
|
if (!pickedUserId) return;
|
|
try {
|
|
await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole });
|
|
setAddOpen(false);
|
|
setSearchQ('');
|
|
setPickedUserId(null);
|
|
} catch (err: any) {
|
|
alert(err?.response?.data?.message || err?.message || 'Error al asignar rol');
|
|
}
|
|
};
|
|
|
|
const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => {
|
|
if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return;
|
|
try {
|
|
await revokeRole.mutateAsync({ userId, role });
|
|
} catch (err: any) {
|
|
alert(err?.response?.data?.message || err?.message || 'Error al quitar rol');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Header title="Gestión de Staff" />
|
|
<main className="p-6 space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Staff interno de Horux 360 con poderes transversales. <code className="font-mono text-xs">platform_admin</code> implica todos los otros roles.
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setAddOpen(true)}>
|
|
<UserPlus className="h-4 w-4 mr-1" />
|
|
Agregar staff
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Equipo ({staff.length})</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
|
|
) : staff.length === 0 ? (
|
|
<p className="text-center py-8 text-muted-foreground">
|
|
Todavía no hay staff. Agrega al primer miembro con el botón arriba.
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="py-2 pr-4 font-medium">Usuario</th>
|
|
<th className="py-2 pr-4 font-medium">Tenant origen</th>
|
|
<th className="py-2 pr-4 font-medium">Roles</th>
|
|
<th className="py-2 font-medium text-right">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{staff.map(s => (
|
|
<tr key={s.id} className="border-b last:border-0 hover:bg-muted/40">
|
|
<td className="py-3 pr-4">
|
|
<div className="font-medium">{s.nombre}</div>
|
|
<div className="text-xs text-muted-foreground">{s.email}</div>
|
|
</td>
|
|
<td className="py-3 pr-4 text-xs">
|
|
{s.tenant ? (
|
|
<div>
|
|
<div>{s.tenant.nombre}</div>
|
|
<div className="font-mono text-muted-foreground">{s.tenant.rfc}</div>
|
|
</div>
|
|
) : <span className="text-muted-foreground">—</span>}
|
|
</td>
|
|
<td className="py-3 pr-4">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{s.roles.map(r => {
|
|
const meta = ROLE_META[r];
|
|
const Icon = meta.icon;
|
|
return (
|
|
<span key={r} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${meta.color}`}>
|
|
<Icon className="h-3 w-3" />
|
|
{meta.label}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRevoke(s.id, r, s.email)}
|
|
className="hover:opacity-60 ml-1"
|
|
title="Quitar"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 text-right">
|
|
<Button size="sm" variant="outline" onClick={() => { setPickedUserId(s.id); setSearchQ(s.email); setAddOpen(true); }}>
|
|
Agregar rol
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Shield className="h-4 w-4" />
|
|
Descripción de roles
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{ALL_ROLES.map(r => {
|
|
const m = ROLE_META[r];
|
|
const Icon = m.icon;
|
|
return (
|
|
<div key={r} className={`rounded border p-3 ${m.color}`}>
|
|
<div className="flex items-center gap-2 font-medium mb-1">
|
|
<Icon className="h-4 w-4" />
|
|
{m.label}
|
|
</div>
|
|
<p className="text-xs opacity-80">{m.desc}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Add staff dialog */}
|
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Agregar rol de staff</DialogTitle>
|
|
<DialogDescription>
|
|
Busca al usuario por email o nombre y asígnale un rol de plataforma.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-1">
|
|
<Label>Buscar usuario</Label>
|
|
<div className="relative">
|
|
<Search className="h-4 w-4 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={searchQ}
|
|
onChange={e => { setSearchQ(e.target.value); setPickedUserId(null); }}
|
|
placeholder="email o nombre (min 2 caracteres)"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
{searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && (
|
|
<div className="border rounded mt-1 max-h-40 overflow-auto">
|
|
{candidates.map(c => (
|
|
<button
|
|
type="button"
|
|
key={c.id}
|
|
onClick={() => { setPickedUserId(c.id); setSearchQ(c.email); }}
|
|
className="w-full text-left px-3 py-2 hover:bg-muted text-sm border-b last:border-0"
|
|
>
|
|
<div className="font-medium">{c.nombre}</div>
|
|
<div className="text-xs text-muted-foreground">{c.email} · {c.tenant?.rfc || '—'}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Rol a asignar</Label>
|
|
<div className="grid gap-2">
|
|
{ALL_ROLES.map(r => {
|
|
const m = ROLE_META[r];
|
|
const Icon = m.icon;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={r}
|
|
onClick={() => setPickedRole(r)}
|
|
className={`text-left rounded border-2 p-3 transition-all ${pickedRole === r ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}
|
|
>
|
|
<div className="flex items-center gap-2 font-medium text-sm mb-0.5">
|
|
<Icon className="h-4 w-4" />
|
|
{m.label}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{m.desc}</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancelar</Button>
|
|
<Button onClick={handleGrant} disabled={!pickedUserId || grantRole.isPending}>
|
|
{grantRole.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
|
Asignar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|