diff --git a/client/src/api.js b/client/src/api.js index c90e416a..344e3319 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -471,6 +471,62 @@ export async function getModelArchitectures() { return makeApiRequest("pytc/architectures", "get"); } +// ── Project Manager persistence ─────────────────────────────────────────────── + +export async function getPMData() { + try { + const res = await apiClient.get("/api/pm/data"); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getPMConfig() { + try { + const res = await apiClient.get("/api/pm/config"); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getPMSchema() { + try { + const res = await apiClient.get("/api/pm/schema"); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function updatePMConfig(patch) { + try { + const res = await apiClient.post("/api/pm/config", patch); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function savePMData(state) { + try { + const res = await apiClient.post("/api/pm/data", state); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function resetPMData() { + try { + const res = await apiClient.post("/api/pm/data/reset"); + return res.data; + } catch (error) { + handleError(error); + } +} + // ── Workflow spine ─────────────────────────────────────────────────────────── export async function getCurrentWorkflow() { diff --git a/client/src/contexts/ProjectManagerContext.js b/client/src/contexts/ProjectManagerContext.js new file mode 100644 index 00000000..573240aa --- /dev/null +++ b/client/src/contexts/ProjectManagerContext.js @@ -0,0 +1,392 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, +} from "react"; +import { message, Spin } from "antd"; +import { + getPMConfig, + getPMData, + savePMData, + resetPMData, + updatePMConfig, +} from "../api"; +import { apiClient } from "../api"; +import { dataReader } from "../services/data_reader"; + +// ── Context ─────────────────────────────────────────────────────────────────── + +const ProjectManagerContext = createContext(null); + +export function useProjectManager() { + const ctx = useContext(ProjectManagerContext); + if (!ctx) + throw new Error( + "useProjectManager must be used inside ", + ); + return ctx; +} + +// ── Provider ────────────────────────────────────────────────────────────────── + +const DEBOUNCE_MS = 800; + +/** + * ProjectManagerProvider + * + * Props: + * role "admin" | "worker" — current RBAC role + * activeWorker worker key string — e.g. "alex" (relevant when role=worker) + */ +export function ProjectManagerProvider({ children }) { + const [pmState, setPmState] = useState(null); // null = not yet loaded + const [pmConfig, setPmConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [configLoading, setConfigLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [user, setUser] = useState(null); // Authenticated user object + const debounceRef = useRef(null); + + // ── Session Restoration ────────────────────────────────────────────────── + useEffect(() => { + const saved = localStorage.getItem("pm_user"); + if (saved) { + try { + setUser(JSON.parse(saved)); + } catch (e) { + localStorage.removeItem("pm_user"); + } + } + }, []); + + const loadPmData = useCallback(async () => { + const data = await getPMData(); + setPmState(data); + return data; + }, []); + + const loadPmConfig = useCallback(async () => { + const config = await getPMConfig(); + setPmConfig(config); + return config; + }, []); + + const refreshPmState = useCallback(async () => { + setLoading(true); + try { + const [data, config] = await Promise.all([loadPmData(), loadPmConfig()]); + return { data, config }; + } catch (err) { + console.error("[PM] Failed to load data:", err); + message.error("Failed to load Project Manager data from server."); + throw err; + } finally { + setLoading(false); + } + }, [loadPmConfig, loadPmData]); + + // ── Fetch on mount (always load PM config/seeds) ────────────────────────── + useEffect(() => { + let cancelled = false; + + const bootstrap = async () => { + setLoading(true); + try { + const [data, config] = await Promise.all([getPMData(), getPMConfig()]); + if (!cancelled) { + setPmState(data); + setPmConfig(config); + } + } catch (err) { + if (!cancelled) { + console.error("[PM] Failed to load data:", err); + message.error("Failed to load Project Manager data from server."); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + bootstrap(); + return () => { + cancelled = true; + }; + }, []); + + // ── Auth Actions ────────────────────────────────────────────────────────── + const login = useCallback(async (username, password) => { + try { + const res = await apiClient.post("/api/pm/login", { username, password }); + if (res.data.ok) { + const userData = res.data.user; + setUser(userData); + localStorage.setItem("pm_user", JSON.stringify(userData)); + message.success(`Logged in as ${userData.name}`); + return true; + } + } catch (err) { + message.error(err.response?.data?.detail || "Login failed"); + return false; + } + }, []); + + const logout = useCallback(() => { + setUser(null); + localStorage.removeItem("pm_user"); + message.info("Logged out."); + }, []); + + // ── Debounced server save ───────────────────────────────────────────────── + const _scheduleSave = useCallback((nextState) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setSaving(true); + try { + await savePMData(nextState); + message.success({ + content: "Changes saved.", + key: "pm-save", + duration: 1.5, + }); + } catch (err) { + console.error("[PM] Save failed:", err); + message.error({ + content: "Failed to save changes to server.", + key: "pm-save", + }); + } finally { + setSaving(false); + } + }, DEBOUNCE_MS); + }, []); + + // ── Public state-mutators ───────────────────────────────────────────────── + const updateState = useCallback( + (patch) => { + setPmState((prev) => { + const next = { ...prev, ...patch }; + _scheduleSave(next); + return next; + }); + }, + [_scheduleSave], + ); + + const resetData = useCallback(async () => { + setLoading(true); + try { + const seed = await resetPMData(); + setPmState(seed); + await loadPmConfig(); + message.success("Started a fresh project."); + } catch (err) { + console.error("[PM] Reset failed:", err); + message.error("Failed to reset data on server."); + } finally { + setLoading(false); + } + }, [loadPmConfig]); + + const ingestData = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.post("/api/pm/data/ingest"); + if (res.data.ok) { + setPmState(res.data.data); + message.success( + `Ingested ${res.data.data.volumes?.length || 0} volumes.`, + ); + } + } catch (err) { + console.error("[PM] Ingestion failed:", err); + message.error( + err.response?.data?.detail || "Failed to ingest data from storage.", + ); + } finally { + setLoading(false); + } + }, []); + + const updateProjectConfig = useCallback( + async (patch) => { + setConfigLoading(true); + try { + const config = await updatePMConfig(patch); + setPmConfig(config); + const data = await getPMData(); + setPmState(data); + message.success("Project source updated."); + return { config, data }; + } catch (err) { + console.error("[PM] Config update failed:", err); + message.error( + err.response?.data?.detail || "Failed to update project source.", + ); + throw err; + } finally { + setConfigLoading(false); + } + }, + [], + ); + + // ── Volume helpers ─────────────────────────────────────────────────────── + const getVolumes = useCallback(async (params = {}) => { + try { + return await dataReader.getPooledVolumes(params); + } catch (err) { + message.error("Failed to load volumes from server."); + throw err; + } + }, []); + + const updateVolumeStatus = useCallback(async (volumeId, status) => { + try { + const res = await dataReader.updateStatus(volumeId, status); + if (res?.global_progress) { + setPmState((prev) => + prev ? { ...prev, global_progress: res.global_progress } : prev, + ); + } + return res; + } catch (err) { + message.error("Failed to update volume status."); + throw err; + } + }, []); + + const updateVolumeAssignee = useCallback(async (volumeId, assignee) => { + try { + const res = await dataReader.assignVolume(volumeId, assignee); + if (res?.global_progress) { + setPmState((prev) => + prev ? { ...prev, global_progress: res.global_progress } : prev, + ); + } + return res; + } catch (err) { + message.error("Failed to update volume assignee."); + throw err; + } + }, []); + + // ── Convenience setters ─────────────────────────────────────────────────── + const setQuotaData = useCallback( + (v) => updateState({ quota_data: v }), + [updateState], + ); + const setProofreaderData = useCallback( + (v) => updateState({ proofreader_data: v }), + [updateState], + ); + const setThroughputData = useCallback( + (v) => updateState({ throughput_data: v }), + [updateState], + ); + const setMsgPreview = useCallback( + (v) => updateState({ msg_preview: v }), + [updateState], + ); + + // ── Computed RBAC Values ────────────────────────────────────────────────── + const isAuthenticated = !!user; + const role = user?.role || "guest"; + const isAdmin = role === "admin"; + const isWorker = role === "worker"; + const activeWorker = isWorker ? user?.key : null; + + // ── Role-filtered convenience getters ───────────────────────────────────── + const allQuotaData = pmState?.quota_data ?? []; + const allProofreaderData = pmState?.proofreader_data ?? []; + + const quotaData = isWorker + ? allQuotaData.filter((r) => r.worker_key === activeWorker) + : allQuotaData; + const proofreaderData = isWorker + ? allProofreaderData.filter((r) => r.worker_key === activeWorker) + : allProofreaderData; + + // ── Loading gate ────────────────────────────────────────────────────────── + if (loading && pmState === null) { + return ( +
+ +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/client/src/services/data_reader.js b/client/src/services/data_reader.js new file mode 100644 index 00000000..2569d6fb --- /dev/null +++ b/client/src/services/data_reader.js @@ -0,0 +1,90 @@ +/** + * Data Reader Service + * + * Provides a standardized interface for accessing project volumes + * by taskId, abstracting away file paths and backend specifics. + */ + +import axios from "axios"; + +// Backend configuration +const API_BASE = + process.env.REACT_APP_API_URL || "http://localhost:4242/api/pm"; + +class DataReaderService { + /** + * Fetch all pooled volumes (with pagination handled internally if needed) + * The frontend "pools" the list from the active metadata JSON. + */ + async getPooledVolumes(params = {}) { + try { + const response = await axios.get(`${API_BASE}/volumes`, { params }); + return response.data; // { total, page, items, ... } + } catch (error) { + console.error("Error fetching pooled volumes:", error); + throw error; + } + } + + /** + * Get metadata for a specific volume by taskId + */ + async getVolumeByTaskId(taskId) { + try { + // Since the backend uses id as taskId (for example a relative volume path) + // we can just use the volumes endpoint with filtering if supported, + // or we could add a dedicated GET /volumes/{id} if needed. + // For now, we'll assume the frontend already has the list or we fetch one. + const response = await axios.get(`${API_BASE}/volumes`, { + params: { id: taskId, page_size: 1 }, + }); + return response.data.items[0] || null; + } catch (error) { + console.error(`Error fetching volume ${taskId}:`, error); + throw error; + } + } + + /** + * Apply a partial update to a volume by taskId. + */ + async updateVolume(taskId, patch) { + try { + const response = await axios.patch(`${API_BASE}/volumes/${taskId}`, patch); + return response.data; + } catch (error) { + console.error(`Error updating volume ${taskId}:`, error); + throw error; + } + } + + /** + * Update volume status by taskId. + */ + async updateStatus(taskId, status) { + return this.updateVolume(taskId, { status }); + } + + /** + * Update volume assignee by taskId. + */ + async assignVolume(taskId, assignee) { + return this.updateVolume(taskId, { assignee }); + } + + /** + * Bulk link to an external metadata JSON + */ + async linkExternalMetadata(path) { + try { + const response = await axios.post(`${API_BASE}/data/link`, { path }); + return response.data; + } catch (error) { + console.error("Error linking external metadata:", error); + throw error; + } + } +} + +export const dataReader = new DataReaderService(); +export default dataReader; diff --git a/client/src/views/Views.js b/client/src/views/Views.js index c6e5e068..82282ce6 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -8,6 +8,7 @@ import { DashboardOutlined, BugOutlined, MessageOutlined, + ProjectOutlined, } from "@ant-design/icons"; import FilesManager from "./FilesManager"; import Visualization from "./Visualization"; @@ -15,6 +16,7 @@ import ModelTraining from "./ModelTraining"; import ModelInference from "./ModelInference"; import Monitoring from "./Monitoring"; import MaskProofreading from "./MaskProofreading"; +import ProjectManager from "./project-manager/ProjectManager"; import Chatbot from "../components/Chatbot"; import { useWorkflow } from "../contexts/WorkflowContext"; @@ -35,6 +37,11 @@ const MODULE_ITEMS = [ key: "mask-proofreading", icon: , }, + { + label: "Project Manager", + key: "project-manager", + icon: , + }, ]; function Views() { @@ -50,6 +57,33 @@ function Views() { const [viewers, setViewers] = useState([]); const [isInferring, setIsInferring] = useState(false); + /* + const allItems = [ + { label: "File Management", key: "files", icon: }, + { label: "Visualization", key: "visualization", icon: }, + { label: "Model Training", key: "training", icon: }, + { + label: "Model Inference", + key: "inference", + icon: , + }, + { label: "Tensorboard", key: "monitoring", icon: }, + { label: "SynAnno", key: "synanno", icon: }, + { + label: "Worm Error Handling", + key: "worm-error-handling", + icon: , + }, + { + label: "Project Manager", + key: "project-manager", + icon: , + }, + ]; + + const items = allItems.filter((item) => visibleTabs.has(item.key)); +*/ + const onClick = (e) => { setCurrent(e.key); setVisitedTabs((prev) => new Set(prev).add(e.key)); @@ -154,6 +188,9 @@ function Views() { />, )} {renderTabContent("mask-proofreading", )} + {/* {renderTabContent("synanno", )} */} + {/* {renderTabContent("worm-error-handling", )} */} + {renderTabContent("project-manager", )} ; +} + +// Convert a date to a 0-1 fraction along the 6-month timeline +function dateToFrac(d) { + const total = PROJECT_END.diff(PROJECT_START, "day"); + const elapsed = dayjs(d).diff(PROJECT_START, "day"); + return Math.min(1, Math.max(0, elapsed / total)); +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +/** Custom SVG 6-month horizontal timeline */ +function SixMonthTimeline({ milestones }) { + const W = 900; + const H = 80; + const PAD = 40; + const trackY = 48; + const trackW = W - PAD * 2; + + const months = []; + let cur = PROJECT_START.startOf("month"); + while (cur.isBefore(PROJECT_END) || cur.isSame(PROJECT_END, "month")) { + months.push(cur); + cur = cur.add(1, "month"); + } + + const todayFrac = dateToFrac(dayjs("2026-03-20")); + + return ( + + + + {months.map((m) => { + const x = PAD + dateToFrac(m) * trackW; + return ( + + + + {m.format("MMM")} + + + ); + })} + {milestones.map((ms) => { + const x = PAD + dateToFrac(ms.date) * trackW; + return ( + + + + + {ms.label} + + + + ); + })} + + + Today + + + ); +} + +/** Cumulative progress SVG line chart */ +function CumulativeChart({ cumulativeData, cumulativeTarget }) { + if ( + !Array.isArray(cumulativeData) || + !Array.isArray(cumulativeTarget) || + cumulativeData.length === 0 || + cumulativeTarget.length === 0 + ) { + return ( +
+ + No cumulative progress history yet. Add data and volume activity to + populate this chart. + +
+ ); + } + const W = 800; + const H = 180; + const PADL = 52; + const PADR = 16; + const PADT = 16; + const PADB = 32; + const chartW = W - PADL - PADR; + const chartH = H - PADT - PADB; + const maxVal = Math.max(...cumulativeData, ...cumulativeTarget) * 1.05; + const weeks = cumulativeData.length; + const toX = (i) => PADL + (i / (weeks - 1)) * chartW; + const toY = (v) => PADT + chartH - (v / maxVal) * chartH; + const pathD = (data) => + data + .map( + (v, i) => + `${i === 0 ? "M" : "L"} ${toX(i).toFixed(1)},${toY(v).toFixed(1)}`, + ) + .join(" "); + const areaD = (data) => + `${pathD(data)} L ${toX(weeks - 1).toFixed(1)},${(PADT + chartH).toFixed(1)} L ${PADL},${(PADT + chartH).toFixed(1)} Z`; + const yTicks = [0, 10000, 20000, 30000]; + const xTicks = [0, 4, 8, 12, 16, 20, 24]; + return ( + + {yTicks.map((v) => ( + + + + {v === 0 ? "0" : `${v / 1000}k`} + + + ))} + {xTicks.map((i) => ( + + Wk {i + 1} + + ))} + + + + + + + + Actual + + + + Target + + + + ); +} + +// ─── Column definitions ─────────────────────────────────────────────────────── + +const DATASET_COLUMNS = [ + { + title: "Dataset Name", + dataIndex: "name", + key: "name", + render: (name) => {name}, + }, + { + title: "Experiment", + dataIndex: "experiment", + key: "experiment", + render: (t) => {t}, + }, + { + title: "Total Samples", + dataIndex: "total", + key: "total", + align: "right", + render: (n) => n.toLocaleString(), + }, + { + title: "% Proofread", + dataIndex: "proofread", + key: "proofread", + width: 180, + render: (pct) => ( + + = 80 ? "#52c41a" : pct >= 40 ? "#faad14" : "#ff4d4f" + } + showInfo={false} + /> + {pct}% + + ), + }, + { title: "Status", dataIndex: "status", key: "status", render: statusTag }, + { + title: "ETA", + dataIndex: "eta", + key: "eta", + render: (eta) => {eta}, + }, +]; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function AnnotationDashboard() { + const { + quotaData, + allQuotaData, + proofreaderData, + datasets, + milestones, + cumulativeData, + cumulativeTarget, + atRisk, + upcomingMilestones, + globalProgress, + isWorker, + activeWorker, + } = useProjectManager(); + + const [filter, setFilter] = useState("all"); + + // ── KPI calculations ────────────────────────────────────────────────────── + // Weekly quota progress (uses role-filtered quotaData) + const totalActual = useMemo( + () => + quotaData.reduce( + (sum, row) => + sum + + row.actualMon + + row.actualTue + + row.actualWed + + row.actualThu + + row.actualFri + + row.actualSat + + row.actualSun, + 0, + ), + [quotaData], + ); + const totalTarget = useMemo( + () => + quotaData.reduce( + (sum, row) => + sum + + row.mon + + row.tue + + row.wed + + row.thu + + row.fri + + row.sat + + row.sun, + 0, + ), + [quotaData], + ); + + const totalSamplesCount = useMemo( + () => datasets.reduce((s, d) => s + d.total, 0), + [datasets], + ); + const overallPct = + totalTarget > 0 ? Math.round((totalActual / totalTarget) * 100) : 0; + const activeDatasetsCount = datasets.filter( + (d) => d.status === "in_progress", + ).length; + const activeProofreadersCount = proofreaderData.filter( + (p) => p.status === "online" || p.status === "away", + ).length; + + // Volume progress — admin = global 1000, worker = their 250 + const volProgress = isWorker + ? (globalProgress?.by_worker?.[activeWorker] ?? { + done: 0, + total: 250, + pct: 0, + }) + : (globalProgress ?? { done: 0, total: 1000, pct: 0 }); + + // Filtered datasets + const filteredDatasets = useMemo(() => { + if (filter === "all") return datasets; + if (filter === "high") return datasets.filter((d) => d.priority === "high"); + if (filter === "blocked") + return datasets.filter((d) => d.status === "blocked"); + return datasets; + }, [filter, datasets]); + + const filterBtns = [ + { key: "all", label: "All" }, + { key: "high", label: "High Priority" }, + { key: "blocked", label: "Blocked" }, + ]; + const isEmptyProject = + (globalProgress?.total ?? 0) === 0 && + datasets.length === 0 && + milestones.length === 0; + + return ( +
+ + + {isEmptyProject && ( + + )} + + {/* ── Header ── */} +
+ + {isWorker ? "My Dashboard" : "Neural Dataset Proofreading – 6 Months"} + + + {PROJECT_START.format("MMM D, YYYY")} →{" "} + {PROJECT_END.format("MMM D, YYYY")} · Real-time Context Sync + +
+ + {/* ── KPI Cards ── */} + + {/* ① Volume Progress — derived from actual done status of 1000 volumes */} + + + + {isWorker ? "My Volumes Done" : "Global Volume Progress"} + + } + value={volProgress.done} + suffix={`/ ${volProgress.total}`} + prefix={} + valueStyle={{ + color: "#722ed1", + fontSize: 20, + fontWeight: "bold", + }} + /> +
+ + + {volProgress.pct ?? 0}% of{" "} + {isWorker ? "250 assigned" : "1,000 total"} volumes done + +
+
+ + + {/* ② Weekly Points */} + + + + Weekly Points + + } + value={totalActual.toLocaleString()} + suffix={`/ ${totalTarget.toLocaleString()}`} + prefix={} + valueStyle={{ + color: "#52c41a", + fontSize: 20, + fontWeight: "bold", + }} + /> +
+ +
+
+ + + {/* ③ Active Datasets (admin only, for worker show accuracy) */} + + + + Active Datasets + + } + value={activeDatasetsCount} + suffix={`/ ${datasets.length}`} + prefix={} + valueStyle={{ + color: "#1890ff", + fontSize: 20, + fontWeight: "bold", + }} + /> + + {totalSamplesCount.toLocaleString()} total samples + + + + + {/* ④ Team status */} + + + + {isWorker ? "My Weekly Points" : "Proofreaders Online"} + + } + value={isWorker ? totalActual : activeProofreadersCount} + suffix={isWorker ? undefined : `/ ${allQuotaData.length}`} + prefix={ + isWorker ? ( + + ) : ( + + ) + } + valueStyle={{ + color: "#fa8c16", + fontSize: 20, + fontWeight: "bold", + }} + /> + + {isWorker ? "Points this week" : "Team-wide active status"} + + + +
+ + {/* ── Timeline ── */} + 6-Month Project Timeline} + style={{ marginBottom: 20, borderRadius: 8 }} + bodyStyle={{ paddingTop: 8 }} + > + + + + {/* ── Main content: Table + Right Sidebar ── */} + + {/* Datasets Table */} + + Datasets} + extra={ + + {filterBtns.map((b) => ( + + ))} + + } + style={{ marginBottom: 20, borderRadius: 8 }} + > + + + + + {/* Right Sidebar */} + + {/* This Week */} + + + Weekly Progress + + } + style={{ marginBottom: 12, borderRadius: 8 }} + > + + Total items annotated vs. target + +
+
+ Completed + + {totalActual.toLocaleString()} + +
+ +
+ + Target: {totalTarget.toLocaleString()} + + + {overallPct}% + +
+
+
+ + {/* At Risk */} + + + At Risk + + } + style={{ marginBottom: 12, borderRadius: 8 }} + > + {atRisk.map((item, idx) => ( +
+ {idx > 0 && } +
+ {item.icon === "clock" ? ( + + ) : item.icon === "blocked" ? ( + + ) : ( + + )} +
+ + {item.label} + +
+ + {item.reason} + +
+
+
+ ))} +
+ + {/* Upcoming Milestones */} + + + Upcoming Milestones + + } + style={{ borderRadius: 8 }} + > + {upcomingMilestones.map((ms, idx) => ( +
+ {idx > 0 && } +
+ {ms.label} + + {ms.date} + +
+
+ ))} +
+ + + + {/* ── Cumulative Progress Chart ── */} + Cumulative Items Proofread over 6 Months} + style={{ marginBottom: 8, borderRadius: 8 }} + bodyStyle={{ paddingTop: 8 }} + > + + + + ); +} + +export default AnnotationDashboard; diff --git a/client/src/views/project-manager/ModelQualityDashboard.js b/client/src/views/project-manager/ModelQualityDashboard.js new file mode 100644 index 00000000..cc776a27 --- /dev/null +++ b/client/src/views/project-manager/ModelQualityDashboard.js @@ -0,0 +1,479 @@ +import React, { useState } from "react"; +import { + Alert, + Card, + Row, + Col, + Statistic, + Progress, + Table, + Tag, + Typography, + Space, + Select, + Divider, +} from "antd"; +import { + LineChartOutlined, + CheckCircleOutlined, + WarningOutlined, + HistoryOutlined, + ThunderboltOutlined, + SlidersOutlined, +} from "@ant-design/icons"; + +const { Title, Text } = Typography; +const { Option } = Select; + +// ─── Seed Data ─────────────────────────────────────────────────────────────── + +const QUALITY_METRICS = { + accuracy: 94.2, + precision: 92.5, + recall: 91.8, + f1: 92.1, + agreement: 88.5, + disagreement: 4.2, + uncertainty: 2.1, +}; + +const BATCH_QUALITY = [ + { + key: "1", + batch: "Batch #104", + dataset: "Hippocampus_CA3", + agreement: 91.2, + flagged: 12, + revisionNeeded: 5, + status: "verified", + }, + { + key: "2", + batch: "Batch #105", + dataset: "Motor_Cortex_M1", + agreement: 84.5, + flagged: 42, + revisionNeeded: 18, + status: "needs_review", + }, + { + key: "3", + batch: "Batch #106", + dataset: "Cerebellum_PC", + agreement: 89.1, + flagged: 8, + revisionNeeded: 2, + status: "verified", + }, + { + key: "4", + batch: "Batch #107", + dataset: "Retina_GCL", + agreement: 72.0, + flagged: 5, + revisionNeeded: 22, + status: "blocked", + }, +]; + +const TREND_DATA = [82, 84, 85, 83, 86, 88, 87, 89, 91, 93, 94.2]; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function QualityTrendChart() { + const W = 800; + const H = 200; + const PAD = 40; + const chartW = W - PAD * 2; + const chartH = H - PAD; + + const maxVal = 100; + const minVal = 80; // Zoomed in to show subtle improvements + const range = maxVal - minVal; + + const toX = (i) => PAD + (i / (TREND_DATA.length - 1)) * chartW; + const toY = (v) => PAD / 2 + chartH - ((v - minVal) / range) * chartH; + + const pathD = TREND_DATA.map( + (v, i) => `${i === 0 ? "M" : "L"} ${toX(i)},${toY(v)}`, + ).join(" "); + + return ( + + {/* Grid lines */} + {[80, 85, 90, 95, 100].map((v) => ( + + + + {v}% + + + ))} + + {/* The Line */} + + + {/* Area fill */} + + + {/* Points */} + {TREND_DATA.map((v, i) => ( + + ))} + + {/* Labels */} + {TREND_DATA.map( + (v, i) => + i % 2 === 0 && ( + + v{i + 1} + + ), + )} + + ); +} + +// ─── Column definitions ─────────────────────────────────────────────────────── + +const BATCH_COLUMNS = [ + { + title: "Batch", + dataIndex: "batch", + key: "batch", + render: (text) => {text}, + }, + { + title: "Dataset", + dataIndex: "dataset", + key: "dataset", + render: (text) => {text}, + }, + { + title: "IAA Score", + dataIndex: "agreement", + key: "agreement", + render: (val) => ( + + 85 ? "#52c41a" : "#faad14"} + /> + {val}% + + ), + }, + { + title: "Flagged", + dataIndex: "flagged", + key: "flagged", + render: (val) => 20 ? "red" : "blue"}>{val} items, + }, + { + title: "Revision Rate", + dataIndex: "revisionNeeded", + key: "revisionNeeded", + render: (val) => ( + 10 ? "danger" : "secondary"}>{val}% + ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + render: (status) => ( + + {status.toUpperCase().replace("_", " ")} + + ), + }, +]; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function ModelQualityDashboard() { + const [modelVersion, setModelVersion] = useState("v3.2"); + + return ( +
+ {/* ── Header ── */} + +
+ + Model Performance & Data Quality + + + PM JSON-backed quality snapshot; not yet wired to live evaluation outputs + + + + + Model Version: + + }> + Retrain hook pending + + + + + + + + {/* ── Main Metrics Gauges ── */} + + + + ( +
+
{pct}
+
F1 Score
+
+ )} + /> + +
+ + + + + + + + + + + + + + + } + /> + + + } + /> + + + } + /> + + + + + + + + {/* Quality Trend */} + + + + Model Performance Trend (Last 12 Iterations) + + } + style={{ marginBottom: 20 }} + > + + + + + {/* Confusion Matrix */} + + Confusion Matrix (v3.2)} + style={{ marginBottom: 20 }} + > +
+
+
+ + Pred Syn + + + Pred Non + + + + Act Syn + +
+ 1,420 +
TP
+
+
+ 85 +
FN
+
+ + + Act Non + +
+ 112 +
FP
+
+
+ 3,200 +
TN
+
+
+
+ + *Based on evaluation dataset: 4,817 samples + +
+
+ + + + + {/* Batch Data Quality Table */} + + + Data Quality Log (Per Batch) + + } + > +
+ + + ); +} + +export default ModelQualityDashboard; diff --git a/client/src/views/project-manager/ProjectManager.js b/client/src/views/project-manager/ProjectManager.js new file mode 100644 index 00000000..c75c5626 --- /dev/null +++ b/client/src/views/project-manager/ProjectManager.js @@ -0,0 +1,318 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Card, + Layout, + Menu, + Modal, + Typography, + ConfigProvider, + Space, + Button, + Tag, +} from "antd"; +import { + DashboardOutlined, + TeamOutlined, + ScheduleOutlined, + LineChartOutlined, + BugOutlined, + SettingOutlined, + DatabaseOutlined, + SyncOutlined, +} from "@ant-design/icons"; + +import AnnotationDashboard from "./AnnotationDashboard"; +import ProofreaderProgress from "./ProofreaderProgress"; +import QuotaManagement from "./QuotaManagement"; +import ModelQualityDashboard from "./ModelQualityDashboard"; +import VolumeTracker from "./VolumeTracker"; +import ProjectManagerLogin from "./ProjectManagerLogin"; +import ProjectSourceSettings from "./ProjectSourceSettings"; +import { + ProjectManagerProvider, + useProjectManager, +} from "../../contexts/ProjectManagerContext"; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; + +const ProjectManagerInner = () => { + const { + isAuthenticated, + user, + logout, + isAdmin, + isWorker, + pmConfig, + projectInfo, + globalProgress, + ingestData, + loading, + } = useProjectManager(); + + const [selectedKey, setSelectedKey] = useState("volumes"); + const [settingsOpen, setSettingsOpen] = useState(false); + const didAutoRouteRef = useRef(false); + const didAutoOpenSetupRef = useRef(false); + + const trackedVolumes = globalProgress?.total ?? 0; + const needsJson = !pmConfig?.metadata_exists; + const needsSync = !needsJson && trackedVolumes === 0; + + useEffect(() => { + if (!isAuthenticated) { + didAutoRouteRef.current = false; + didAutoOpenSetupRef.current = false; + setSelectedKey("volumes"); + return; + } + if (didAutoRouteRef.current) { + return; + } + setSelectedKey("volumes"); + didAutoRouteRef.current = true; + }, [isAuthenticated]); + + useEffect(() => { + if (!isAuthenticated) return; + if (!isAdmin) return; + if (didAutoOpenSetupRef.current) return; + if (!(needsJson || needsSync)) return; + setSettingsOpen(true); + didAutoOpenSetupRef.current = true; + }, [isAuthenticated, isAdmin, needsJson, needsSync]); + + const menuItems = [ + { + key: "volumes", + icon: , + label: isWorker ? "My Volumes" : "Volume Tracker", + }, + { key: "dashboard", icon: , label: "Dashboard" }, + { + key: "progress", + icon: , + label: isAdmin ? "Team Progress" : "My Progress", + }, + { + key: "quotas", + icon: , + label: isAdmin ? "Weekly Quotas" : "My Quota", + }, + ...(isAdmin + ? [ + { + key: "model-quality", + icon: , + label: "Model Quality", + }, + ] + : []), + { type: "divider" }, + { + key: "issues", + icon: , + label: "Issues & Flags", + disabled: true, + }, + ]; + + const renderSubView = () => { + switch (selectedKey) { + case "dashboard": + return ; + case "progress": + return ; + case "quotas": + return ; + case "volumes": + return setSettingsOpen(true)} />; + case "model-quality": + return ; + default: + return setSettingsOpen(true)} />; + } + }; + + const projectLabel = projectInfo?.name || pmConfig?.project_name || "Project"; + const metadataLabel = pmConfig?.metadata_path + ? pmConfig.metadata_path.split("/").pop() + : "metadata not set"; + const dataRootLabel = pmConfig?.data_root + ? pmConfig.data_root.split("/").pop() || pmConfig.data_root + : "storage not set"; + + const heroSummary = useMemo(() => { + if (needsJson) { + return "Open Settings to choose the project JSON."; + } + if (needsSync) { + return "Settings are saved. Run storage sync next."; + } + return "Project is loaded. Use Volume Tracker for assignments and status."; + }, [needsJson, needsSync]); + + if (!isAuthenticated) { + return ; + } + + return ( + + +
+ + Project Manager + + + streamlined workflow + +
+ +
+
+ + + {user?.name} + + + + {isAdmin ? "ADMIN" : "WORKER"} + + + + +
+
+ + setSelectedKey(e.key)} + items={menuItems} + style={{ borderRight: 0, flex: 1 }} + /> + + + + +
+ + + {projectLabel} + + {heroSummary} + + + {trackedVolumes} volumes + + {metadataLabel} + {dataRootLabel} + + + + + {isAdmin && ( + + )} + +
+
+ {renderSubView()} + setSettingsOpen(false)} + footer={null} + width={920} + destroyOnClose={false} + > + { + setSettingsOpen(false); + setSelectedKey("volumes"); + }} + /> + +
+ + ); +}; + +const ProjectManager = () => ( + + + + + +); + +export default ProjectManager; diff --git a/client/src/views/project-manager/ProjectManagerLogin.js b/client/src/views/project-manager/ProjectManagerLogin.js new file mode 100644 index 00000000..5325917e --- /dev/null +++ b/client/src/views/project-manager/ProjectManagerLogin.js @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import { Card, Form, Input, Button, Typography, Space, Divider } from "antd"; +import { UserOutlined, LockOutlined, RocketOutlined } from "@ant-design/icons"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +export default function ProjectManagerLogin() { + const { login } = useProjectManager(); + const [loading, setLoading] = useState(false); + + const onFinish = async (values) => { + setLoading(true); + await login(values.username, values.password); + setLoading(false); + }; + + return ( +
+ +
+
+ +
+ + Project Manager + + Sign in to manage EM connectomics tasks +
+ +
+ + } + placeholder="Username" + style={{ borderRadius: 8 }} + /> + + + + } + placeholder="Password" + style={{ borderRadius: 8 }} + /> + + + + + + + + + + Login Credentials + + + +
+ + + Admin Access: admin / admin123 + + + Annotator Access: alex / alex123 + + +
+
+
+ ); +} diff --git a/client/src/views/project-manager/ProjectSourceSettings.js b/client/src/views/project-manager/ProjectSourceSettings.js new file mode 100644 index 00000000..b82935e8 --- /dev/null +++ b/client/src/views/project-manager/ProjectSourceSettings.js @@ -0,0 +1,550 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Card, + Collapse, + Descriptions, + Input, + Popconfirm, + Space, + Table, + Typography, + message, +} from "antd"; +import { + CopyOutlined, + FolderOpenOutlined, + ReloadOutlined, + RobotOutlined, + SaveOutlined, + SyncOutlined, +} from "@ant-design/icons"; +import { getPMSchema } from "../../api"; +import UnifiedFileInput from "../../components/UnifiedFileInput"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Paragraph, Text, Title } = Typography; + +const schemaColumns = [ + { + title: "Field", + dataIndex: "field", + key: "field", + width: 180, + render: (value) => {value}, + }, + { + title: "Level", + dataIndex: "level", + key: "level", + width: 130, + render: (value) => ( + + {(value || "optional").toUpperCase()} + + ), + }, + { + title: "Type", + dataIndex: "type", + key: "type", + width: 140, + render: (value) => {value}, + }, + { + title: "Purpose", + dataIndex: "description", + key: "description", + }, +]; + +function SchemaTable({ rows }) { + return ( +
({ ...row, key: row.field }))} + pagination={false} + /> + ); +} + +function StatusBadge({ ok, goodLabel, badLabel }) { + return ( + + {ok ? goodLabel : badLabel} + + ); +} + +function ProjectSourceSettings({ onOpenVolumes, isModal = false }) { + const { + pmConfig, + projectInfo, + isAdmin, + loading, + configLoading, + globalProgress, + resetData, + updateProjectConfig, + refreshPmState, + ingestData, + } = useProjectManager(); + const [metadataPath, setMetadataPath] = useState(""); + const [dataRoot, setDataRoot] = useState(""); + const [schemaData, setSchemaData] = useState(null); + const [schemaLoading, setSchemaLoading] = useState(true); + + useEffect(() => { + setMetadataPath(pmConfig?.metadata_path ?? ""); + setDataRoot(pmConfig?.data_root ?? ""); + }, [pmConfig?.data_root, pmConfig?.metadata_path]); + + useEffect(() => { + let cancelled = false; + + const loadSchema = async () => { + setSchemaLoading(true); + try { + const nextSchema = await getPMSchema(); + if (!cancelled) { + setSchemaData(nextSchema); + } + } catch (error) { + if (!cancelled) { + message.error("Failed to load Project Manager schema reference."); + } + } finally { + if (!cancelled) { + setSchemaLoading(false); + } + } + }; + + loadSchema(); + return () => { + cancelled = true; + }; + }, []); + + const trackedVolumes = globalProgress?.total ?? 0; + const needsInitialProject = !pmConfig?.metadata_exists || trackedVolumes === 0; + const hasUnsavedChanges = + metadataPath !== (pmConfig?.metadata_path ?? "") || + dataRoot !== (pmConfig?.data_root ?? ""); + + const setupState = useMemo(() => { + if (!pmConfig?.metadata_exists) { + return { + title: "Pick the metadata JSON", + description: + "Choose the JSON file this project manager should own, then save the path.", + }; + } + if (!pmConfig?.data_root_exists || !pmConfig?.data_root_is_dir) { + return { + title: "Connect the storage root", + description: + "Set a valid directory containing your supported volume files or dataset directories, then save again.", + }; + } + if (trackedVolumes === 0) { + return { + title: "Sync storage", + description: + "Scan the storage root to populate the volumes array in the active JSON.", + }; + } + return { + title: "Track work", + description: + "Open Volume Tracker to assign owners and update status as the project moves.", + }; + }, [ + pmConfig?.data_root_exists, + pmConfig?.data_root_is_dir, + pmConfig?.metadata_exists, + trackedVolumes, + ]); + + const starterJson = useMemo( + () => JSON.stringify(schemaData?.blank_template ?? {}, null, 2), + [schemaData?.blank_template], + ); + + const skillCommand = useMemo( + () => + '/generate-project-manager-json "/absolute/path/to/data/root" "/absolute/path/to/project_manager_data.json"', + [], + ); + + const handleSave = async () => { + await updateProjectConfig({ + metadata_path: metadataPath, + data_root: dataRoot, + }); + }; + + const handleLoadExistingJson = async () => { + if (!metadataPath) { + message.error("Choose a project JSON file first."); + return; + } + await updateProjectConfig({ metadata_path: metadataPath }); + }; + + const handleResetPaths = async () => { + await updateProjectConfig({ + metadata_path: "", + data_root: "", + }); + }; + + const handleCopy = async (label, value) => { + try { + await navigator.clipboard.writeText(value); + message.success(`${label} copied to clipboard.`); + } catch (error) { + message.error(`Failed to copy ${label.toLowerCase()}.`); + } + }; + + const projectLabel = projectInfo?.name || pmConfig?.project_name || "Project"; + const canSync = + !hasUnsavedChanges && + !!pmConfig?.metadata_exists && + !!pmConfig?.data_root_exists && + !!pmConfig?.data_root_is_dir; + + return ( +
+ {!isModal && ( +
+ + {isAdmin ? "Project Setup" : "Project Info"} + + + Keep the core flow simple: choose the JSON, connect storage, sync + volumes, then move into tracking. + +
+ )} + + + + {isAdmin && ( + + )} + + } + > + + {isAdmin && needsInitialProject && ( + + )} + +
+ + {setupState.title} + + {setupState.description} +
+ + + {projectLabel} + + {trackedVolumes} + + + + {pmConfig?.metadata_path || "Not configured"} + + + + + + {pmConfig?.data_root || "Not configured"} + + + + + +
+ + 1. Metadata JSON path + + + setMetadataPath( + typeof value === "string" ? value : value?.path || "", + ) + } + disabled={!isAdmin || configLoading} + placeholder="/absolute/path/to/project_manager_data.json" + selectionType="file" + style={{ marginBottom: 8 }} + /> + + Use an existing file or a new file path whose parent directory + already exists. + + {isAdmin && ( + + + + + + + )} +
+ +
+ + 2. Storage root + + + setDataRoot(typeof value === "string" ? value : value?.path || "") + } + disabled={!isAdmin || configLoading} + placeholder="/absolute/path/to/data/root" + selectionType="directory" + style={{ marginBottom: 8 }} + /> + + Sync scans this directory recursively for supported volume files, + dataset containers, and image-stack directories, then updates the + active JSON. + +
+ + {isAdmin && ( + + + + + + )} +
+
+ + + Loading schema reference… + ) : ( + + + The minimum useful project file has `project_info`, + `workers`, `users`, and `volumes`. + + + Supported inputs:{" "} + {(schemaData?.supported_volume_inputs || []).join(", ")} + + + ), + }, + { + key: "volumes", + label: "Volume rows", + children: , + }, + { + key: "workers", + label: "Worker rows", + children: , + }, + { + key: "users", + label: "User rows", + children: , + }, + ]} + /> + + ), + }, + { + key: "starter-json", + label: "Starter JSON template", + extra: ( + + ), + children: ( + + + Save this starter file anywhere, then point the app at it. + + + + ), + }, + { + key: "skill", + label: "Generate with Claude Code", + extra: ( + + ), + children: ( + + + The repo-local skill scans a directory of supported volume + inputs and generates a compatible project JSON. + + + + + + `.claude/skills/generate-project-manager-json/` + + + + ), + }, + ]} + /> + +
+ ); +} + +export default ProjectSourceSettings; diff --git a/client/src/views/project-manager/ProofreaderProgress.js b/client/src/views/project-manager/ProofreaderProgress.js new file mode 100644 index 00000000..2d19b260 --- /dev/null +++ b/client/src/views/project-manager/ProofreaderProgress.js @@ -0,0 +1,499 @@ +import React, { useCallback, useMemo } from "react"; +import { + Alert, + Card, + Table, + Avatar, + Progress, + Typography, + Row, + Col, + Tag, + Space, + Badge, + Tooltip, + Divider, + Button, + Popconfirm, + Spin, +} from "antd"; +import { + UserOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + ThunderboltOutlined, + RiseOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +// ─── Read-only display pill (for metrics that must not look editable) ───────── +const ReadOnlyPill = ({ children, color = "#595959", bg = "#fafafa" }) => ( + + {children} + +); + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function WeeklyThroughputChart({ throughput }) { + const W = 800; + const H = 150; + const PAD = 40; + const chartW = W - PAD * 2; + const chartH = H - PAD; + + if (!throughput || throughput.length === 0) return null; + const maxVal = Math.max(...throughput.map((d) => d.count)) * 1.1; + const barWidth = (chartW / throughput.length) * 0.6; + const gap = (chartW / throughput.length) * 0.4; + + return ( + + {[0, 0.5, 1].map((f) => ( + + ))} + {throughput.map((d, i) => { + const x = PAD + i * (barWidth + gap) + gap / 2; + const barH = (d.count / maxVal) * chartH; + const y = PAD / 2 + chartH - barH; + return ( + + + + + + {d.day} + + + {d.count.toLocaleString()} + + + ); + })} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function ProofreaderProgress() { + const { + quotaData, + proofreaderData, + throughputData, + saving, + resetData, + isAdmin, + isWorker, + } = useProjectManager(); + + // Helper to get calculated stats for a proofreader from quotaData + const getStats = useCallback( + (key) => { + const quota = quotaData.find((q) => q.key === key); + if (!quota) return { target: 1500, actual: 0 }; + const target = + quota.mon + + quota.tue + + quota.wed + + quota.thu + + quota.fri + + quota.sat + + quota.sun; + const actual = + (quota.actualMon || 0) + + (quota.actualTue || 0) + + (quota.actualWed || 0) + + (quota.actualThu || 0) + + (quota.actualFri || 0) + + (quota.actualSat || 0) + + (quota.actualSun || 0); + return { target, actual }; + }, + [quotaData], + ); + + const topPerformers = useMemo(() => { + const withPoints = proofreaderData.map((p) => ({ + ...p, + weeklyPoints: getStats(p.key).actual, + })); + return [...withPoints] + .sort((a, b) => b.weeklyPoints - a.weeklyPoints) + .slice(0, 3); + }, [getStats, proofreaderData]); + + const teamAccuracy = + proofreaderData.length > 0 + ? ( + proofreaderData.reduce((sum, p) => sum + p.accuracy, 0) / + proofreaderData.length + ).toFixed(1) + : 0; + + const COLUMNS = [ + { + title: "Proofreader", + dataIndex: "name", + key: "name", + render: (name, record) => ( + + } + size="small" + /> +
+ + {name} + + + {record.role} + +
+
+ ), + }, + { + title: "Total Points", + dataIndex: "totalPoints", + key: "totalPoints", + align: "right", + render: (pts) => {pts.toLocaleString()}, + }, + { + title: "This Week", + key: "weeklyProgress", + align: "right", + render: (_, record) => { + const { target, actual } = getStats(record.key); + const pct = target > 0 ? Math.round((actual / target) * 100) : 0; + return ( + + + {actual.toLocaleString()}{" "} + + / {target.toLocaleString()} + + + = target ? "#52c41a" : "#1890ff"} + /> + + ); + }, + }, + { + title: "Accuracy", + dataIndex: "accuracy", + key: "accuracy", + align: "center", + render: (pct) => ( + = 95 ? "#52c41a" : pct >= 90 ? "#faad14" : "#f5222d"} + bg={pct >= 95 ? "#f6ffed" : pct >= 90 ? "#fffbe6" : "#fff2f0"} + > + {pct}% + + ), + }, + { + title: "Last Active", + dataIndex: "lastActive", + key: "lastActive", + render: (time, record) => ( + + + {time} + + ), + }, + ]; + + return ( +
+ + + {/* ── Header ── */} +
+
+ + {isWorker ? "My Performance" : "Proofreader Performance"} + + + {isWorker + ? "Your personal throughput and accuracy · auto-saved to server" + : "Real-time throughput and accuracy tracking · auto-saved to server"} + {saving && ( + <> + {" "} + · saving… + + )} + +
+ {isAdmin && ( + + + + )} +
+ + {/* ── Top Row: Individual Cards ── */} + + {proofreaderData.map((pr) => { + const { target, actual } = getStats(pr.key); + const pct = target > 0 ? Math.round((actual / target) * 100) : 0; + return ( +
+ + +
+ } + /> + +
+
+ + {pr.name} + + + {pr.role} + +
+
+
+ + Weekly Goal + + + {pct}% + +
+ = target ? "#52c41a" : "#1890ff"} + /> + + {actual.toLocaleString()} / {target.toLocaleString()} pts + +
+
+
+ + ); + })} + + + {/* ── Main Section: Table + Insights ── */} + + + + Active Session Metrics + + } + > +
+ + + + + + Top Performers + + } + style={{ height: "100%" }} + > + + {topPerformers.map((pr, idx) => ( +
+
+ {idx + 1} +
+
+ + {pr.name} + +
+ + {pr.weeklyPoints.toLocaleString()} pts this week + +
+ + +{Math.round(pr.accuracy)}% acc + +
+ ))} + + + +
+ + Team + Accuracy + +
+ + + Target: 95.0% + +
+
+
+
+ + + + {/* ── Bottom Section: Team Throughput ── */} + + Team Throughput (Last 7 Days) + + } + > + + + + ); +} + +export default ProofreaderProgress; diff --git a/client/src/views/project-manager/QuotaManagement.js b/client/src/views/project-manager/QuotaManagement.js new file mode 100644 index 00000000..9837a7d9 --- /dev/null +++ b/client/src/views/project-manager/QuotaManagement.js @@ -0,0 +1,548 @@ +import React, { useMemo } from "react"; +import { + Alert, + Card, + Table, + Button, + Input, + InputNumber, + Row, + Col, + Typography, + Space, + Tag, + Divider, + Progress, + Tooltip, + message, + Popconfirm, + Spin, +} from "antd"; +import { + ScheduleOutlined, + CopyOutlined, + ThunderboltOutlined, + EditOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; +const { TextArea } = Input; + +// ─── Sparkline trend data for the last 8 weeks (attainment percentages) ────── + +const PERFORMANCE_TRENDS = { + 1: [98, 102, 100, 95, 105, 101, 100, 99], + 2: [90, 92, 88, 85, 90, 94, 91, 89], + 3: [70, 75, 65, 80, 72, 60, 55, 62], + 4: [100, 105, 110, 100, 102, 108, 104, 106], +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getAttainmentColor(percent) { + if (percent >= 100) return "#52c41a"; + if (percent >= 75) return "#faad14"; + return "#f5222d"; +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function Sparkline({ data }) { + const W = 100; + const H = 20; + const gap = 2; + const barW = (W - (data.length - 1) * gap) / data.length; + return ( + + {data.map((v, i) => { + const h = (v / 120) * H; + return ( + + ); + })} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function QuotaManagement() { + const { + quotaData, + datasets, + msgPreview, + setQuotaData, + setMsgPreview, + saving, + resetData, + isAdmin, + isWorker, + } = useProjectManager(); + + const handleUpdateQuota = (key, day, val) => { + const updated = quotaData.map((item) => + item.key === key ? { ...item, [day]: val } : item, + ); + setQuotaData(updated); + }; + + const handleAutoAllocate = () => { + const datasetPriorityByName = new Map( + (datasets || []).map((dataset) => [dataset.name, dataset.priority]), + ); + const weeklyTotal = quotaData.reduce( + (sum, row) => + sum + + row.mon + + row.tue + + row.wed + + row.thu + + row.fri + + row.sat + + row.sun, + 0, + ); + const scoredRows = quotaData.map((row) => { + const currentTarget = + row.mon + row.tue + row.wed + row.thu + row.fri + row.sat + row.sun; + const currentActual = + (row.actualMon || 0) + + (row.actualTue || 0) + + (row.actualWed || 0) + + (row.actualThu || 0) + + (row.actualFri || 0) + + (row.actualSat || 0) + + (row.actualSun || 0); + const datasetWeight = (row.datasets || []).reduce((sum, datasetName) => { + return ( + sum + + (datasetPriorityByName.get(datasetName) === "high" ? 1.35 : 1.0) + ); + }, 0); + const normalizedDatasetWeight = row.datasets?.length + ? datasetWeight / row.datasets.length + : 1; + const score = + Math.max(currentActual, Math.max(currentTarget, 1) * 0.85, 1) * + normalizedDatasetWeight; + return { row, currentTarget, score }; + }); + const totalScore = + scoredRows.reduce((sum, entry) => sum + entry.score, 0) || 1; + const dayKeys = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + + const updated = scoredRows.map(({ row, score }) => { + const nextRow = { ...row }; + const weeklyTarget = Math.max( + 100, + Math.round((weeklyTotal * (score / totalScore)) / 10) * 10, + ); + const currentDayTotal = dayKeys.reduce((sum, day) => sum + row[day], 0); + const activeDays = dayKeys.filter((day) => row[day] > 0); + const distributionDays = + activeDays.length > 0 ? activeDays : ["mon", "tue", "wed", "thu", "fri"]; + let remaining = weeklyTarget; + + distributionDays.forEach((day, index) => { + let nextValue; + if (index === distributionDays.length - 1) { + nextValue = remaining; + } else { + const weight = + currentDayTotal > 0 ? row[day] / currentDayTotal : 1 / distributionDays.length; + nextValue = Math.round((weeklyTarget * weight) / 10) * 10; + remaining -= nextValue; + } + nextRow[day] = Math.max(0, nextValue); + }); + + dayKeys + .filter((day) => !distributionDays.includes(day)) + .forEach((day) => { + nextRow[day] = 0; + }); + + return nextRow; + }); + + setQuotaData(updated); + message.success( + "Rebalanced weekly targets using recent throughput and dataset priority.", + ); + }; + + const handleCopyDraft = async () => { + try { + await navigator.clipboard.writeText(msgPreview); + message.success("Communication draft copied to clipboard."); + } catch (error) { + message.error("Failed to copy draft to clipboard."); + } + }; + + // Day columns + const dayCols = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map( + (day) => ({ + title: day.charAt(0).toUpperCase() + day.slice(1), + dataIndex: day, + key: day, + width: 100, + render: (val, record) => { + const actual = + record[`actual${day.charAt(0).toUpperCase() + day.slice(1)}`]; + const attainment = val > 0 ? Math.round((actual / val) * 100) : 100; + return ( +
+
+ + Target + + handleUpdateQuota(record.key, day, v)} + disabled={isWorker} + style={{ + width: "100%", + fontWeight: "bold", + background: "#e6f7ff", + borderRadius: 4, + border: "1px solid #91d5ff", + }} + controls={false} + /> +
+
+ + Actual + + + + {actual} + + +
+
+ ); + }, + }), + ); + + const finalColumns = [ + { + title: "Proofreader", + dataIndex: "name", + key: "name", + fixed: "left", + width: 160, + render: (text, record) => ( +
+ {text} +
+ {record.datasets.join(", ")} +
+
+ ), + }, + ...dayCols, + { + title: "Weekly Summary", + key: "total", + width: 140, + fixed: "right", + render: (_, record) => { + const targetTotal = + record.mon + + record.tue + + record.wed + + record.thu + + record.fri + + record.sat + + record.sun; + const actualTotal = + record.actualMon + + record.actualTue + + record.actualWed + + record.actualThu + + record.actualFri + + record.actualSat + + record.actualSun; + const attainment = Math.round((actualTotal / targetTotal) * 100); + return ( +
+
+ {actualTotal} + / {targetTotal} +
+ +
+ ); + }, + }, + { + title: "8-Wk Trend", + key: "trend", + width: 120, + fixed: "right", + render: (_, record) => ( + + ), + }, + ]; + + // Dynamic Calculations for Summary + const totals = useMemo(() => { + return quotaData.reduce( + (acc, row) => { + acc.target += + row.mon + row.tue + row.wed + row.thu + row.fri + row.sat + row.sun; + acc.actual += + (row.actualMon || 0) + + (row.actualTue || 0) + + (row.actualWed || 0) + + (row.actualThu || 0) + + (row.actualFri || 0) + + (row.actualSat || 0) + + (row.actualSun || 0); + return acc; + }, + { target: 0, actual: 0 }, + ); + }, [quotaData]); + + const globalUtilization = + totals.target > 0 ? Math.round((totals.actual / totals.target) * 100) : 0; + const remainingBuffer = Math.max(0, totals.target - totals.actual); + + return ( +
+ + + {/* ── Header ── */} + +
+ + {isWorker ? "My Weekly Quota" : "Weekly Quota Management"} + + + {isWorker + ? "Manager-defined targets and your current attainment" + : "Plan targets and track capacity utilization · auto-saved to JSON"} + {saving && ( + <> + {" "} + · saving… + + )} + + + + + Single active plan + {isAdmin && ( + + )} + {isAdmin && ( + + + + )} + + + + + {/* ── Quota Table ── */} + + + Targets vs Actuals + + Week 10 (Current) + + + } + extra={ + + {isAdmin + ? "Blue cells are editable manager targets." + : "Targets are manager-controlled in this build."} + + } + style={{ marginBottom: 20, borderRadius: 8, overflow: "hidden" }} + > +
+ + + + {/* Capacity Summary */} + + Capacity Summary} + style={{ borderRadius: 8 }} + > +
+ + + Global Utilization + + + + +
+ Total Weekly Target + {totals.target.toLocaleString()} points +
+
+ Actual points so far + {totals.actual.toLocaleString()} points +
+
+ Remaining to target + 0 ? "#faad14" : "#52c41a" }} + > + {remainingBuffer.toLocaleString()} + +
+
+
+ + + {isAdmin && ( + + + Team Communication Draft + + } + extra={ + + } + > +