Files
GhostGrid/src/components/UserDirectory.tsx
2026-06-03 15:20:06 +02:00

170 lines
7.3 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
interface UserDirectoryProps {
users: User[];
currentUser: User;
bookings: Booking[];
}
// Deterministic accent so a given user always renders the same colour.
const AVATAR_COLORS = [
'from-emerald-500 to-teal-600',
'from-cyan-500 to-blue-600',
'from-indigo-500 to-violet-600',
'from-amber-500 to-orange-600',
'from-rose-500 to-pink-600',
'from-fuchsia-500 to-purple-600',
];
function colorFor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) {
const [search, setSearch] = useState('');
const bookingCount = useMemo(() => {
const map = new Map<string, number>();
bookings.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const activeCount = useMemo(() => {
const map = new Map<string, number>();
bookings
.filter(b => b.status === 'active' || b.status === 'upcoming')
.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name));
if (!q) return sorted;
return sorted.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.role.toLowerCase().includes(q)
);
}, [users, search]);
return (
<div className="space-y-6 font-sans" id="user-directory-root">
{/* Header banner */}
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
TEAM
</div>
<div className="relative space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<Users className="w-6 h-6 text-emerald-400" />
Registered Operators
</h2>
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here.
</p>
<div className="flex flex-wrap gap-2 pt-3">
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Users className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{users.length}</strong> registered
</span>
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Calendar className="w-3.5 h-3.5 text-indigo-400" />
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
</span>
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
</span>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search operators by name, email or role…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
/>
</div>
{/* User grid */}
{filtered.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map(user => {
const isMe = user.id === currentUser.id;
const total = bookingCount.get(user.id) ?? 0;
const active = activeCount.get(user.id) ?? 0;
return (
<div
key={user.id}
className={`relative bg-[#1E293B] border rounded-xl p-5 transition-all hover:shadow-lg ${isMe ? 'border-emerald-500/50 shadow-[0_0_20px_rgba(16,185,129,0.08)]' : 'border-slate-800 hover:border-slate-700'}`}
>
{isMe && (
<span className="absolute top-3 right-3 text-[9px] font-bold uppercase tracking-wider text-emerald-400 bg-emerald-950/60 border border-emerald-900/60 px-2 py-0.5 rounded-full">
You
</span>
)}
<div className="flex items-center gap-3.5">
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${colorFor(user.id)} flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-inner`}>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
<a
href={`mailto:${user.email}`}
className="text-[11px] text-slate-400 hover:text-emerald-400 truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
>
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between">
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
<span className="flex items-center gap-1" title="Total bookings">
<Calendar className="w-3 h-3 text-indigo-400" />
{total}
</span>
<span className="flex items-center gap-1" title="Active / upcoming bookings">
<Activity className="w-3 h-3 text-emerald-400" />
{active}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}