// 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 ? (
) : (
{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.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 ? (
) : 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 });