// Workspace: project list, project detail (folder tree + file list) // Format ISO timestamp as a relative string ("2 hr ago", "Yesterday", etc.) const relativeTime = (iso) => { if (!iso) return "—"; const d = new Date(iso); const now = Date.now(); const diff = Math.floor((now - d.getTime()) / 1000); if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`; if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`; if (diff < 172800) return "Yesterday"; if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`; return d.toLocaleDateString("en-IN", { day: "numeric", month: "short" }); }; // Convert API artifact to display-friendly shape const toDisplayFile = (a) => ({ id: a.id, name: a.file_name, ext: (a.file_type || "").toLowerCase(), size: "—", type: a.doc_type, conf: a.confidentiality, owner: "—", owner_id: a.owner_id, added: relativeTime(a.uploaded_at), uploaded_at: a.uploaded_at, governance: [ a.reusable && "Reusable", a.best_in_class && "Best-in-Class", a.manager_approved && "Manager Approved", ].filter(Boolean), raw: a, }); // ─── File action menu (Download · Delete · Governance) ─── const FileActionMenu = ({ file, currentUser, tier, onRefresh }) => { const [open, setOpen] = React.useState(false); const [busy, setBusy] = React.useState(false); const ref = React.useRef(null); // Close menu on outside click React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const isLeadership = tier === "leadership"; const isOwner = currentUser && file.raw.owner_id === currentUser.id; const managerDesignations = ["Manager", "Associate Director", "Director", "Partner"]; const isManagerPlus = currentUser && managerDesignations.includes(currentUser.designation); // Delete: leadership always, execution only own file within 24h const canDelete = (() => { if (isLeadership) return true; if (!isOwner) return false; if (!file.uploaded_at) return false; const age = Date.now() - new Date(file.uploaded_at).getTime(); return age <= 24 * 60 * 60 * 1000; })(); const handleDownload = async () => { setBusy(true); try { const resp = await PrimaAPI.getDownloadUrl(file.id); window.open(resp.download_url, "_blank"); } catch (e) { alert(e.message || "Download failed."); } finally { setBusy(false); setOpen(false); } }; const handleDelete = async () => { if (!confirm(`Delete "${file.name}"? This cannot be undone.`)) return; setBusy(true); try { await PrimaAPI.deleteArtifact(file.id); onRefresh(); } catch (e) { alert(e.message || "Delete failed."); } finally { setBusy(false); setOpen(false); } }; const toggleGovernance = async (flag) => { setBusy(true); try { const payload = {}; payload[flag] = !file.raw[flag]; await PrimaAPI.updateGovernance(file.id, payload); onRefresh(); } catch (e) { alert(e.message || "Update failed."); } finally { setBusy(false); setOpen(false); } }; return (
{open && (
{/* Download */} {/* Governance toggles */}
Governance
{/* Delete */} {canDelete && ( <>
)}
)}
); }; const menuItemStyle = { display: "flex", alignItems: "center", gap: 8, width: "100%", padding: "7px 12px", border: 0, background: "transparent", color: "inherit", textAlign: "left", fontSize: 13, cursor: "pointer", borderRadius: 0, }; const Sidebar = ({ active, onNav, onOpenProfile, onLogout, tier, collapsed, onToggleCollapse, currentUser }) => { const items = [ { id: "workspace", label: "Project Workspace", icon: "folder" }, { id: "research", label: "Research Hub", icon: "book" }, { id: "thought", label: "Thought Leadership", icon: "mic" }, { id: "leaderboard", label: "Leaderboard", icon: "trophy" }, { id: "profile", label: "Update Profile", icon: "user" }, ]; const displayName = currentUser?.full_name || "—"; const displayRole = tier ? tier.toUpperCase() : "USER"; return ( ); }; // ─── Notification bell ─── const NotificationBell = ({ currentUser }) => { const [open, setOpen] = React.useState(false); const [notifs, setNotifs] = React.useState([]); const [unread, setUnread] = React.useState(0); const ref = React.useRef(null); const load = () => { PrimaAPI.getNotifications() .then(data => { setNotifs(data.items || []); setUnread(data.unread_count || 0); }) .catch(() => {}); }; React.useEffect(() => { if (!currentUser) return; load(); const id = setInterval(load, 30000); return () => clearInterval(id); }, [currentUser]); React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const handleOpen = () => { setOpen(o => !o); if (!open) load(); }; const handleMarkRead = async () => { await PrimaAPI.markAllRead().catch(() => {}); setNotifs(ns => ns.map(n => ({ ...n, is_read: true }))); setUnread(0); }; return (
{open && (
Notifications {unread > 0 && ( )}
{notifs.length === 0 ?
No notifications
: notifs.map(n => (
{n.message}
{relativeTime(n.created_at)}
)) }
)}
); }; const Topbar = ({ crumb, action, onSearch, currentUser }) => (
{crumb.map((c, i) => ( {i > 0 && } {c} ))}
Search projects, artifacts, people… ⌘K
{action}
); const ProjectStatusTag = ({ project, canEdit, onRefresh }) => { const [busy, setBusy] = React.useState(false); if (!canEdit) { return ( {project.status} ); } return ( ); }; // ─── Contribution rank strip ─── const RankStrip = ({ currentUser, onViewLeaderboard }) => { const [entries, setEntries] = React.useState(null); React.useEffect(() => { PrimaAPI.getLeaderboard(20) .then(res => setEntries(res.entries || [])) .catch(() => {}); }, []); if (!entries || !currentUser) return null; const me = entries.find(e => e.user_id === currentUser.id); if (!me) return null; const top = entries[0]; const next = entries.find(e => e.rank === me.rank - 1); const below = entries.find(e => e.rank === me.rank + 1); const gapToNext = next ? next.score - me.score : 0; const pctOfTop = top.score > 0 ? Math.round((me.score / top.score) * 100) : 100; const tier = me.rank === 1 ? "Gold" : me.rank <= 3 ? "Silver" : me.rank <= 6 ? "Bronze" : "Rising"; const tierColor = me.rank === 1 ? "#d4a017" : me.rank <= 3 ? "#9aa0a6" : me.rank <= 6 ? "#cd7f32" : "var(--fg-subtle)"; return (
#{me.rank} of {entries.length}
Your contribution rank {tier}
{next ? <>You're {gapToNext} points behind {next.full_name} for #{me.rank - 1}. : <>You're at the top — keep contributing to hold the lead.}
{me.score} pts {top.score} (#1)
{below && (
{below.full_name.split(" ").slice(-1)[0]} is {me.score - below.score} pts behind you
)}
); }; // ─── Project list ─── const ProjectWorkspace = ({ onOpen, onCreate, onNav, currentUser, tier, showRbac, refreshKey }) => { const [projects, setProjects] = React.useState([]); const [stats, setStats] = React.useState(null); const [loading, setLoading] = React.useState(true); const [filter, setFilter] = React.useState("all"); // role filter: all, owner, member const [statusFilter, setStatusFilter] = React.useState("all"); // status filter: all, active, closed const [showStatusMenu, setShowStatusMenu] = React.useState(false); const refreshLocal = React.useCallback(() => { setLoading(true); Promise.all([ PrimaAPI.listProjects(), PrimaAPI.getProjectStats() ]).then(([projData, statData]) => { setProjects(projData); setStats(statData); }) .catch(() => { setProjects([]); setStats(null); }) .finally(() => setLoading(false)); }, []); React.useEffect(() => { refreshLocal(); }, [refreshLocal, refreshKey]); const filtered = projects.filter(p => { const roleMatch = filter === "all" || p.membership === filter; const statusMatch = statusFilter === "all" || p.status === statusFilter; return roleMatch && statusMatch; }); // Derive membership label from API field const membershipLabel = (p) => { if (tier === "leadership") return "overseer"; if (!p.membership) return "none"; return p.membership; // "owner" | "member" }; return (
Workspace

