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:
@@ -43,6 +43,7 @@ import KanbanPage from './pages/KanbanPage';
|
|||||||
import LeadProjectPage from './pages/LeadProjectPage';
|
import LeadProjectPage from './pages/LeadProjectPage';
|
||||||
import StormIntelPage from './pages/StormIntelPage';
|
import StormIntelPage from './pages/StormIntelPage';
|
||||||
import FieldStormZonePage from './pages/FieldStormZonePage';
|
import FieldStormZonePage from './pages/FieldStormZonePage';
|
||||||
|
import UserDetailsPage from './pages/UserDetailsPage';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -88,6 +89,13 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Self-service profile for internal roles */}
|
||||||
|
<Route path="/profile" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
|
||||||
|
<UserDetailsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/emp/fa/dashboard"
|
path="/emp/fa/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ const INITIAL_STATE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LEADERBOARD_MOCK = [
|
const LEADERBOARD_MOCK = [
|
||||||
{ id: '1', name: 'Alex M.', points: 1200, avatar: 'AM', isUser: false },
|
{ id: 'e4', name: 'Shelby Greer', points: 1300, avatar: 'SG', isUser: false },
|
||||||
{ id: '2', name: 'Jessica R.', points: 1050, avatar: 'JR', isUser: false },
|
{ id: 'e1', name: 'Cody Tatum', points: 1200, avatar: 'CT', 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: 'user', name: 'You', points: 850, avatar: 'You', isUser: true },
|
{ id: 'user', name: 'You', points: 850, avatar: 'You', isUser: true },
|
||||||
{ id: '6', name: 'Mike T.', points: 700, avatar: 'MT', isUser: false },
|
{ id: 'e2', name: 'Hannah Reyes', points: 780, avatar: 'HR', isUser: false },
|
||||||
{ id: '7', name: 'Emily C.', points: 650, avatar: 'EC', 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 }) => {
|
export const GamificationProvider = ({ children }) => {
|
||||||
|
|||||||
@@ -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. Mon–Fri 7 am–5 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;
|
||||||
@@ -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
|
// Main Page
|
||||||
// ────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────
|
||||||
@@ -1052,6 +1267,7 @@ const OrgSettings = () => {
|
|||||||
access: false,
|
access: false,
|
||||||
commission: false,
|
commission: false,
|
||||||
overrides: false,
|
overrides: false,
|
||||||
|
skillCatalog: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSection = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
|
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 (
|
return (
|
||||||
|
|||||||
+581
-129
@@ -1,39 +1,256 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
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 MaskedData from '../../components/MaskedData';
|
||||||
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
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 PeopleDirectory = () => {
|
||||||
const { personnel, vendors } = useMockStore();
|
const { personnel, vendors, users, subcontractors, resolveProfile, skillCatalog, setSkillScore } = useMockStore();
|
||||||
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
|
const [filter, setFilter] = useState('all');
|
||||||
const [search, setSearch] = useState('');
|
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 detailRef = useRef(null);
|
||||||
|
|
||||||
const handleSelectPerson = (person) => {
|
// ── Build unified roster ────────────────────────────────────────────────
|
||||||
setSelectedPerson(person);
|
const fieldAgents = (users || []).filter(u => u.role === 'FIELD_AGENT');
|
||||||
};
|
|
||||||
|
|
||||||
// Combine and normalize data for display
|
|
||||||
const allPeople = [
|
const allPeople = [
|
||||||
...personnel.map(p => ({ ...p, type: 'personnel', category: 'Internal Team' })),
|
...personnel.map(p => ({
|
||||||
...vendors.map(v => ({ ...v, name: v.vendorName, type: 'vendor', category: 'Vendor/Partner' }))
|
...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 => {
|
// ── Filter + search ─────────────────────────────────────────────────────
|
||||||
const matchesSearch = person.name.toLowerCase().includes(search.toLowerCase()) ||
|
const filteredPeople = allPeople.filter(p => {
|
||||||
person.email?.toLowerCase().includes(search.toLowerCase());
|
const q = search.toLowerCase();
|
||||||
|
const matchSearch = (p.name || '').toLowerCase().includes(q) ||
|
||||||
if (filter === 'all') return matchesSearch;
|
(p.email || '').toLowerCase().includes(q);
|
||||||
if (filter === 'employee') return matchesSearch && person.type === 'personnel';
|
if (!matchSearch) return false;
|
||||||
if (filter === 'contractor') return matchesSearch && person.role === 'CONTRACTOR'; // Assuming role field exists
|
if (filter === 'all') return true;
|
||||||
if (filter === 'subcontractor') return matchesSearch && person.role === 'SUBCONTRACTOR'; // Assuming role field exists
|
if (filter === 'agent') return p._src === 'user' && p.role === 'FIELD_AGENT';
|
||||||
|
if (filter === 'personnel') return p._src === 'personnel';
|
||||||
return matchesSearch;
|
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 (
|
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">
|
<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 */}
|
{/* 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">
|
<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">
|
<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>
|
<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">
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Manage your team, contractors, and compliance records.</p>
|
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>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
{/* Left Sidebar: List & Search */}
|
{/* ── Left Sidebar: List & Search ─────────────────────── */}
|
||||||
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedPerson ? 'hidden lg:flex' : 'flex'}`}>
|
<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">
|
<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">
|
<div className="relative">
|
||||||
<label htmlFor="search-people" className="sr-only">Search by name or email</label>
|
<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} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
@@ -61,158 +283,361 @@ const PeopleDirectory = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or email..."
|
placeholder="Search by name or email..."
|
||||||
value={search}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||||||
<button
|
<FilterBtn active={filter === 'all'} onClick={() => setFilter('all')} color="default" label="All" />
|
||||||
onClick={() => setFilter('all')}
|
<FilterBtn active={filter === 'personnel'} onClick={() => setFilter('personnel')} color="blue" label="Internal" />
|
||||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'all'
|
<FilterBtn active={filter === 'agent'} onClick={() => setFilter('agent')} color="sky" label="Field Agents" />
|
||||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
<FilterBtn active={filter === 'subcontractor'} onClick={() => setFilter('subcontractor')} color="orange" label="Subs" />
|
||||||
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
<FilterBtn active={filter === 'vendor'} onClick={() => setFilter('vendor')} color="purple" label="Vendors" />
|
||||||
}`}
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
{/* Grouped list */}
|
||||||
{filteredPeople.map((person) => (
|
<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
|
<div
|
||||||
key={person.id}
|
key={person.id}
|
||||||
onClick={() => handleSelectPerson(person)}
|
onClick={() => handleSelectPerson(person)}
|
||||||
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
|
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-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'
|
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<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'
|
<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)}`}>
|
||||||
? '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'
|
{(person.name || '?').charAt(0)}
|
||||||
: '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>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className={`font-bold text-sm ${selectedPerson?.id === person.id ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-white'}`}>
|
<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}
|
{person.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{person.role || person.vendorType}</p>
|
<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>
|
||||||
|
<div className="ml-auto shrink-0">
|
||||||
|
{statusBadge(person.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
|
||||||
{/* Right Panel: Details */}
|
{/* ── Right Panel: Details ─────────────────────────────── */}
|
||||||
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedPerson ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
|
<div
|
||||||
{selectedPerson ? (
|
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">
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
{/* Mobile Back Button */}
|
{/* Mobile Back */}
|
||||||
<button
|
<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"
|
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} />
|
<ArrowLeft size={16} />
|
||||||
Back to People
|
Back to People
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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="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">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<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">{selectedPerson.name}</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">
|
||||||
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
|
{self.preferredName || selectedRaw.name}
|
||||||
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
</h2>
|
||||||
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
|
{self.preferredName && self.preferredName !== selectedRaw.name && (
|
||||||
}`}>
|
<span className="text-zinc-400 text-base font-normal">({selectedRaw.name})</span>
|
||||||
{selectedPerson.status}
|
)}
|
||||||
</span>
|
{statusBadge(selectedRaw.status)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2 text-sm font-medium">
|
<p className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2 text-sm font-medium">
|
||||||
<Briefcase size={16} />
|
<Briefcase size={16} />
|
||||||
{selectedPerson.category} • {selectedPerson.role || selectedPerson.vendorType}
|
{selectedRaw._category} • {selectedRaw.role || selectedRaw.tradeType || selectedRaw.vendorType || '—'}
|
||||||
</p>
|
</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
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable body */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-6 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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{(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>
|
||||||
|
|
||||||
|
{/* 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
|
{/* ── Skill Scores ──────────────────────── */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
|
{(skillCatalog || []).length > 0 && (
|
||||||
{/* Sensitive Data Section */}
|
<Section icon={<Star />} title="Skill Scores" iconColor="text-sky-500">
|
||||||
<div className="space-y-6 sm:space-y-8">
|
<div className="space-y-3">
|
||||||
<div className="space-y-4">
|
{skillCatalog.map(skill => {
|
||||||
<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">
|
const score = getEffectiveScore(skill.id);
|
||||||
<Shield size={16} className="text-blue-500" />
|
const isNA = score === 'N/A' || score === null || score === undefined;
|
||||||
Sensitive Information
|
const isDirty = skillDraft[skill.id] !== undefined;
|
||||||
</h3>
|
return (
|
||||||
<div className="space-y-4">
|
<div key={skill.id} className="space-y-1">
|
||||||
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Email</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.email || selectedPerson.primaryContact?.email}</span>
|
<p className="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate">
|
||||||
</div>
|
{skill.name}
|
||||||
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
</p>
|
||||||
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Phone</span>
|
<p className="text-[10px] text-zinc-400 truncate">{skill.description}</p>
|
||||||
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.phone || selectedPerson.primaryContact?.phone}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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 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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-zinc-400 mt-3">
|
||||||
|
Enter 0–100 and click <Save size={9} className="inline" /> to persist a score.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Compliance Section */}
|
{/* ── Compliance (personnel / vendor) ──── */}
|
||||||
<div className="space-y-8">
|
{(selectedRaw.compliance) && (
|
||||||
<div>
|
<Section icon={<Zap />} title="Compliance & Documents" iconColor="text-amber-500">
|
||||||
<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">
|
<ComplianceChecklist data={selectedRaw} type={selectedRaw._src === 'vendor' ? 'vendor' : 'personnel'} />
|
||||||
<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">
|
<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>
|
<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">
|
<div className="flex gap-3">
|
||||||
@@ -224,9 +649,34 @@ const PeopleDirectory = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
<span className="text-sm text-zinc-400">/ 5.0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{selectedRaw.companyName && (
|
||||||
|
<DetailRow label="Company" value={selectedRaw.companyName} />
|
||||||
|
)}
|
||||||
|
{selectedRaw.tradeType && (
|
||||||
|
<DetailRow label="Trade" value={selectedRaw.tradeType} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
) : (
|
) : (
|
||||||
@@ -236,7 +686,9 @@ const PeopleDirectory = () => {
|
|||||||
<User size={32} />
|
<User size={32} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Select a Person</h3>
|
<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>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user