From fff7985c5c2a172bd8360f2a5cf43c6765c026c8 Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Thu, 5 Mar 2026 19:54:52 -0500 Subject: [PATCH 1/5] feat(pm): initial project manager with RBAC gateway (issue #125) - Implemented Login UI and Auth context with localStorage persistence. - Added Admin/Worker role-based view gating. - Integrated mock data for 19 real volumes from cluster export. - Resolved merge conflicts with upstream/main and handled WorkflowSelector sunset. --- .agents/rules/pytc-client.md | 6 + client/src/views/Views.js | 43 ++ .../project-manager/AnnotationDashboard.js | 616 ++++++++++++++++++ .../project-manager/ModelQualityDashboard.js | 352 ++++++++++ .../views/project-manager/ProjectManager.js | 118 ++++ .../project-manager/ProofreaderProgress.js | 368 +++++++++++ .../views/project-manager/QuotaManagement.js | 406 ++++++++++++ docs/seg.bio_job_management.pdf | Bin 0 -> 1795933 bytes 8 files changed, 1909 insertions(+) create mode 100644 .agents/rules/pytc-client.md create mode 100644 client/src/views/project-manager/AnnotationDashboard.js create mode 100644 client/src/views/project-manager/ModelQualityDashboard.js create mode 100644 client/src/views/project-manager/ProjectManager.js create mode 100644 client/src/views/project-manager/ProofreaderProgress.js create mode 100644 client/src/views/project-manager/QuotaManagement.js create mode 100644 docs/seg.bio_job_management.pdf diff --git a/.agents/rules/pytc-client.md b/.agents/rules/pytc-client.md new file mode 100644 index 0000000..513a1d3 --- /dev/null +++ b/.agents/rules/pytc-client.md @@ -0,0 +1,6 @@ +--- +trigger: always_on +glob: +description: +--- + diff --git a/client/src/views/Views.js b/client/src/views/Views.js index e442adf..171c5ef 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -8,13 +8,22 @@ import { DashboardOutlined, BugOutlined, MessageOutlined, + BarChartOutlined, + ProjectOutlined, } from "@ant-design/icons"; import FilesManager from "./FilesManager"; import Visualization from "./Visualization"; import ModelTraining from "./ModelTraining"; import ModelInference from "./ModelInference"; import Monitoring from "./Monitoring"; +<<<<<<< HEAD import MaskProofreading from "./MaskProofreading"; +======= +import ProofReading from "./ProofReading"; +import WormErrorHandling from "./WormErrorHandling"; +import ProjectManager from "./project-manager/ProjectManager"; +import WorkflowSelector from "../components/WorkflowSelector"; +>>>>>>> c648179 (feat: annotation management system) import Chatbot from "../components/Chatbot"; const { Content } = Layout; @@ -46,6 +55,34 @@ function Views() { const [viewers, setViewers] = useState([]); const [isInferring, setIsInferring] = useState(false); +<<<<<<< HEAD +======= + 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)); + +>>>>>>> c648179 (feat: annotation management system) const onClick = (e) => { setCurrent(e.key); setVisitedTabs((prev) => new Set(prev).add(e.key)); @@ -140,7 +177,13 @@ function Views() { setIsInferring={setIsInferring} />, )} +<<<<<<< HEAD {renderTabContent("mask-proofreading", )} +======= + {renderTabContent("synanno", )} + {renderTabContent("worm-error-handling", )} + {renderTabContent("project-manager", )} +>>>>>>> c648179 (feat: annotation management system) ; +} + +// 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() { + const W = 900; // viewBox width + const H = 80; + const PAD = 40; + const trackY = 48; + const trackW = W - PAD * 2; + + // Month tick marks + 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"); + } + + // Today marker + const todayFrac = dateToFrac(dayjs("2026-03-02")); + + return ( + + {/* Track background */} + + {/* Progress fill */} + + + {/* Month ticks */} + {months.map((m) => { + const x = PAD + dateToFrac(m) * trackW; + return ( + + + + {m.format("MMM")} + + + ); + })} + + {/* Milestones */} + {MILESTONES.map((ms) => { + const x = PAD + dateToFrac(ms.date) * trackW; + return ( + + + {/* Diamond shape */} + + + {ms.label} + + + + ); + })} + + {/* Today marker */} + + + Today + + + ); +} + +/** Cumulative progress SVG line chart */ +function CumulativeChart() { + 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(...CUMULATIVE_DATA, ...CUMULATIVE_TARGET) * 1.05; + const weeks = CUMULATIVE_DATA.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`; + + // Y axis ticks + const yTicks = [0, 10000, 20000, 30000]; + + // X axis ticks (every 4 weeks) + const xTicks = [0, 4, 8, 12, 16, 20, 24]; + + return ( + + {/* Grid lines */} + {yTicks.map((v) => ( + + + + {v === 0 ? "0" : `${v / 1000}k`} + + + ))} + + {/* X axis ticks */} + {xTicks.map((i) => ( + + Wk {i + 1} + + ))} + + {/* Target area fill */} + + + {/* Target line */} + + + {/* Actual area fill */} + + + {/* Actual line */} + + + {/* Legend */} + + + 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 [filter, setFilter] = useState("all"); + + // Derived stats + const totalSamples = useMemo( + () => DATASETS.reduce((s, d) => s + d.total, 0), + [] + ); + const totalProofread = useMemo( + () => + DATASETS.reduce((s, d) => s + Math.round((d.proofread / 100) * d.total), 0), + [] + ); + const overallPct = Math.round((totalProofread / totalSamples) * 100); + const activeDatasets = DATASETS.filter((d) => d.status === "in_progress").length; + + // 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]); + + const filterBtns = [ + { key: "all", label: "All" }, + { key: "high", label: "High Priority" }, + { key: "blocked", label: "Blocked" }, + ]; + + return ( +
+ {/* ── Header ── */} +
+ + Neural Dataset Proofreading – 6 Months + + + {PROJECT_START.format("MMM D, YYYY")} → {PROJECT_END.format("MMM D, YYYY")} + +
+ + {/* ── KPI Cards ── */} + + + + } + valueStyle={{ color: "#52c41a", fontSize: 18 }} + /> + + + + + + } + valueStyle={{ color: "#1890ff", fontSize: 18 }} + /> + + + + + } + valueStyle={{ color: "#722ed1", fontSize: 18 }} + /> + + + + + } + valueStyle={{ color: "#fa8c16", fontSize: 18 }} + /> + + + + + {/* ── Timeline ── */} + 6-Month Project Timeline} + style={{ marginBottom: 20 }} + bodyStyle={{ paddingTop: 8 }} + > + + + + {/* ── Main content: Table + Right Sidebar ── */} + + {/* Datasets Table */} + + Datasets} + extra={ + + {filterBtns.map((b) => ( + + ))} + + } + style={{ marginBottom: 20 }} + > + + + + + {/* Right Sidebar */} + + {/* This Week */} + + + This Week + + } + style={{ marginBottom: 12 }} + > + Items proofread vs. target +
+
+ Completed + 1,420 +
+ +
+ Target: 1,750 + 80% +
+
+
+ + {/* At Risk */} + + + At Risk + + } + style={{ marginBottom: 12 }} + > + {AT_RISK.map((item, idx) => ( +
+ {idx > 0 && } +
+ {item.icon === "clock" ? ( + + ) : item.icon === "blocked" ? ( + + ) : ( + + )} +
+ {item.label} +
+ {item.reason} +
+
+
+ ))} +
+ + {/* Upcoming Milestones */} + + + Upcoming Milestones + + } + > + {UPCOMING_MILESTONES.map((ms, idx) => ( +
+ {idx > 0 && } +
+ {ms.label} + {ms.date} +
+
+ ))} +
+ + + + {/* ── Cumulative Progress Chart ── */} + Cumulative Items Proofread over 6 Months} + style={{ marginBottom: 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 0000000..cac8b46 --- /dev/null +++ b/client/src/views/project-manager/ModelQualityDashboard.js @@ -0,0 +1,352 @@ +import React, { useState } from "react"; +import { + Card, + Row, + Col, + Statistic, + Progress, + Table, + Tag, + Button, + 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 CONFUSION_MATRIX = [ + { key: "1", actual: "Synapse", predSynapse: 1420, predNonSynapse: 85 }, + { key: "2", actual: "Non-Synapse", predSynapse: 112, predNonSynapse: 3200 }, +]; + +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 + Monitor model accuracy and annotation consistency + + + + Model Version: + + + + + + + {/* ── Main Metrics Gauges ── */} + + + + ( +
+
{pct}
+
F1 Score
+
+ )} + /> + +
+ + + + + + + + + + + + + + + } /> + + + } /> + + + } /> + + + + + + + + {/* Quality Trend */} + + + + Model Performance Trend (Last 12 Iterations) + + } + extra={} + 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 0000000..f4ae31a --- /dev/null +++ b/client/src/views/project-manager/ProjectManager.js @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import { Layout, Menu, Typography, ConfigProvider } from "antd"; +import { + DashboardOutlined, + TeamOutlined, + ScheduleOutlined, + LineChartOutlined, + BugOutlined, + GlobalOutlined, + SettingOutlined, +} from "@ant-design/icons"; + +import AnnotationDashboard from "./AnnotationDashboard"; +import ProofreaderProgress from "./ProofreaderProgress"; +import QuotaManagement from "./QuotaManagement"; +import ModelQualityDashboard from "./ModelQualityDashboard"; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; + +const ProjectManager = () => { + const [selectedKey, setSelectedKey] = useState("dashboard"); + + const menuItems = [ + { + key: "dashboard", + icon: , + label: "Dashboard", + }, + { + key: "progress", + icon: , + label: "Proofreader Progress", + }, + { + key: "quotas", + icon: , + label: "Weekly Quotas", + }, + { + key: "model-quality", + icon: , + label: "Model Quality", + }, + { + type: "divider", + }, + { + key: "issues", + icon: , + label: "Issues & Flags", + disabled: true, + }, + { + key: "global-quality", + icon: , + label: "Global Quality", + disabled: true, + }, + { + key: "settings", + icon: , + label: "Settings", + disabled: true, + }, + ]; + + const renderSubView = () => { + switch (selectedKey) { + case "dashboard": + return ; + case "progress": + return ; + case "quotas": + return ; + case "model-quality": + return ; + default: + return ; + } + }; + + return ( + + + +
+ Project Manager + v1.0.4-beta +
+ setSelectedKey(e.key)} + items={menuItems} + style={{ borderRight: 0 }} + /> + + + {renderSubView()} + + + + ); +}; + +export default ProjectManager; diff --git a/client/src/views/project-manager/ProofreaderProgress.js b/client/src/views/project-manager/ProofreaderProgress.js new file mode 100644 index 0000000..b200338 --- /dev/null +++ b/client/src/views/project-manager/ProofreaderProgress.js @@ -0,0 +1,368 @@ +import React, { useMemo } from "react"; +import { + Card, + Table, + Avatar, + Progress, + Typography, + Row, + Col, + Tag, + Space, + Badge, + Tooltip, + Divider, +} from "antd"; +import { + UserOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + ThunderboltOutlined, + RiseOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; + +const { Title, Text } = Typography; + +// ─── Seed Data ─────────────────────────────────────────────────────────────── + +const PROOFREADERS = [ + { + key: "1", + name: "Alex Rivera", + avatarColor: "#1890ff", + role: "Senior Annotator", + totalPoints: 12450, + weeklyPoints: 1420, + weeklyQuota: 1750, + accuracy: 98.5, + lastActive: "2 mins ago", + status: "online", + }, + { + key: "2", + name: "Jordan Smith", + avatarColor: "#52c41a", + role: "Proofreader", + totalPoints: 8900, + weeklyPoints: 1100, + weeklyQuota: 1500, + accuracy: 94.2, + lastActive: "15 mins ago", + status: "online", + }, + { + key: "3", + name: "Sam Taylor", + avatarColor: "#fadb14", + role: "Proofreader", + totalPoints: 5600, + weeklyPoints: 850, + weeklyQuota: 1500, + accuracy: 92.1, + lastActive: "1 hour ago", + status: "away", + }, + { + key: "4", + name: "Casey Chen", + avatarColor: "#eb2f96", + role: "Junior Annotator", + totalPoints: 3200, + weeklyPoints: 1250, + weeklyQuota: 1200, + accuracy: 96.8, + lastActive: "3 hours ago", + status: "offline", + }, + { + key: "5", + name: "Robin Banks", + avatarColor: "#722ed1", + role: "Proofreader", + totalPoints: 7100, + weeklyPoints: 300, + weeklyQuota: 1500, + accuracy: 89.5, + lastActive: "Yesterday", + status: "offline", + }, +]; + +// Team daily throughput (last 7 days) +const DAILY_THROUGHPUT = [ + { day: "Mon", count: 4200 }, + { day: "Tue", count: 3800 }, + { day: "Wed", count: 5100 }, + { day: "Thu", count: 4700 }, + { day: "Fri", count: 5300 }, + { day: "Sat", count: 1200 }, + { day: "Sun", count: 900 }, +]; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function WeeklyThroughputChart() { + const W = 800; + const H = 150; + const PAD = 40; + const chartW = W - PAD * 2; + const chartH = H - PAD; + + const maxVal = Math.max(...DAILY_THROUGHPUT.map(d => d.count)) * 1.1; + const barWidth = (chartW / DAILY_THROUGHPUT.length) * 0.6; + const gap = (chartW / DAILY_THROUGHPUT.length) * 0.4; + + return ( + + {/* Grid lines */} + {[0, 0.5, 1].map((f) => ( + + ))} + + {DAILY_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()} + + + ); + })} + + ); +} + +// ─── Column definitions ─────────────────────────────────────────────────────── + +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", + dataIndex: "weeklyPoints", + key: "weeklyPoints", + align: "right", + render: (pts, record) => ( + + {pts.toLocaleString()} + = record.weeklyQuota ? "#52c41a" : "#1890ff"} + /> + + ), + }, + { + title: "Accuracy", + dataIndex: "accuracy", + key: "accuracy", + align: "center", + render: (pct) => ( + = 95 ? "success" : pct >= 90 ? "warning" : "error"} style={{ borderRadius: 12 }}> + {pct}% + + ), + }, + { + title: "Last Active", + dataIndex: "lastActive", + key: "lastActive", + render: (time, record) => ( + + + {time} + + ), + }, +]; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function ProofreaderProgress() { + const topPerformers = useMemo(() => + [...PROOFREADERS].sort((a, b) => b.weeklyPoints - a.weeklyPoints).slice(0, 3) + , []); + + return ( +
+ {/* ── Header ── */} +
+ Proofreader Performance + Real-time throughput and accuracy tracking +
+ + {/* ── Top Row: Individual Cards ── */} + + {PROOFREADERS.map((pr) => ( +
+ + +
+ } + /> + +
+
+ {pr.name} + {pr.role} +
+
+
+ Weekly Quota + + {Math.round((pr.weeklyPoints / pr.weeklyQuota) * 100)}% + +
+ = pr.weeklyQuota ? "#52c41a" : "#1890ff"} + /> + + {pr.weeklyPoints} / {pr.weeklyQuota} pts + +
+
+
+ + ))} + + + {/* ── Main Section: Table ── */} + + + Active Session Metrics} + > +
+ + + + {/* Right Panel: Insights */} + + 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 0000000..05db869 --- /dev/null +++ b/client/src/views/project-manager/QuotaManagement.js @@ -0,0 +1,406 @@ +import React, { useState, useMemo } from "react"; +import { + Card, + Table, + DatePicker, + Button, + Input, + InputNumber, + Row, + Col, + Typography, + Space, + Tag, + Divider, + Progress, + Tooltip, + message, +} from "antd"; +import { + ScheduleOutlined, + SendOutlined, + ThunderboltOutlined, + LeftOutlined, + RightOutlined, + EditOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import weekday from "dayjs/plugin/weekday"; +import localeData from "dayjs/plugin/localeData"; + +dayjs.extend(weekday); +dayjs.extend(localeData); + +const { Title, Text } = Typography; +const { RangePicker } = DatePicker; +const { TextArea } = Input; + +// ─── Seed Data ─────────────────────────────────────────────────────────────── + +const QUOTA_DATA = [ + { + key: "1", + name: "Alex Rivera", + datasets: ["Hippocampus_CA3", "Motor_Cortex_M1"], + mon: 300, tue: 300, wed: 300, thu: 300, fri: 300, sat: 150, sun: 100, + actualMon: 310, actualTue: 290, actualWed: 320, actualThu: 280, actualFri: 300, actualSat: 160, actualSun: 90, + }, + { + key: "2", + name: "Jordan Smith", + datasets: ["Cerebellum_PC"], + mon: 250, tue: 250, wed: 250, thu: 250, fri: 250, sat: 0, sun: 0, + actualMon: 240, actualTue: 260, actualWed: 230, actualThu: 250, actualFri: 220, actualSat: 0, actualSun: 0, + }, + { + key: "3", + name: "Sam Taylor", + datasets: ["Retina_GCL"], + mon: 200, tue: 200, wed: 200, thu: 200, fri: 200, sat: 250, sun: 250, + actualMon: 180, actualTue: 190, actualWed: 170, actualThu: 210, actualFri: 200, actualSat: 100, actualSun: 0, + }, + { + key: "4", + name: "Casey Chen", + datasets: ["Visual_Cortex_V1"], + mon: 240, tue: 240, wed: 240, thu: 240, fri: 240, sat: 0, sun: 0, + actualMon: 250, actualTue: 260, actualWed: 270, actualThu: 240, actualFri: 230, actualSat: 0, actualSun: 0, + }, +]; + +// 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"; // success + if (percent >= 75) return "#faad14"; // warning + return "#f5222d"; // error +} + +// ─── 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; // scaled to 120% max + return ( + + ); + })} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function QuotaManagement() { + const [data, setData] = useState(QUOTA_DATA); + const [selectedWeek, setSelectedWeek] = useState(dayjs().startOf("week")); + const [msgPreview, setMsgPreview] = useState( + "Hi Team,\n\nI've just assigned the quotas for the upcoming week. Please review your targets in the dashboard. Our goal for this week is to maintain 95%+ accuracy while meeting the sample volume targets.\n\nGood luck!" + ); + + const handleUpdateQuota = (key, day, val) => { + const newData = data.map(item => { + if (item.key === key) { + return { ...item, [day]: val }; + } + return item; + }); + setData(newData); + }; + + const handleAutoAllocate = () => { + message.loading("Calculating optimal distribution...", 1.5).then(() => { + message.success("Quotas auto-allocated based on user capacity and dataset priority."); + }); + }; + + const handleSendQuotas = () => { + message.success("Weekly quotas dispatched to 4 proofreaders."); + }; + + const columns = [ + { + title: "Name", + dataIndex: "name", + key: "name", + width: 150, + render: (text, record) => ( +
+ {text} +
+ {record.datasets.join(", ")} +
+
+ ) + }, + { + title: "Target / Actual", + children: [ + { title: "Mon", dataIndex: "mon", key: "mon", width: 80 }, + { title: "Tue", dataIndex: "tue", key: "tue", width: 80 }, + { title: "Wed", dataIndex: "wed", key: "wed", width: 80 }, + { title: "Thu", dataIndex: "thu", key: "thu", width: 80 }, + { title: "Fri", dataIndex: "fri", key: "fri", width: 80 }, + { title: "Sat", dataIndex: "sat", key: "sat", width: 80 }, + { title: "Sun", dataIndex: "sun", key: "sun", width: 80 }, + ], + render: (_, record) => { + // This is a complex render because we want Target (editable) over Actual + // For simplicity in this mock, we'll just show the target as an input for the day selected + // but Ant Design tables handle nested children differently. + // We'll map the days below for clarity. + } + }, + { + title: "Weekly Total", + key: "total", + width: 120, + align: "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.toLocaleString()} + / {targetTotal.toLocaleString()} +
+ + {attainment}% + +
+ ); + } + }, + { + title: "Capacity", + key: "capacity", + width: 100, + render: (_, record) => { + const total = record.mon + record.tue + record.wed + record.thu + record.fri + record.sat + record.sun; + const load = Math.min(100, Math.round((total / 2000) * 100)); // Assuming 2000 is max capacity + return 90 ? "exception" : "active"} />; + } + }, + { + title: "8-Week Trend", + key: "trend", + width: 120, + render: (_, record) => + } + ]; + + // Manual mapping of day columns to enable per-cell editing UI + const dayCols = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map(day => ({ + title: day.charAt(0).toUpperCase() + day.slice(1), + dataIndex: day, + key: day, + width: 80, + 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 ( +
+ handleUpdateQuota(record.key, day, v)} + bordered={false} + style={{ width: "100%", fontWeight: "bold", padding: 0 }} + controls={false} + /> + + + {actual} ({attainment}%) + + +
+ ); + } + })); + + 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) => + } + ]; + + return ( +
+ {/* ── Header ── */} + +
+ Weekly Quota Management + Plan targets and track capacity utilization + + + + + + + + + {/* ── Quota Table ── */} + + + Targets vs Actuals + Week 10 (Current) + + } + extra={Click values to edit targets} + style={{ marginBottom: 20 }} + > +
+ + + + {/* Statistics Component (Small subset) */} + + Capacity Summary}> +
+ + Global Utilization + + + +
+ Total Weekly Goal + 7,200 samples +
+
+ Allocated So Far + 5,480 samples +
+
+ Remaining Buffer + 1,720 (24%) +
+
+
+ + + {/* Communication Preview */} + + Team Communication Preview} + extra={ + + } + > +