feat(profile): self-service User Details page; owner Person Details (full roster, two layers, skill scoring); skill-catalog editor; canonical leaderboard

This commit is contained in:
Satyam Rastogi
2026-05-29 20:39:54 +05:30
parent 2f07103617
commit f38d4c9707
5 changed files with 1428 additions and 162 deletions
+8
View File
@@ -43,6 +43,7 @@ import KanbanPage from './pages/KanbanPage';
import LeadProjectPage from './pages/LeadProjectPage';
import StormIntelPage from './pages/StormIntelPage';
import FieldStormZonePage from './pages/FieldStormZonePage';
import UserDetailsPage from './pages/UserDetailsPage';
// ... (existing imports)
const ProtectedRoute = ({ children, allowedRoles }) => {
@@ -88,6 +89,13 @@ function App() {
</ProtectedRoute>
} />
{/* Self-service profile for internal roles */}
<Route path="/profile" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
<UserDetailsPage />
</ProtectedRoute>
} />
<Route
path="/emp/fa/dashboard"
element={
+5 -6
View File
@@ -20,13 +20,12 @@ const INITIAL_STATE = {
};
const LEADERBOARD_MOCK = [
{ id: '1', name: 'Alex M.', points: 1200, avatar: 'AM', isUser: false },
{ id: '2', name: 'Jessica R.', points: 1050, avatar: 'JR', isUser: false },
{ id: '3', name: 'David L.', points: 980, avatar: 'DL', isUser: false },
{ id: '4', name: 'Sarah M.', points: 900, avatar: 'SM', isUser: false },
{ id: 'e4', name: 'Shelby Greer', points: 1300, avatar: 'SG', isUser: false },
{ id: 'e1', name: 'Cody Tatum', points: 1200, avatar: 'CT', isUser: false },
{ id: 'user', name: 'You', points: 850, avatar: 'You', isUser: true },
{ id: '6', name: 'Mike T.', points: 700, avatar: 'MT', isUser: false },
{ id: '7', name: 'Emily C.', points: 650, avatar: 'EC', isUser: false },
{ id: 'e2', name: 'Hannah Reyes', points: 780, avatar: 'HR', isUser: false },
{ id: 'e5', name: 'Dalton Pruitt', points: 560, avatar: 'DP', isUser: false },
{ id: 'e3', name: 'Travis Boone', points: 310, avatar: 'TB', isUser: false },
];
export const GamificationProvider = ({ children }) => {
+577
View File
@@ -0,0 +1,577 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { SpotlightCard } from '../components/SpotlightCard';
import {
User, Phone, Info, BookOpen, Calendar,
Shield, Star, Save, Loader2, X, Plus
} from 'lucide-react';
import { toast } from 'sonner';
// ── Local helpers ────────────────────────────────────────────────────────────
const SectionHeader = ({ icon: Icon, label, color = 'text-blue-500 dark:text-blue-400' }) => (
<div className={`flex items-center gap-2 pb-3 mb-4 border-b border-zinc-200 dark:border-white/5 ${color}`}>
<Icon size={18} />
<h3 className="text-sm font-black uppercase tracking-widest">{label}</h3>
</div>
);
const FieldInput = ({
id, label, value, onChange, type = 'text', placeholder = '', required = false, optional = false,
}) => (
<div className="space-y-1.5">
<label htmlFor={id} className="text-xs font-bold uppercase text-zinc-500 tracking-wider">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{optional && <span className="text-zinc-400 normal-case font-normal ml-1">(optional)</span>}
</label>
<input
id={id}
type={type}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-4 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-sm text-zinc-900 dark:text-zinc-100"
/>
</div>
);
const FieldTextarea = ({ id, label, value, onChange, placeholder = '', rows = 4 }) => (
<div className="space-y-1.5">
<label htmlFor={id} className="text-xs font-bold uppercase text-zinc-500 tracking-wider">{label}</label>
<textarea
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full px-4 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-sm text-zinc-900 dark:text-zinc-100 resize-none"
/>
</div>
);
// Chip-list with add-by-Enter and remove-by-click
const ChipInput = ({ id, label, items, onAdd, onRemove, placeholder = 'Type and press Enter…' }) => {
const [draft, setDraft] = useState('');
const commitDraft = () => {
const trimmed = draft.trim();
if (trimmed && !items.includes(trimmed)) {
onAdd(trimmed);
}
setDraft('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitDraft();
}
if (e.key === 'Backspace' && draft === '' && items.length > 0) {
onRemove(items[items.length - 1]);
}
};
return (
<div className="space-y-1.5">
<label htmlFor={id} className="text-xs font-bold uppercase text-zinc-500 tracking-wider">{label}</label>
<div className="flex flex-wrap gap-1.5 min-h-[2.5rem] px-3 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus-within:ring-2 focus-within:ring-blue-500 transition-all">
{items.map((item) => (
<span
key={item}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
>
{item}
<button
type="button"
aria-label={`Remove ${item}`}
onClick={() => onRemove(item)}
className="hover:text-red-500 transition-colors"
>
<X size={11} />
</button>
</span>
))}
<input
id={id}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitDraft}
placeholder={items.length === 0 ? placeholder : ''}
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400"
/>
</div>
<p className="text-[11px] text-zinc-400">Press Enter to add · click chip to remove</p>
</div>
);
};
const ReadOnlyChip = ({ label }) => (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-300">
{label}
</span>
);
// ── Main component ────────────────────────────────────────────────────────────
const UserDetailsPage = () => {
const { user } = useAuth();
const { resolveProfile, updateOwnProfile } = useMockStore();
const me = resolveProfile(user?.id);
const selfSrc = me?.profile?.self || {};
const achievements = me?.profile?.achievements || null;
const isSub = user?.role === 'SUBCONTRACTOR';
// ── Form state seeded from profile.self ──────────────────────────────────
const [form, setForm] = useState({
// Identity
preferredName: selfSrc.preferredName ?? '',
nickname: selfSrc.nickname ?? '',
pronouns: selfSrc.pronouns ?? '',
gender: selfSrc.gender ?? '',
sex: selfSrc.sex ?? '',
photo: selfSrc.photo ?? '',
// Contact
phone: selfSrc.phone ?? '',
altContact: selfSrc.altContact ?? '',
address: selfSrc.address ?? '',
emergencyContact: selfSrc.emergencyContact ?? '',
// About
bio: selfSrc.bio ?? '',
yearsExperience: selfSrc.yearsExperience ?? '',
languages: Array.isArray(selfSrc.languages) ? [...selfSrc.languages] : [],
specialties: Array.isArray(selfSrc.specialties) ? [...selfSrc.specialties] : [],
equipment: Array.isArray(selfSrc.equipment) ? [...selfSrc.equipment] : [],
// Availability
availability: selfSrc.availability ?? '',
serviceAreas: Array.isArray(selfSrc.serviceAreas) ? [...selfSrc.serviceAreas] : [],
// Credentials (sub only)
license: selfSrc.license ?? '',
insuranceCoi: selfSrc.insurance?.coi ?? '',
insuranceExpiry: selfSrc.insurance?.expiry ?? '',
w9Status: selfSrc.w9Status ?? '',
});
// Re-seed if the user changes (e.g., hot reload / context re-init)
useEffect(() => {
if (!user?.id) return;
const s = resolveProfile(user.id)?.profile?.self || {};
setForm({
preferredName: s.preferredName ?? '',
nickname: s.nickname ?? '',
pronouns: s.pronouns ?? '',
gender: s.gender ?? '',
sex: s.sex ?? '',
photo: s.photo ?? '',
phone: s.phone ?? '',
altContact: s.altContact ?? '',
address: s.address ?? '',
emergencyContact: s.emergencyContact ?? '',
bio: s.bio ?? '',
yearsExperience: s.yearsExperience ?? '',
languages: Array.isArray(s.languages) ? [...s.languages] : [],
specialties: Array.isArray(s.specialties) ? [...s.specialties] : [],
equipment: Array.isArray(s.equipment) ? [...s.equipment] : [],
availability: s.availability ?? '',
serviceAreas: Array.isArray(s.serviceAreas) ? [...s.serviceAreas] : [],
license: s.license ?? '',
insuranceCoi: s.insurance?.coi ?? '',
insuranceExpiry: s.insurance?.expiry ?? '',
w9Status: s.w9Status ?? '',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const [saving, setSaving] = useState(false);
const photoInputRef = useRef(null);
// ── Chip helpers ─────────────────────────────────────────────────────────
const addChip = (field) => (val) => setForm(f => ({ ...f, [field]: [...f[field], val] }));
const dropChip = (field) => (val) => setForm(f => ({ ...f, [field]: f[field].filter(v => v !== val) }));
// ── Photo: file → object URL ──────────────────────────────────────────────
const handlePhotoFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setForm(f => ({ ...f, photo: url }));
};
// ── Save ──────────────────────────────────────────────────────────────────
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
await new Promise((res) => setTimeout(res, 400)); // simulate async
const patch = {
preferredName: form.preferredName,
nickname: form.nickname,
pronouns: form.pronouns,
gender: form.gender,
sex: form.sex,
photo: form.photo,
phone: form.phone,
altContact: form.altContact,
address: form.address,
emergencyContact: form.emergencyContact,
bio: form.bio,
yearsExperience: form.yearsExperience === '' ? 0 : Number(form.yearsExperience),
languages: form.languages,
specialties: form.specialties,
equipment: form.equipment,
availability: form.availability,
serviceAreas: form.serviceAreas,
...(isSub && {
license: form.license,
insurance: { coi: form.insuranceCoi, expiry: form.insuranceExpiry },
w9Status: form.w9Status,
}),
};
updateOwnProfile(user.id, patch);
toast.success('Profile saved!');
} catch {
toast.error('Could not save profile. Please try again.');
} finally {
setSaving(false);
}
};
// ── Avatar initials ───────────────────────────────────────────────────────
const initials = (me?.name || user?.name || '?').charAt(0).toUpperCase();
const displayName = me?.name || user?.name || 'My Profile';
const hasAchievements =
achievements &&
(achievements.level > 0 ||
achievements.xp > 0 ||
achievements.streak > 0 ||
(achievements.badges?.length ?? 0) > 0 ||
(achievements.tiers?.length ?? 0) > 0);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-6 md:p-10 pt-24 transition-colors duration-300">
<div className="max-w-3xl mx-auto space-y-8">
{/* ── Page header ─────────────────────────────────────────── */}
<div className="flex items-center gap-4">
{form.photo ? (
<img
src={form.photo}
alt="Profile"
className="w-16 h-16 rounded-full object-cover border-2 border-zinc-300 dark:border-zinc-600"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shrink-0">
{initials}
</div>
)}
<div>
<h1 className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60">
{displayName}
</h1>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-0.5">
Edit your profile · <span className="font-semibold text-blue-500">{user?.role}</span>
</p>
</div>
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* ── IDENTITY ────────────────────────────────────────── */}
<SpotlightCard>
<div className="p-6 space-y-5">
<SectionHeader icon={User} label="Identity" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FieldInput
id="preferredName"
label="Preferred Name"
value={form.preferredName}
onChange={(v) => setForm(f => ({ ...f, preferredName: v }))}
placeholder="What you go by day-to-day"
/>
<FieldInput
id="nickname"
label="Nickname"
value={form.nickname}
onChange={(v) => setForm(f => ({ ...f, nickname: v }))}
placeholder="e.g. Sparky"
optional
/>
<FieldInput
id="pronouns"
label="Pronouns"
value={form.pronouns}
onChange={(v) => setForm(f => ({ ...f, pronouns: v }))}
placeholder="e.g. he/him, she/her, they/them"
optional
/>
<FieldInput
id="gender"
label="Gender"
value={form.gender}
onChange={(v) => setForm(f => ({ ...f, gender: v }))}
placeholder="e.g. Male, Female, Non-binary"
optional
/>
<FieldInput
id="sex"
label="Sex"
value={form.sex}
onChange={(v) => setForm(f => ({ ...f, sex: v }))}
placeholder="e.g. Male, Female, Intersex"
optional
/>
</div>
{/* Photo */}
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-zinc-500 tracking-wider">Photo</label>
<div className="flex items-center gap-3">
<input
id="photoUrl"
type="text"
value={form.photo}
onChange={(e) => setForm(f => ({ ...f, photo: e.target.value }))}
placeholder="Paste an image URL…"
className="flex-1 px-4 py-2 bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-sm text-zinc-900 dark:text-zinc-100"
/>
<span className="text-xs text-zinc-400 shrink-0">or</span>
<button
type="button"
onClick={() => photoInputRef.current?.click()}
className="shrink-0 px-3 py-2 rounded-lg border border-zinc-300 dark:border-zinc-600 text-xs font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors flex items-center gap-1.5"
>
<Plus size={13} /> Upload
</button>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoFile}
/>
</div>
</div>
</div>
</SpotlightCard>
{/* ── CONTACT ─────────────────────────────────────────── */}
<SpotlightCard>
<div className="p-6 space-y-5">
<SectionHeader icon={Phone} label="Contact" color="text-emerald-500 dark:text-emerald-400" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FieldInput
id="phone"
label="Phone"
value={form.phone}
onChange={(v) => setForm(f => ({ ...f, phone: v }))}
placeholder="e.g. 469-555-0100"
type="tel"
/>
<FieldInput
id="altContact"
label="Alt Contact"
value={form.altContact}
onChange={(v) => setForm(f => ({ ...f, altContact: v }))}
placeholder="Secondary phone or email"
/>
</div>
<FieldTextarea
id="address"
label="Address"
value={form.address}
onChange={(v) => setForm(f => ({ ...f, address: v }))}
placeholder="Street, City, State ZIP"
rows={2}
/>
<FieldInput
id="emergencyContact"
label="Emergency Contact"
value={form.emergencyContact}
onChange={(v) => setForm(f => ({ ...f, emergencyContact: v }))}
placeholder="Name · Phone"
/>
</div>
</SpotlightCard>
{/* ── ABOUT ───────────────────────────────────────────── */}
<SpotlightCard>
<div className="p-6 space-y-5">
<SectionHeader icon={BookOpen} label="About" color="text-purple-500 dark:text-purple-400" />
<FieldTextarea
id="bio"
label="Bio"
value={form.bio}
onChange={(v) => setForm(f => ({ ...f, bio: v }))}
placeholder="A brief professional description…"
rows={4}
/>
<FieldInput
id="yearsExperience"
label="Years of Experience"
value={form.yearsExperience}
onChange={(v) => setForm(f => ({ ...f, yearsExperience: v }))}
type="number"
placeholder="0"
/>
<ChipInput
id="languages"
label="Languages"
items={form.languages}
onAdd={addChip('languages')}
onRemove={dropChip('languages')}
placeholder="e.g. English…"
/>
<ChipInput
id="specialties"
label="Specialties"
items={form.specialties}
onAdd={addChip('specialties')}
onRemove={dropChip('specialties')}
placeholder="e.g. Roof Inspection…"
/>
<ChipInput
id="equipment"
label="Equipment"
items={form.equipment}
onAdd={addChip('equipment')}
onRemove={dropChip('equipment')}
placeholder="e.g. drone, bucket truck…"
/>
</div>
</SpotlightCard>
{/* ── AVAILABILITY ────────────────────────────────────── */}
<SpotlightCard>
<div className="p-6 space-y-5">
<SectionHeader icon={Calendar} label="Availability" color="text-amber-500 dark:text-amber-400" />
<FieldInput
id="availability"
label="Availability"
value={form.availability}
onChange={(v) => setForm(f => ({ ...f, availability: v }))}
placeholder="e.g. MonFri 7 am5 pm"
/>
<ChipInput
id="serviceAreas"
label="Service Areas"
items={form.serviceAreas}
onAdd={addChip('serviceAreas')}
onRemove={dropChip('serviceAreas')}
placeholder="e.g. Plano…"
/>
</div>
</SpotlightCard>
{/* ── CREDENTIALS (SUBCONTRACTOR only) ────────────────── */}
{isSub && (
<SpotlightCard>
<div className="p-6 space-y-5">
<SectionHeader icon={Shield} label="Credentials" color="text-rose-500 dark:text-rose-400" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FieldInput
id="license"
label="License Number"
value={form.license}
onChange={(v) => setForm(f => ({ ...f, license: v }))}
placeholder="e.g. TX-ELEC-ME-88234"
/>
<FieldInput
id="w9Status"
label="W-9 Status"
value={form.w9Status}
onChange={(v) => setForm(f => ({ ...f, w9Status: v }))}
placeholder="e.g. signed, pending"
/>
<FieldInput
id="insuranceCoi"
label="Insurance COI"
value={form.insuranceCoi}
onChange={(v) => setForm(f => ({ ...f, insuranceCoi: v }))}
placeholder="Certificate of Insurance ID"
/>
<FieldInput
id="insuranceExpiry"
label="Insurance Expiry"
value={form.insuranceExpiry}
onChange={(v) => setForm(f => ({ ...f, insuranceExpiry: v }))}
placeholder="YYYY-MM-DD"
type="date"
/>
</div>
</div>
</SpotlightCard>
)}
{/* ── ACHIEVEMENTS (read-only) ─────────────────────────── */}
{hasAchievements && (
<SpotlightCard>
<div className="p-6 space-y-4">
<SectionHeader icon={Star} label="Achievements" color="text-yellow-500 dark:text-yellow-400" />
<p className="text-[11px] uppercase font-bold tracking-wider text-zinc-400 flex items-center gap-1">
<Info size={12} /> Read-only
</p>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 text-center">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Level</div>
<div className="text-2xl font-extrabold text-yellow-500">{achievements.level}</div>
</div>
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 text-center">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">XP</div>
<div className="text-2xl font-extrabold text-blue-500">{achievements.xp}</div>
</div>
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 text-center">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Streak</div>
<div className="text-2xl font-extrabold text-emerald-500">{achievements.streak}</div>
</div>
</div>
{achievements.badges?.length > 0 && (
<div>
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-2">Badges</div>
<div className="flex flex-wrap gap-1.5">
{achievements.badges.map((b) => (
<ReadOnlyChip key={b} label={b} />
))}
</div>
</div>
)}
{achievements.tiers?.length > 0 && (
<div>
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-2">Tiers</div>
<div className="flex flex-wrap gap-1.5">
{achievements.tiers.map((t) => (
<ReadOnlyChip key={t} label={t} />
))}
</div>
</div>
)}
</div>
</SpotlightCard>
)}
{/* ── Save bar ─────────────────────────────────────────── */}
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<><Loader2 className="animate-spin" size={18} /> Saving</>
) : (
<><Save size={18} /> Save Changes</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default UserDetailsPage;
+230
View File
@@ -1037,6 +1037,221 @@ const UserOverridesSection = ({ members, roles, orgDefaults, roleOverrides, user
);
};
// ────────────────────────────────────────────────────
// 6. Skill Catalog Section
// ────────────────────────────────────────────────────
const SkillCatalogSection = ({ skillCatalog, addCatalogSkill, updateCatalogSkill }) => {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const [addingNew, setAddingNew] = useState(false);
const [newForm, setNewForm] = useState({ name: '', description: '' });
const slugify = (str) =>
str.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
const startEdit = (skill) => {
setEditingId(skill.id);
setEditForm({ name: skill.name, description: skill.description });
setAddingNew(false);
};
const cancelEdit = () => {
setEditingId(null);
setEditForm({ name: '', description: '' });
};
const handleSaveEdit = (id) => {
if (!editForm.name.trim()) return;
updateCatalogSkill(id, { name: editForm.name.trim(), description: editForm.description.trim() });
setEditingId(null);
toast.success('Skill updated');
};
const handleAddSkill = () => {
if (!newForm.name.trim()) return;
addCatalogSkill({
id: 'sk_' + slugify(newForm.name),
name: newForm.name.trim(),
description: newForm.description.trim(),
});
setNewForm({ name: '', description: '' });
setAddingNew(false);
toast.success('Skill added to catalog');
};
const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-violet-400 dark:focus:border-violet-500/50 transition-colors";
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-500">Define the skills agents can earn and level up in your organization</p>
{!addingNew && (
<button
type="button"
onClick={() => { setAddingNew(true); setEditingId(null); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-50 dark:bg-violet-500/10 border border-violet-200 dark:border-violet-500/30 text-xs font-bold text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-500/20 transition-colors"
>
<Plus size={12} /> Add Skill
</button>
)}
</div>
{/* Add New Skill Form */}
<AnimatePresence>
{addingNew && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-xl border border-violet-200 dark:border-violet-500/20 bg-violet-50/50 dark:bg-violet-500/[0.03] p-4 space-y-3">
<p className="text-xs font-bold text-violet-700 dark:text-violet-400 uppercase tracking-wider">New Skill</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Name</label>
<input
type="text"
placeholder="e.g. Objection Handling"
value={newForm.name}
onChange={e => setNewForm(prev => ({ ...prev, name: e.target.value }))}
className={inputClasses}
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Description</label>
<input
type="text"
placeholder="Short description of the skill"
value={newForm.description}
onChange={e => setNewForm(prev => ({ ...prev, description: e.target.value }))}
className={inputClasses}
/>
</div>
</div>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => { setAddingNew(false); setNewForm({ name: '', description: '' }); }}
className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleAddSkill}
disabled={!newForm.name.trim()}
className="flex-1 px-4 py-2 rounded-xl bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-500/30 text-xs font-bold hover:bg-violet-200 dark:hover:bg-violet-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Add Skill
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Skill List */}
<div className="space-y-2">
{skillCatalog.length === 0 && !addingNew && (
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic px-2">No skills defined yet — add the first one above.</p>
)}
{skillCatalog.map(skill => {
const isEditing = editingId === skill.id;
return (
<div key={skill.id} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-500/10 border border-violet-200 dark:border-violet-500/20 flex items-center justify-center shrink-0">
<BarChart3 size={13} className="text-violet-500 dark:text-violet-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{skill.name}</p>
{skill.description && (
<p className="text-[11px] text-zinc-500 truncate">{skill.description}</p>
)}
</div>
</div>
<button
type="button"
onClick={() => isEditing ? cancelEdit() : startEdit(skill)}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-white transition-colors shrink-0 ml-2"
>
{isEditing ? <X size={14} /> : <Edit3 size={13} />}
</button>
</div>
{/* Inline Edit Form */}
<AnimatePresence>
{isEditing && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 pt-2 border-t border-zinc-100 dark:border-white/5 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Name</label>
<input
type="text"
value={editForm.name}
onChange={e => setEditForm(prev => ({ ...prev, name: e.target.value }))}
className={inputClasses}
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Description</label>
<input
type="text"
value={editForm.description}
onChange={e => setEditForm(prev => ({ ...prev, description: e.target.value }))}
className={inputClasses}
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={cancelEdit}
className="px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => handleSaveEdit(skill.id)}
disabled={!editForm.name.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-200 dark:border-violet-500/30 text-xs font-bold hover:bg-violet-200 dark:hover:bg-violet-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Save size={11} /> Save
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Info Footer */}
<div className="p-4 bg-zinc-50 dark:bg-white/[0.02] rounded-xl border border-zinc-200 dark:border-white/5">
<div className="flex items-start gap-2">
<Info size={14} className="text-zinc-400 dark:text-zinc-600 mt-0.5 shrink-0" />
<p className="text-[11px] text-zinc-500">
Skills defined here appear in agent profiles and performance reviews. The skill ID is auto-generated from the name and cannot be changed after creation.
</p>
</div>
</div>
</div>
);
};
// ────────────────────────────────────────────────────
// Main Page
// ────────────────────────────────────────────────────
@@ -1052,6 +1267,7 @@ const OrgSettings = () => {
access: false,
commission: false,
overrides: false,
skillCatalog: false,
});
const toggleSection = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
@@ -1160,6 +1376,20 @@ const OrgSettings = () => {
/>
),
},
{
id: 'skillCatalog',
icon: BarChart3,
title: 'Skill Catalog',
subtitle: `${store.skillCatalog.length} skill${store.skillCatalog.length !== 1 ? 's' : ''} defined — manage the skills agents can earn`,
color: '#7c3aed',
content: (
<SkillCatalogSection
skillCatalog={store.skillCatalog}
addCatalogSkill={store.addCatalogSkill}
updateCatalogSkill={store.updateCatalogSkill}
/>
),
},
];
return (
+608 -156
View File
@@ -1,39 +1,256 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useCallback } from 'react';
import { useMockStore } from '../../data/mockStore';
import { Search, Filter, Shield, User, Briefcase, Zap, ArrowLeft } from 'lucide-react';
import {
Search, Shield, User, Briefcase, Zap, ArrowLeft,
Award, Star, ChevronDown, ChevronUp, Save, BookOpen,
MapPin, Wrench, Globe
} from 'lucide-react';
import MaskedData from '../../components/MaskedData';
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
import { SpotlightCard } from '../../components/SpotlightCard';
// ── Badge chip ───────────────────────────────────────────────────────────────
const BadgeChip = ({ label }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-100 text-amber-700 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
<Award size={10} />
{label}
</span>
);
// ── Status tag chip ──────────────────────────────────────────────────────────
const StatusTagChip = ({ label }) => {
const colorMap = {
preferred: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20',
probation: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20',
suspended: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20',
};
const cls = colorMap[label?.toLowerCase()] || 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-white/10 dark:text-zinc-300 dark:border-white/10';
return (
<span className={`inline-flex px-2 py-0.5 rounded-full text-[10px] font-bold border uppercase tracking-wider ${cls}`}>
{label}
</span>
);
};
// ── Skill score bar ──────────────────────────────────────────────────────────
const SkillBar = ({ score }) => {
if (score === 'N/A' || score === null || score === undefined) return null;
const pct = Math.min(100, Math.max(0, Number(score)));
const color = pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-amber-500' : 'bg-red-500';
return (
<div className="w-full bg-zinc-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
);
};
// ── XP tier color ────────────────────────────────────────────────────────────
function tierColor(tier) {
if (!tier) return 'text-zinc-400';
const t = tier.toLowerCase();
if (t === 'gold') return 'text-amber-400';
if (t === 'silver') return 'text-zinc-400';
if (t === 'bronze') return 'text-orange-400';
return 'text-zinc-400';
}
// ── Collapsible section wrapper ───────────────────────────────────────────────
const Section = ({ icon, title, iconColor = 'text-blue-500', children, defaultOpen = true }) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="space-y-3">
<button
onClick={() => setOpen(v => !v)}
className="w-full text-left text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
{React.cloneElement(icon, { size: 16, className: iconColor })}
{title}
</span>
{open ? <ChevronUp size={14} className="text-zinc-400" /> : <ChevronDown size={14} className="text-zinc-400" />}
</button>
{open && children}
</div>
);
};
// ── Detail row ───────────────────────────────────────────────────────────────
const DetailRow = ({ label, value }) => {
if (!value && value !== 0) return null;
return (
<div className="flex justify-between items-start py-1.5 border-b border-zinc-100 dark:border-white/5 last:border-0 gap-4">
<span className="text-zinc-500 dark:text-zinc-400 text-sm shrink-0">{label}</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium text-right">{value}</span>
</div>
);
};
// ── Avatar color based on person category (_src set during roster build) ─────
function avatarCls(person) {
if (person._src === 'vendor') return 'bg-purple-100 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400';
if (person.role === 'FIELD_AGENT') return 'bg-sky-100 text-sky-600 dark:bg-sky-500/20 dark:text-sky-400';
if (person._src === 'subcontractor') return 'bg-orange-100 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400';
return 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400';
}
// ── Filter button ─────────────────────────────────────────────────────────────
const FilterBtn = ({ active, onClick, color, label }) => {
const activeClasses = {
default: 'bg-zinc-900 text-white dark:bg-white dark:text-black',
blue: 'bg-blue-600 text-white dark:bg-blue-500',
sky: 'bg-sky-600 text-white dark:bg-sky-500',
orange: 'bg-orange-600 text-white dark:bg-orange-500',
purple: 'bg-purple-600 text-white dark:bg-purple-500',
};
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${
active
? (activeClasses[color] || activeClasses.default)
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
{label}
</button>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
const PeopleDirectory = () => {
const { personnel, vendors } = useMockStore();
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
const { personnel, vendors, users, subcontractors, resolveProfile, skillCatalog, setSkillScore } = useMockStore();
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [selectedPerson, setSelectedPerson] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [notesEdit, setNotesEdit] = useState(null); // null | string (local draft while editing)
const [notesSaved, setNotesSaved] = useState({}); // { [personId]: string } — session-persisted override
const [notesSaving, setNotesSaving] = useState(false);
const [skillDraft, setSkillDraft] = useState({}); // { [skillId]: number }
const detailRef = useRef(null);
const handleSelectPerson = (person) => {
setSelectedPerson(person);
};
// ── Build unified roster ────────────────────────────────────────────────
const fieldAgents = (users || []).filter(u => u.role === 'FIELD_AGENT');
// Combine and normalize data for display
const allPeople = [
...personnel.map(p => ({ ...p, type: 'personnel', category: 'Internal Team' })),
...vendors.map(v => ({ ...v, name: v.vendorName, type: 'vendor', category: 'Vendor/Partner' }))
...personnel.map(p => ({
...p,
_src: 'personnel',
_category: 'Internal Team',
})),
...fieldAgents.map(u => ({
...u,
_src: 'user',
_category: 'Field Agent',
status: u.status || 'active',
})),
...(subcontractors || []).map(s => ({
...s,
_src: 'subcontractor',
_category: 'Subcontractor',
})),
...vendors.map(v => ({
...v,
name: v.vendorName || v.name,
_src: 'vendor',
_category: 'Vendor/Partner',
})),
];
const filteredPeople = allPeople.filter(person => {
const matchesSearch = person.name.toLowerCase().includes(search.toLowerCase()) ||
person.email?.toLowerCase().includes(search.toLowerCase());
if (filter === 'all') return matchesSearch;
if (filter === 'employee') return matchesSearch && person.type === 'personnel';
if (filter === 'contractor') return matchesSearch && person.role === 'CONTRACTOR'; // Assuming role field exists
if (filter === 'subcontractor') return matchesSearch && person.role === 'SUBCONTRACTOR'; // Assuming role field exists
return matchesSearch;
// ── Filter + search ─────────────────────────────────────────────────────
const filteredPeople = allPeople.filter(p => {
const q = search.toLowerCase();
const matchSearch = (p.name || '').toLowerCase().includes(q) ||
(p.email || '').toLowerCase().includes(q);
if (!matchSearch) return false;
if (filter === 'all') return true;
if (filter === 'agent') return p._src === 'user' && p.role === 'FIELD_AGENT';
if (filter === 'personnel') return p._src === 'personnel';
if (filter === 'subcontractor') return p._src === 'subcontractor';
if (filter === 'vendor') return p._src === 'vendor';
return true;
});
// ── Group for display ───────────────────────────────────────────────────
const groups = ['Internal Team', 'Field Agent', 'Subcontractor', 'Vendor/Partner'];
const grouped = groups
.map(cat => ({ cat, items: filteredPeople.filter(p => p._category === cat) }))
.filter(g => g.items.length > 0);
// ── Selected person + resolved profile ─────────────────────────────────
const selectedRaw = selectedId ? allPeople.find(p => p.id === selectedId) || null : null;
const resolved = selectedRaw ? resolveProfile(selectedRaw.id) : null;
const profile = resolved?.profile || null;
const self = profile?.self || {};
const company = profile?.company || {};
const achievements = profile?.achievements || null;
// ── Notes editing ────────────────────────────────────────────────────────
const handleSelectPerson = useCallback((person) => {
setSelectedId(person.id);
setNotesEdit(null);
setSkillDraft({});
}, []);
const handleNotesChange = (val) => setNotesEdit(val);
const handleNotesSave = () => {
if (notesEdit === null || !selectedRaw) return;
setNotesSaving(true);
// The store does not expose an updateCompanyNotes action, so we persist
// within component state (session-scoped). The saved map survives navigation
// within this panel session and takes precedence over the seed data.
setTimeout(() => {
setNotesSaved(prev => ({ ...prev, [selectedRaw.id]: notesEdit }));
setNotesEdit(null); // return to "clean" state; notesSaved is now canonical
setNotesSaving(false);
}, 300);
};
// ── Skill scoring ────────────────────────────────────────────────────────
const getEffectiveScore = (skillId) => {
if (skillDraft[skillId] !== undefined) return skillDraft[skillId];
const entry = (company.skillScores || []).find(s => s.skillId === skillId);
return entry ? entry.score : 'N/A';
};
const handleSkillChange = (skillId, raw) => {
const num = parseInt(raw, 10);
if (!isNaN(num)) {
const clamped = Math.min(100, Math.max(0, num));
setSkillDraft(prev => ({ ...prev, [skillId]: clamped }));
}
};
const handleSkillSave = (skillId) => {
if (skillDraft[skillId] === undefined || !selectedRaw) return;
setSkillScore(selectedRaw.id, skillId, skillDraft[skillId]);
setSkillDraft(prev => {
const n = { ...prev };
delete n[skillId];
return n;
});
};
// ── Status badge ─────────────────────────────────────────────────────────
const statusBadge = (status) => {
const s = (status || 'unknown').toLowerCase();
const cls = s === 'active'
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
: s === 'inactive'
? 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:border-white/10'
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20';
return <span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${cls}`}>{status || 'unknown'}</span>;
};
// ── Effective notes: draft > session-saved > store seed ─────────────────
const sessionNote = selectedRaw ? (notesSaved[selectedRaw.id] ?? null) : null;
const canonicalNotes = sessionNote !== null ? sessionNote : (company.internalNotes || '');
const displayNotes = notesEdit !== null ? notesEdit : canonicalNotes;
const hasUnsavedNotes = notesEdit !== null && notesEdit !== canonicalNotes;
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
{/* Ambient Background Glows */}
@@ -44,14 +261,19 @@ const PeopleDirectory = () => {
<div className="relative z-10 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
<header className="mb-6 shrink-0">
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">People Directory</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Manage your team, contractors, and compliance records.</p>
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
People Directory
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
Manage your team, field agents, subcontractors, and compliance records.
</p>
</header>
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Left Sidebar: List & Search */}
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedPerson ? 'hidden lg:flex' : 'flex'}`}>
{/* ── Left Sidebar: List & Search ─────────────────────── */}
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedId ? 'hidden lg:flex' : 'flex'}`}>
<div className="p-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-4">
{/* Search */}
<div className="relative">
<label htmlFor="search-people" className="sr-only">Search by name or email</label>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
@@ -61,172 +283,400 @@ const PeopleDirectory = () => {
type="text"
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={e => setSearch(e.target.value)}
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'all'
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
All
</button>
<button
onClick={() => setFilter('employee')}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'employee'
? 'bg-blue-600 text-white dark:bg-blue-500 dark:text-white'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
Employees
</button>
<button
onClick={() => setFilter('contractor')}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'contractor'
? 'bg-purple-600 text-white dark:bg-purple-500 dark:text-white'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
Contractors
</button>
<FilterBtn active={filter === 'all'} onClick={() => setFilter('all')} color="default" label="All" />
<FilterBtn active={filter === 'personnel'} onClick={() => setFilter('personnel')} color="blue" label="Internal" />
<FilterBtn active={filter === 'agent'} onClick={() => setFilter('agent')} color="sky" label="Field Agents" />
<FilterBtn active={filter === 'subcontractor'} onClick={() => setFilter('subcontractor')} color="orange" label="Subs" />
<FilterBtn active={filter === 'vendor'} onClick={() => setFilter('vendor')} color="purple" label="Vendors" />
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{filteredPeople.map((person) => (
<div
key={person.id}
onClick={() => handleSelectPerson(person)}
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
}`}
>
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ${person.type === 'personnel'
? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400 ring-2 ring-white dark:ring-zinc-900 group-hover:scale-110 transition-transform'
: 'bg-purple-100 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400 ring-2 ring-white dark:ring-zinc-900 group-hover:scale-110 transition-transform'
}`}>
{person.name.charAt(0)}
</div>
<div>
<h3 className={`font-bold text-sm ${selectedPerson?.id === person.id ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-white'}`}>
{person.name}
</h3>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{person.role || person.vendorType}</p>
</div>
{/* Grouped list */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{grouped.length === 0 && (
<p className="text-center text-zinc-400 text-sm py-8">No results found.</p>
)}
{grouped.map(({ cat, items }) => (
<div key={cat} className="mb-3">
<p className="px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
{cat} ({items.length})
</p>
<div className="space-y-1">
{items.map(person => (
<div
key={person.id}
onClick={() => handleSelectPerson(person)}
className={`p-3 rounded-xl border transition-all cursor-pointer group ${
selectedId === person.id
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
}`}
>
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ring-2 ring-white dark:ring-zinc-900 group-hover:scale-110 transition-transform ${avatarCls(person)}`}>
{(person.name || '?').charAt(0)}
</div>
<div className="min-w-0">
<h3 className={`font-bold text-sm truncate ${selectedId === person.id ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-white'}`}>
{person.name}
</h3>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wide truncate">
{person.role || person.tradeType || person.vendorType || cat}
</p>
</div>
<div className="ml-auto shrink-0">
{statusBadge(person.status)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</SpotlightCard>
{/* Right Panel: Details */}
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedPerson ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
{selectedPerson ? (
{/* ── Right Panel: Details ─────────────────────────────── */}
<div
className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedId ? 'hidden lg:flex' : 'flex'}`}
ref={detailRef}
>
{selectedRaw ? (
<SpotlightCard className="h-full flex flex-col overflow-hidden">
{/* Mobile Back Button */}
{/* Mobile Back */}
<button
onClick={() => setSelectedPerson(null)}
onClick={() => setSelectedId(null)}
className="lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-bold text-blue-600 dark:text-blue-400 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5"
>
<ArrowLeft size={16} />
Back to People
</button>
{/* Header */}
<div className="p-4 sm:p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start">
<div className="p-4 sm:p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start gap-4">
<div>
<div className="flex items-center space-x-3 mb-2">
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
}`}>
{selectedPerson.status}
</span>
<div className="flex items-center flex-wrap gap-2 mb-1">
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">
{self.preferredName || selectedRaw.name}
</h2>
{self.preferredName && self.preferredName !== selectedRaw.name && (
<span className="text-zinc-400 text-base font-normal">({selectedRaw.name})</span>
)}
{statusBadge(selectedRaw.status)}
</div>
<p className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2 text-sm font-medium">
<Briefcase size={16} />
{selectedPerson.category} {selectedPerson.role || selectedPerson.vendorType}
{selectedRaw._category} &bull; {selectedRaw.role || selectedRaw.tradeType || selectedRaw.vendorType || '—'}
</p>
{selectedRaw.empId && (
<p className="text-xs text-zinc-400 mt-0.5">ID: {selectedRaw.empId}</p>
)}
{/* Status tags */}
{(company.statusTags || []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{company.statusTags.map(t => <StatusTagChip key={t} label={t} />)}
</div>
)}
</div>
<button className="px-4 py-2 bg-white dark:bg-white/5 hover:bg-zinc-50 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/10 shadow-sm">
Edit Profile
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
{/* Sensitive Data Section */}
<div className="space-y-6 sm:space-y-8">
<div className="space-y-4">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
<Shield size={16} className="text-blue-500" />
Sensitive Information
</h3>
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Email</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.email || selectedPerson.primaryContact?.email}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Phone</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.phone || selectedPerson.primaryContact?.phone}</span>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-6 space-y-8">
{/* Masked Fields */}
{selectedPerson.sensitiveData?.ssn && (
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">SSN</span>
<MaskedData value={selectedPerson.sensitiveData.ssn} label="SSN" />
</div>
)}
{selectedPerson.sensitiveData?.ein && (
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">EIN</span>
<MaskedData value={selectedPerson.sensitiveData.ein} label="EIN" />
</div>
)}
{selectedPerson.sensitiveData?.bankAccount && (
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Bank Account</span>
<MaskedData value={selectedPerson.sensitiveData.bankAccount} label="Bank" />
</div>
)}
</div>
</div>
</div>
{/* Compliance Section */}
<div className="space-y-8">
{/* ── Self Profile ─────────────────────── */}
<Section icon={<User />} title="Profile" iconColor="text-blue-500">
{self.bio && (
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-3">
{self.bio}
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-0">
<div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2 mb-4">
<Zap size={16} className="text-amber-500" />
Compliance & Documents
</h3>
<ComplianceChecklist data={selectedPerson} type={selectedPerson.type} />
<div className="mt-4 p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
<h4 className="text-xs font-bold text-blue-600 dark:text-blue-300 mb-3 uppercase tracking-wider">Quick Actions</h4>
<div className="flex gap-3">
<button className="flex-1 px-3 py-2 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500 text-white rounded-lg text-xs font-bold uppercase transition-colors shadow-sm">
Request Document
</button>
<button className="flex-1 px-3 py-2 bg-white hover:bg-zinc-50 dark:bg-white/5 dark:hover:bg-white/10 rounded-lg text-xs font-bold uppercase transition-colors text-zinc-600 dark:text-zinc-300 border border-zinc-200 dark:border-white/10">
View Audit Log
</button>
<DetailRow label="Phone" value={self.phone || selectedRaw.phone || selectedRaw.primaryContact?.phone} />
<DetailRow label="Alt Contact" value={self.altContact} />
<DetailRow label="Email" value={selectedRaw.email || selectedRaw.primaryContact?.email} />
<DetailRow label="Address" value={self.address} />
<DetailRow label="Emergency Contact" value={self.emergencyContact} />
<DetailRow label="Nickname" value={self.nickname} />
<DetailRow label="Pronouns" value={self.pronouns} />
<DetailRow label="Years Experience" value={self.yearsExperience ? `${self.yearsExperience} yrs` : null} />
</div>
<div>
<DetailRow label="Availability" value={self.availability} />
{(self.serviceAreas || []).length > 0 && (
<div className="flex justify-between items-start py-1.5 border-b border-zinc-100 dark:border-white/5 gap-4">
<span className="text-zinc-500 dark:text-zinc-400 text-sm shrink-0 flex items-center gap-1">
<MapPin size={12} />Service Areas
</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium text-right">
{self.serviceAreas.join(', ')}
</span>
</div>
</div>
)}
{(self.languages || []).length > 0 && (
<div className="flex justify-between items-start py-1.5 border-b border-zinc-100 dark:border-white/5 gap-4">
<span className="text-zinc-500 dark:text-zinc-400 text-sm shrink-0 flex items-center gap-1">
<Globe size={12} />Languages
</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium text-right">
{self.languages.join(', ')}
</span>
</div>
)}
{(self.specialties || []).length > 0 && (
<div className="flex justify-between items-start py-1.5 border-b border-zinc-100 dark:border-white/5 gap-4">
<span className="text-zinc-500 dark:text-zinc-400 text-sm shrink-0">Specialties</span>
<div className="flex flex-wrap gap-1 justify-end">
{self.specialties.map(s => (
<span key={s} className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 text-blue-700 dark:text-blue-300 rounded text-[10px] font-medium">
{s}
</span>
))}
</div>
</div>
)}
{(self.equipment || []).length > 0 && (
<div className="flex justify-between items-start py-1.5 border-b border-zinc-100 dark:border-white/5 gap-4">
<span className="text-zinc-500 dark:text-zinc-400 text-sm shrink-0 flex items-center gap-1">
<Wrench size={12} />Equipment
</span>
<span className="text-zinc-900 dark:text-white text-sm font-medium text-right">
{self.equipment.join(', ')}
</span>
</div>
)}
</div>
</div>
</div>
{/* Sub-specific: license / insurance / w9 */}
{selectedRaw._src === 'subcontractor' && (self.license || self.insurance || self.w9Status) && (
<div className="mt-3 p-3 rounded-xl bg-orange-50 dark:bg-orange-500/10 border border-orange-100 dark:border-orange-500/20 space-y-2">
<p className="text-xs font-bold text-orange-600 dark:text-orange-300 uppercase tracking-wider mb-1">Sub Credentials</p>
{self.license && <DetailRow label="License" value={self.license} />}
{self.insurance?.coi && <DetailRow label="COI" value={self.insurance.coi} />}
{self.insurance?.expiry && (
<DetailRow label="Insurance Expiry" value={new Date(self.insurance.expiry).toLocaleDateString()} />
)}
{self.w9Status && <DetailRow label="W-9 Status" value={self.w9Status} />}
</div>
)}
</Section>
{/* ── Sensitive Information ─────────────── */}
{(selectedRaw.email || selectedRaw.phone || selectedRaw.sensitiveData) && (
<Section icon={<Shield />} title="Sensitive Information" iconColor="text-rose-500">
<div className="space-y-0">
<DetailRow
label="Email"
value={selectedRaw.email || selectedRaw.primaryContact?.email}
/>
<DetailRow
label="Phone"
value={selectedRaw.phone || selectedRaw.primaryContact?.phone}
/>
{selectedRaw.sensitiveData?.ssn && (
<div className="flex justify-between items-center py-1.5 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">SSN</span>
<MaskedData value={selectedRaw.sensitiveData.ssn} label="SSN" />
</div>
)}
{selectedRaw.sensitiveData?.ein && (
<div className="flex justify-between items-center py-1.5 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">EIN</span>
<MaskedData value={selectedRaw.sensitiveData.ein} label="EIN" />
</div>
)}
{selectedRaw.sensitiveData?.bankAccount && (
<div className="flex justify-between items-center py-1.5 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Bank Account</span>
<MaskedData value={selectedRaw.sensitiveData.bankAccount} label="Bank" />
</div>
)}
</div>
</Section>
)}
{/* ── Achievements ─────────────────────── */}
{achievements && (achievements.xp > 0 || (achievements.badges || []).length > 0) && (
<Section icon={<Award />} title="Achievements" iconColor="text-amber-500">
<div className="flex flex-wrap gap-4 items-center mb-3">
<div className="text-center">
<p className="text-2xl font-extrabold text-zinc-900 dark:text-white">
Lv {achievements.level}
</p>
<p className="text-[10px] text-zinc-400 uppercase">Level</p>
</div>
<div className="text-center">
<p className="text-2xl font-extrabold text-amber-500">
{(achievements.xp || 0).toLocaleString()}
</p>
<p className="text-[10px] text-zinc-400 uppercase">XP</p>
</div>
<div className="text-center">
<p className="text-2xl font-extrabold text-sky-500">
{achievements.streak || 0}
</p>
<p className="text-[10px] text-zinc-400 uppercase">Streak Days</p>
</div>
{(achievements.tiers || []).length > 0 && (
<div className="text-center">
{achievements.tiers.map(t => (
<p key={t} className={`text-sm font-bold ${tierColor(t)}`}>{t}</p>
))}
<p className="text-[10px] text-zinc-400 uppercase">Tier</p>
</div>
)}
</div>
{(achievements.badges || []).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{achievements.badges.map(b => <BadgeChip key={b} label={b} />)}
</div>
)}
</Section>
)}
{/* ── Company (Owner-only) ──────────────── */}
<Section icon={<BookOpen />} title="Owner Notes & Details" iconColor="text-violet-500">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-0 mb-4">
<DetailRow label="Hire Date" value={company.hireDate ? new Date(company.hireDate).toLocaleDateString() : null} />
<DetailRow label="Pay Terms" value={company.payTerms} />
<DetailRow label="Manager" value={company.managerId} />
</div>
{/* Internal Notes — editable */}
<div className="space-y-2">
<label className="text-xs font-bold text-zinc-600 dark:text-zinc-300 uppercase tracking-wider block">
Internal Notes
</label>
<textarea
value={displayNotes}
onChange={e => handleNotesChange(e.target.value)}
rows={3}
className="w-full rounded-xl bg-zinc-100 dark:bg-black/30 border border-zinc-200 dark:border-white/10 px-3 py-2 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-violet-500/20 resize-y"
placeholder="Add internal notes…"
/>
<div className="flex items-center justify-between">
{hasUnsavedNotes && (
<span className="text-xs text-amber-500">Unsaved changes</span>
)}
<button
onClick={handleNotesSave}
disabled={!hasUnsavedNotes || notesSaving}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold bg-violet-600 hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
>
<Save size={12} />
{notesSaving ? 'Saving…' : 'Save Notes'}
</button>
</div>
</div>
</Section>
{/* ── Skill Scores ──────────────────────── */}
{(skillCatalog || []).length > 0 && (
<Section icon={<Star />} title="Skill Scores" iconColor="text-sky-500">
<div className="space-y-3">
{skillCatalog.map(skill => {
const score = getEffectiveScore(skill.id);
const isNA = score === 'N/A' || score === null || score === undefined;
const isDirty = skillDraft[skill.id] !== undefined;
return (
<div key={skill.id} className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
{skill.name}
</p>
<p className="text-[10px] text-zinc-400 truncate">{skill.description}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<input
type="number"
min={0}
max={100}
value={isNA ? '' : (skillDraft[skill.id] !== undefined ? skillDraft[skill.id] : score)}
placeholder="N/A"
onChange={e => handleSkillChange(skill.id, e.target.value)}
className="w-16 text-center rounded-lg bg-zinc-100 dark:bg-black/30 border border-zinc-200 dark:border-white/10 px-2 py-1 text-sm font-bold text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/30"
/>
{isDirty && (
<button
onClick={() => handleSkillSave(skill.id)}
title="Save score"
className="p-1 rounded-lg bg-sky-600 hover:bg-sky-500 text-white transition-colors"
>
<Save size={11} />
</button>
)}
</div>
</div>
{!isNA && (
<SkillBar score={skillDraft[skill.id] !== undefined ? skillDraft[skill.id] : score} />
)}
{isNA && (
<p className="text-[10px] text-zinc-400 italic">Not applicable</p>
)}
</div>
);
})}
</div>
<p className="text-[10px] text-zinc-400 mt-3">
Enter 0100 and click <Save size={9} className="inline" /> to persist a score.
</p>
</Section>
)}
{/* ── Compliance (personnel / vendor) ──── */}
{(selectedRaw.compliance) && (
<Section icon={<Zap />} title="Compliance & Documents" iconColor="text-amber-500">
<ComplianceChecklist data={selectedRaw} type={selectedRaw._src === 'vendor' ? 'vendor' : 'personnel'} />
<div className="mt-4 p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
<h4 className="text-xs font-bold text-blue-600 dark:text-blue-300 mb-3 uppercase tracking-wider">Quick Actions</h4>
<div className="flex gap-3">
<button className="flex-1 px-3 py-2 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500 text-white rounded-lg text-xs font-bold uppercase transition-colors shadow-sm">
Request Document
</button>
<button className="flex-1 px-3 py-2 bg-white hover:bg-zinc-50 dark:bg-white/5 dark:hover:bg-white/10 rounded-lg text-xs font-bold uppercase transition-colors text-zinc-600 dark:text-zinc-300 border border-zinc-200 dark:border-white/10">
View Audit Log
</button>
</div>
</div>
</Section>
)}
{/* ── Vendor-specific rating ─────────────── */}
{selectedRaw._src === 'subcontractor' && selectedRaw.rating != null && (
<Section icon={<Star />} title="Subcontractor Rating" iconColor="text-amber-400" defaultOpen={true}>
<div className="flex items-center gap-3">
<span className="text-3xl font-extrabold text-amber-400">{selectedRaw.rating}</span>
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map(i => (
<Star
key={i}
size={16}
className={i <= Math.round(selectedRaw.rating) ? 'text-amber-400 fill-amber-400' : 'text-zinc-300 dark:text-zinc-600'}
/>
))}
</div>
<span className="text-sm text-zinc-400">/ 5.0</span>
</div>
{selectedRaw.companyName && (
<DetailRow label="Company" value={selectedRaw.companyName} />
)}
{selectedRaw.tradeType && (
<DetailRow label="Trade" value={selectedRaw.tradeType} />
)}
</Section>
)}
</div>
</SpotlightCard>
) : (
@@ -236,7 +686,9 @@ const PeopleDirectory = () => {
<User size={32} />
</div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Select a Person</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto text-sm">Select an employee, contractor, or vendor from the list to view their detailed profile.</p>
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto text-sm">
Select an employee, field agent, subcontractor, or vendor from the list to view their full profile.
</p>
</div>
</SpotlightCard>
)}