Project Workspace

A living index of every active engagement. Each project auto-generates a six-folder file system the moment it's created.

{showStatusMenu && (
{["all", "active", "closed"].map(s => ( ))}
)}
onNav && onNav("leaderboard")} />
Total Projects
{stats?.total_projects || 0}
Unique Projects
Projects
{stats?.my_projects || 0}
Owned by me
Member In
{stats?.member_projects || 0}
Team projects
Artifacts
{stats?.artifacts_uploaded || 0}
Uploaded by me
{[ { id: "all", label: tier === "leadership" ? "All projects" : "All my projects" }, { id: "owner", label: "Owner" }, { id: "member", label: "Member" } ].map(f => ( ))}
{filtered.length} project{filtered.length === 1 ? "" : "s"}
{loading ? (
Loading projects…
) : (
{filtered.map(p => { const mem = membershipLabel(p); return ( ); })} {filtered.length === 0 && !loading && (

No projects yet

Create your first project to get started.

)}
)}
); }; const InviteModal = ({ projectId, open, onClose, onInvited }) => { const [query, setQuery] = React.useState(""); const [results, setResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); React.useEffect(() => { if (open) { setQuery(""); setResults([]); setError(null); } }, [open]); React.useEffect(() => { if (query.length < 2) { setResults([]); return; } const t = setTimeout(() => { setSearching(true); PrimaAPI.directorySearch(query) .then(setResults) .catch(() => setResults([])) .finally(() => setSearching(false)); }, 300); return () => clearTimeout(t); }, [query]); const invite = async (person) => { setBusy(true); setError(null); try { await PrimaAPI.inviteMember(projectId, person.id); onInvited(person.full_name); onClose(); } catch (e) { setError(e.message || "Failed to invite."); } finally { setBusy(false); } }; return (
{ setQuery(e.target.value); setError(null); }} placeholder="Search by name or email…" autoFocus /> {searching &&
Searching…
} {results.length > 0 && (
{results.map(p => (
{p.full_name}
{p.designation} · {p.email}
))}
)} {!searching && query.length >= 2 && results.length === 0 && (
No colleagues found. They may need to log in to Prima first.
)} {error &&
{error}
}
); }; // ─── Team & Join requests panel ─── const TeamPanel = ({ projectId, canManage, refreshKey }) => { const [members, setMembers] = React.useState([]); const [requests, setRequests] = React.useState([]); const [loading, setLoading] = React.useState(true); const [busy, setBusy] = React.useState(null); // id being actioned const load = React.useCallback(() => { setLoading(true); Promise.all([ PrimaAPI.listMembers(projectId).catch(() => []), canManage ? PrimaAPI.listJoinRequests(projectId).catch(() => []) : Promise.resolve([]) ]).then(([m, r]) => { setMembers(m); setRequests(r); }).finally(() => setLoading(false)); }, [projectId, canManage]); React.useEffect(() => { load(); }, [load, refreshKey]); const resolve = async (reqId, approve) => { setBusy(reqId); try { if (approve) await PrimaAPI.approveJoinRequest(projectId, reqId); else await PrimaAPI.rejectJoinRequest(projectId, reqId); load(); } catch (e) { alert(e.message || "Action failed."); } finally { setBusy(null); } }; if (loading) return
Loading team…
; return (
{/* Current Members */}
{members.map(m => (
{m.full_name} {m.role === "owner" && Lead}
{m.designation}
))}
{/* Pending Requests */} {requests.length > 0 && (
Pending Join Requests
{requests.map(r => (
{r.requester_name}
{r.requester_designation} · {relativeTime(r.created_at)}
))}
)}
); }; // ─── Project detail ─── const ProjectDetail = ({ project, onBack, onUpload, currentUser, tier, membership, showRbac }) => { const [activeFolder, setActiveFolder] = React.useState("01"); const [hoverInfo, setHoverInfo] = React.useState(null); const [files, setFiles] = React.useState([]); const [loadingFiles, setLoadingFiles] = React.useState(false); const [refreshKey, setRefreshKey] = React.useState(0); const [showInvite, setShowInvite] = React.useState(false); const isLeadership = tier === "leadership"; const isOwner = membership === "owner"; const isMember = membership === "owner" || membership === "member"; const restrictedFolder = activeFolder === "06" && !isLeadership && !isOwner; const canUpload = isLeadership || isMember; const refreshFiles = React.useCallback(() => setRefreshKey(k => k + 1), []); // Load artifacts whenever folder changes (skip restricted folder unless authorized) React.useEffect(() => { if (restrictedFolder) { setFiles([]); return; } setLoadingFiles(true); PrimaAPI.listArtifacts(project.id, activeFolder) .then(data => setFiles((data || []).map(toDisplayFile))) .catch(() => setFiles([])) .finally(() => setLoadingFiles(false)); }, [project.id, activeFolder, restrictedFolder, refreshKey]); return (
{project.code}
{project.client_type} · {project.code}

