170 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|