Initial commit: Horux Despachos project
This commit is contained in:
264
apps/web/app/(dashboard)/admin/staff/page.tsx
Normal file
264
apps/web/app/(dashboard)/admin/staff/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'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: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user