{project.client_name}

{project.description || "—"}

{project.client_details && (

{project.client_details}

)}
{project.vertical} {project.engagement} {project.months} mo
Folder hierarchy
{FOLDERS.map(f => (
setActiveFolder(f.id)}> {f.id}
{f.name}
{activeFolder === f.id ? files.length : "—"} setHoverInfo(f.id)} onMouseLeave={() => setHoverInfo(null)} onClick={(e) => e.stopPropagation()}> i {hoverInfo === f.id && {f.tip}}
))}
Governance & Team
{(isOwner || isLeadership) && ( )}
{isLeadership ? <>Tier 1 Leadership · Full project authority. : isOwner ? <>Project Owner · Manage team & uploads. : isMember ? <>Member · Collaboration access. : <>Guest · View-only.}
View {(isLeadership || isMember) && Upload} {(isLeadership || isOwner) && Manage Team}
setShowInvite(false)} onInvited={(name) => { setShowInvite(false); refreshFiles(); }} /> {/* Artifacts panel */}
{FOLDERS.find(f => f.id === activeFolder)?.name} {files.length} files
{restrictedFolder ? (

Restricted · Leadership or Owner

Folder 06 holds invoices, POs, and budget trackers. Only the project owner and Tier 1 (VP+) can view.

) : loadingFiles ? (
Loading…
) : files.length === 0 ? (

This folder is empty

Drop files here or click upload to get started. Each upload prompts for metadata.

{canUpload && }
) : (
Name Type Owner Confidentiality Added
{files.map((f, i) => (
{f.ext.toUpperCase()}
{f.name} {f.governance.length ? f.governance.join(" · ") : ""}
{f.type} {f.owner} {f.added}
))}
)}
); }; Object.assign(window, { Sidebar, Topbar, ProjectWorkspace, ProjectDetail });