diff --git a/.agents/rules/pytc-client.md b/.agents/rules/pytc-client.md new file mode 100644 index 0000000..846f1ae --- /dev/null +++ b/.agents/rules/pytc-client.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +glob: +description: +--- diff --git a/.logs/start/api-server.log b/.logs/start/api-server.log new file mode 100644 index 0000000..356d2b5 --- /dev/null +++ b/.logs/start/api-server.log @@ -0,0 +1,17 @@ +INFO: Started server process [1802573] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:4242 (Press CTRL+C to quit) +================================================================================ +EHTOOL ROUTER MODULE LOADED - VERSION: DEBUG v2 +================================================================================ +INFO: 127.0.0.1:52216 - "GET /health HTTP/1.1" 200 OK +INFO: 127.0.0.1:39000 - "GET /api/pm/data HTTP/1.1" 200 OK +INFO: 127.0.0.1:38992 - "GET /files?parent=root HTTP/1.1" 200 OK +INFO: 127.0.0.1:39000 - "GET /api/pm/data HTTP/1.1" 200 OK +INFO: 127.0.0.1:55914 - "OPTIONS /api/pm/login HTTP/1.1" 200 OK +INFO: 127.0.0.1:55930 - "POST /api/pm/login HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [1802573] diff --git a/.logs/start/data-server.log b/.logs/start/data-server.log new file mode 100644 index 0000000..b7f71d6 --- /dev/null +++ b/.logs/start/data-server.log @@ -0,0 +1 @@ +127.0.0.1 - - [26/Mar/2026 14:48:47] "GET / HTTP/1.1" 200 - diff --git a/.logs/start/pytc-server.log b/.logs/start/pytc-server.log new file mode 100644 index 0000000..cfd5395 --- /dev/null +++ b/.logs/start/pytc-server.log @@ -0,0 +1,21 @@ +INFO: Started server process [1802650] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:4243 (Press CTRL+C to quit) + +================================================================================ +SERVER_PYTC STARTING UP +Python executable: /home/sam/Workshop/pytc-client/.venv/bin/python3 +Working directory: /home/sam/Workshop/pytc-client +================================================================================ + + +================================================================================ +SERVER_PYTC: Starting Uvicorn server on port 4243... +================================================================================ + +INFO: 127.0.0.1:50152 - "GET /hello HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [1802650] diff --git a/.logs/start/react-build.log b/.logs/start/react-build.log new file mode 100644 index 0000000..b675bfd --- /dev/null +++ b/.logs/start/react-build.log @@ -0,0 +1,82 @@ + +> PyTC Client@0.1.0 build +> cross-env CI=false react-scripts build + +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +Creating an optimized production build... +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +Compiled with warnings. + +[eslint] +src/components/FilePickerModal.js + Line 102:35: Function declared in a loop contains unsafe references to variable(s) 'curr' no-loop-func + Line 124:35: Function declared in a loop contains unsafe references to variable(s) 'currParentId' no-loop-func + Line 243:15: The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid + +src/components/YamlFileUploader.js + Line 347:6: React Hook useEffect has missing dependencies: 'getCurrentConfig' and 'syncYamlContext'. Either include them or remove the dependency array react-hooks/exhaustive-deps + Line 361:6: React Hook useEffect has missing dependencies: 'getCurrentConfig', 'setCurrentConfig', and 'updateInputSelectorInformation'. Either include them or remove the dependency array react-hooks/exhaustive-deps + Line 374:6: React Hook useMemo has a missing dependency: 'getCurrentConfig'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +src/views/FilesManager.js + Line 81:31: Function declared in a loop contains unsafe references to variable(s) 'current' no-loop-func + Line 420:27: Function declared in a loop contains unsafe references to variable(s) 'curr' no-loop-func + Line 772:32: Function declared in a loop contains unsafe references to variable(s) 'descendantSizeKb', 'descendantSizeKb', 'descendantSizeKb', 'descendantSizeKb' no-loop-func + Line 1069:6: React Hook useEffect has missing dependencies: 'finishCreateFolder', 'finishRename', 'handleCopy', 'handleCut', 'handleDelete', and 'handlePaste'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +src/views/Views.js + Line 11:3: 'BarChartOutlined' is defined but never used no-unused-vars + +src/views/mask-proofreading/MaskProofreading.js + Line 5:10: 'ehToolSession' is assigned a value but never used no-unused-vars + Line 5:25: 'setEhToolSession' is assigned a value but never used no-unused-vars + Line 6:10: 'refreshTrigger' is assigned a value but never used no-unused-vars + Line 6:26: 'setRefreshTrigger' is assigned a value but never used no-unused-vars + +src/views/project-manager/AnnotationDashboard.js + Line 340:5: 'isAdmin' is assigned a value but never used no-unused-vars + +src/views/project-manager/ModelQualityDashboard.js + Line 40:7: 'CONFUSION_MATRIX' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProjectManager.js + Line 19:3: 'UserOutlined' is defined but never used no-unused-vars + Line 20:3: 'CrownOutlined' is defined but never used no-unused-vars + Line 38:61: 'loading' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProjectManagerLogin.js + Line 14:11: 'success' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProofreaderProgress.js + Line 163:6: React Hook useMemo has a missing dependency: 'getStats'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +src/views/project-manager/QuotaManagement.js + Line 29:3: 'SaveOutlined' is defined but never used no-unused-vars + Line 98:5: 'activeWorker' is assigned a value but never used no-unused-vars + +src/views/project-manager/VolumeTracker.js + Line 16:3: 'Popconfirm' is defined but never used no-unused-vars + Line 127:6: React Hook useMemo has an unnecessary dependency: 'isAdmin'. Either exclude it or remove the dependency array react-hooks/exhaustive-deps + Line 184:15: 'cfg' is assigned a value but never used no-unused-vars + +Search for the keywords to learn more about each warning. +To ignore, add // eslint-disable-next-line to the line before. + +File sizes after gzip: + + 553.16 kB build/static/js/main.be80df26.js + 465 B build/static/css/main.40821645.css + +The bundle size is significantly larger than recommended. +Consider reducing it with code splitting: https://goo.gl/9VhYWB +You can also analyze the project dependencies: https://goo.gl/LeUzfb + +The project was built assuming it is hosted at ./. +You can control this with the homepage field in your package.json. + +The build folder is ready to be deployed. + +Find out more about deployment here: + + https://cra.link/deployment + diff --git a/.logs/start/react-dev.log b/.logs/start/react-dev.log new file mode 100644 index 0000000..aa502f2 --- /dev/null +++ b/.logs/start/react-dev.log @@ -0,0 +1,21 @@ + +> PyTC Client@0.1.0 start +> react-scripts start + +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +(node:1802959) [DEP_WEBPACK_DEV_SERVER_ON_AFTER_SETUP_MIDDLEWARE] DeprecationWarning: 'onAfterSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option. +(Use `node --trace-deprecation ...` to show where the warning was created) +(node:1802959) [DEP_WEBPACK_DEV_SERVER_ON_BEFORE_SETUP_MIDDLEWARE] DeprecationWarning: 'onBeforeSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option. +Starting the development server... + +Compiled successfully! + +You can now view PyTC Client in the browser. + + Local: http://localhost:3000 + On Your Network: http://10.101.188.107:3000 + +Note that the development build is not optimized. +To create a production build, use npm run build. + +webpack compiled successfully diff --git a/client/src/api.js b/client/src/api.js index fffe5f2..a657d97 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -462,3 +462,32 @@ export async function getConfigPresetContent(path) { 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 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); + } +} diff --git a/client/src/components/RuntimeLogPanel.js b/client/src/components/RuntimeLogPanel.js index 9a938a7..1ce7745 100644 --- a/client/src/components/RuntimeLogPanel.js +++ b/client/src/components/RuntimeLogPanel.js @@ -117,7 +117,7 @@ function RuntimeLogPanel({ title, runtime, onRefresh }) { background: "#fff", border: "1px solid #f0f0f0", fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", fontSize: 12, color: "#262626", wordBreak: "break-word", @@ -142,7 +142,7 @@ function RuntimeLogPanel({ title, runtime, onRefresh }) { whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", }} > {text || "No runtime logs captured yet."} diff --git a/client/src/components/YamlFileUploader.js b/client/src/components/YamlFileUploader.js index e7a33c5..8fec2db 100644 --- a/client/src/components/YamlFileUploader.js +++ b/client/src/components/YamlFileUploader.js @@ -490,7 +490,11 @@ const YamlFileUploader = (props) => { {yamlContent ? ( {sliderData.map((param, index) => { - const sliderValue = getSliderValue(currentYamlData, type, param.key); + const sliderValue = getSliderValue( + currentYamlData, + type, + param.key, + ); const sliderSupported = isSliderSupported( currentYamlData, type, @@ -507,7 +511,11 @@ const YamlFileUploader = (props) => { min={param.min} max={param.max} marks={param.marks} - value={typeof sliderValue === "number" ? sliderValue : param.value} + value={ + typeof sliderValue === "number" + ? sliderValue + : param.value + } disabled={!sliderSupported} onChange={(newValue) => handleSliderChange(param.key, newValue) diff --git a/client/src/configSchema.js b/client/src/configSchema.js index 23e6ca8..f1473e0 100644 --- a/client/src/configSchema.js +++ b/client/src/configSchema.js @@ -135,14 +135,26 @@ export function setTrainingOutputPath(configObj, outputPath) { } const checkpointsPath = joinPath(outputPath, "checkpoints"); if (hasPath(configObj, ["train", "monitor", "checkpoint", "dirpath"])) { - setPathValue(configObj, ["train", "monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["train", "monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); return; } if (hasPath(configObj, ["monitor", "checkpoint", "dirpath"])) { - setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); return; } - setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); } export function setInferenceOutputPath(configObj, outputPath) { @@ -152,7 +164,11 @@ export function setInferenceOutputPath(configObj, outputPath) { setPathValue(configObj, ["INFERENCE", "OUTPUT_PATH"], outputPath); return; } - setPathValue(configObj, ["inference", "save_prediction", "output_path"], outputPath); + setPathValue( + configObj, + ["inference", "save_prediction", "output_path"], + outputPath, + ); } export function setInferenceExecutionDefaults(configObj) { @@ -212,16 +228,14 @@ export function applyInputPaths( } if (mode === "training") { - const imagePath = - pickFirstExistingPath(configObj, [ - ["train", "data", "train", "image"], - ["data", "train", "image"], - ]) || ["train", "data", "train", "image"]; - const labelPath = - pickFirstExistingPath(configObj, [ - ["train", "data", "train", "label"], - ["data", "train", "label"], - ]) || ["train", "data", "train", "label"]; + const imagePath = pickFirstExistingPath(configObj, [ + ["train", "data", "train", "image"], + ["data", "train", "image"], + ]) || ["train", "data", "train", "image"]; + const labelPath = pickFirstExistingPath(configObj, [ + ["train", "data", "train", "label"], + ["data", "train", "label"], + ]) || ["train", "data", "train", "label"]; setPathValue(configObj, imagePath, inputImagePath); setPathValue(configObj, labelPath, inputLabelPath); if (outputPath) { @@ -230,16 +244,14 @@ export function applyInputPaths( return; } - const imagePath = - pickFirstExistingPath(configObj, [ - ["test", "data", "test", "image"], - ["data", "test", "image"], - ]) || ["test", "data", "test", "image"]; - const labelPath = - pickFirstExistingPath(configObj, [ - ["test", "data", "test", "label"], - ["data", "test", "label"], - ]) || ["test", "data", "test", "label"]; + const imagePath = pickFirstExistingPath(configObj, [ + ["test", "data", "test", "image"], + ["data", "test", "image"], + ]) || ["test", "data", "test", "image"]; + const labelPath = pickFirstExistingPath(configObj, [ + ["test", "data", "test", "label"], + ["data", "test", "label"], + ]) || ["test", "data", "test", "label"]; setPathValue(configObj, imagePath, inputImagePath); if (hasLabelPath) { setPathValue(configObj, labelPath, inputLabelPath); diff --git a/client/src/contexts/ProjectManagerContext.js b/client/src/contexts/ProjectManagerContext.js new file mode 100644 index 0000000..291ec72 --- /dev/null +++ b/client/src/contexts/ProjectManagerContext.js @@ -0,0 +1,287 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, +} from "react"; +import { message, Spin } from "antd"; +import { getPMData, savePMData, resetPMData } from "../api"; +import { apiClient } from "../api"; + +// ── 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 [loading, setLoading] = useState(true); + 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"); + } + } + }, []); + + // ── Fetch on mount (always load PM config/seeds) ────────────────────────── + useEffect(() => { + let cancelled = false; + setLoading(true); + getPMData() + .then((data) => { + if (!cancelled) setPmState(data); + }) + .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); + }); + 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); + message.success("Reset to default data."); + } catch (err) { + console.error("[PM] Reset failed:", err); + message.error("Failed to reset data on server."); + } finally { + setLoading(false); + } + }, []); + + // ── Volume helpers ─────────────────────────────────────────────────────── + const getVolumes = useCallback(async (params = {}) => { + try { + const res = await apiClient.get("/api/pm/volumes", { params }); + return res.data; + } catch (err) { + message.error("Failed to load volumes from server."); + throw err; + } + }, []); + + const updateVolumeStatus = useCallback(async (volumeId, status) => { + try { + const res = await apiClient.patch(`/api/pm/volumes/${volumeId}`, { + status, + }); + if (res.data?.global_progress) { + setPmState((prev) => + prev ? { ...prev, global_progress: res.data.global_progress } : prev, + ); + } + return res.data; + } catch (err) { + message.error("Failed to update volume status."); + 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/views/ModelInference.js b/client/src/views/ModelInference.js index 8ea3bf4..87860a5 100644 --- a/client/src/views/ModelInference.js +++ b/client/src/views/ModelInference.js @@ -60,7 +60,10 @@ function ModelInference({ isInferring, setIsInferring }) { }); return yaml.dump(yamlData, { indent: 2 }).replace(/^\s*\n/gm, ""); } catch (error) { - console.warn("Failed to prepare inference config from current inputs:", error); + console.warn( + "Failed to prepare inference config from current inputs:", + error, + ); return inferenceConfig; } }; @@ -83,7 +86,10 @@ function ModelInference({ isInferring, setIsInferring }) { setIsInferring(false); if (status.exitCode === 0) { setInferenceStatus("Inference completed successfully! ✓"); - } else if (status.exitCode !== null && status.exitCode !== undefined) { + } else if ( + status.exitCode !== null && + status.exitCode !== undefined + ) { setInferenceStatus( `Inference finished with exit code: ${status.exitCode}`, ); @@ -130,7 +136,8 @@ function ModelInference({ isInferring, setIsInferring }) { setIsInferring(true); setInferenceStatus("Starting inference..."); - const preparedInferenceConfig = getPreparedInferenceConfig(inferenceConfig); + const preparedInferenceConfig = + getPreparedInferenceConfig(inferenceConfig); const res = await startModelInference( preparedInferenceConfig, diff --git a/client/src/views/ModelTraining.js b/client/src/views/ModelTraining.js index 312724d..63fe098 100644 --- a/client/src/views/ModelTraining.js +++ b/client/src/views/ModelTraining.js @@ -61,7 +61,9 @@ function ModelTraining() { if (status.exitCode === 0) { setTrainingStatus("Training completed successfully! ✓"); } else if (status.exitCode !== null) { - setTrainingStatus(`Training finished with exit code: ${status.exitCode}`); + setTrainingStatus( + `Training finished with exit code: ${status.exitCode}`, + ); } else if (status.phase === "failed" && status.lastError) { setTrainingStatus(`Training failed: ${status.lastError}`); } else { @@ -91,7 +93,10 @@ function ModelTraining() { }); return yaml.dump(yamlData, { indent: 2 }).replace(/^\s*\n/gm, ""); } catch (error) { - console.warn("Failed to prepare training config from current inputs:", error); + console.warn( + "Failed to prepare training config from current inputs:", + error, + ); return trainingConfig; } }; diff --git a/client/src/views/Views.js b/client/src/views/Views.js index e442adf..c9e0102 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -8,13 +8,16 @@ 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"; -import MaskProofreading from "./MaskProofreading"; +import MaskProofreading from "./mask-proofreading/MaskProofreading"; +import ProjectManager from "./project-manager/ProjectManager"; import Chatbot from "../components/Chatbot"; const { Content } = Layout; @@ -34,11 +37,18 @@ const MODULE_ITEMS = [ key: "mask-proofreading", icon: , }, + { + label: "Project Manager", + key: "project-manager", + icon: , + }, ]; function Views() { - const [current, setCurrent] = useState("files"); - const [visitedTabs, setVisitedTabs] = useState(new Set(["files"])); + const [current, setCurrent] = useState("project-manager"); + const [visitedTabs, setVisitedTabs] = useState( + new Set(["project-manager", "files"]), + ); const [isChatOpen, setIsChatOpen] = useState(false); const [chatWidth, setChatWidth] = useState(560); const isResizing = useRef(false); @@ -46,6 +56,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)); @@ -141,7 +178,10 @@ function Views() { />, )} {renderTabContent("mask-proofreading", )} - + {/* {renderTabContent("synanno", )} */} + {/* {renderTabContent("worm-error-handling", )} */} + {renderTabContent("project-manager", )} + setIsChatOpen(false)} /> - + ); } diff --git a/client/src/views/MaskProofreading.js b/client/src/views/mask-proofreading/MaskProofreading.js similarity index 78% rename from client/src/views/MaskProofreading.js rename to client/src/views/mask-proofreading/MaskProofreading.js index 1050a1b..c81e832 100644 --- a/client/src/views/MaskProofreading.js +++ b/client/src/views/mask-proofreading/MaskProofreading.js @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import EHTool from "./EHTool"; +// import EHTool from "./EHTool"; function MaskProofreading() { const [ehToolSession, setEhToolSession] = useState(null); @@ -7,7 +7,7 @@ function MaskProofreading() { return (
- prev + 1); }} - /> + /> */} +
Mask Proofreading Component (EHTool is currently disabled)
); } diff --git a/client/src/views/project-manager/AnnotationDashboard.js b/client/src/views/project-manager/AnnotationDashboard.js new file mode 100644 index 0000000..ca66a3e --- /dev/null +++ b/client/src/views/project-manager/AnnotationDashboard.js @@ -0,0 +1,768 @@ +import React, { useState, useMemo } from "react"; +import { + Card, + Statistic, + Table, + Tag, + Progress, + Button, + Space, + Typography, + Tooltip, + Row, + Col, + Divider, + Badge, +} from "antd"; +import { + CheckCircleOutlined, + ClockCircleOutlined, + StopOutlined, + MinusCircleOutlined, + WarningOutlined, + RiseOutlined, + TeamOutlined, + FileTextOutlined, + ThunderboltOutlined, + DatabaseOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; + +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +const PROJECT_START = dayjs("2025-10-01"); +const PROJECT_END = dayjs("2026-03-31"); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const STATUS_META = { + in_progress: { label: "In Progress", color: "processing" }, + done: { label: "Done", color: "success" }, + blocked: { label: "Blocked", color: "error" }, + not_started: { label: "Not Started", color: "default" }, +}; + +function statusTag(status) { + const { label, color } = STATUS_META[status] || {}; + return ; +} + +// 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 }) { + 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, + isAdmin, + 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" }, + ]; + + return ( +
+ {/* ── 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 0000000..048adf3 --- /dev/null +++ b/client/src/views/project-manager/ModelQualityDashboard.js @@ -0,0 +1,481 @@ +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..09a0b3a --- /dev/null +++ b/client/src/views/project-manager/ProjectManager.js @@ -0,0 +1,205 @@ +import React, { useState } from "react"; +import { + Layout, + Menu, + Typography, + ConfigProvider, + Space, + Button, + Tag, +} from "antd"; +import { + DashboardOutlined, + TeamOutlined, + ScheduleOutlined, + LineChartOutlined, + BugOutlined, + SettingOutlined, + DatabaseOutlined, + UserOutlined, + CrownOutlined, +} 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 { + ProjectManagerProvider, + useProjectManager, +} from "../../contexts/ProjectManagerContext"; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; + +const ProjectManagerInner = () => { + const { isAuthenticated, user, logout, isAdmin, isWorker, loading } = + useProjectManager(); + + const [selectedKey, setSelectedKey] = useState("dashboard"); + + // ── Login Gate ──────────────────────────────────────────────────────────── + if (!isAuthenticated) { + return ; + } + + // ── Menu items ──────────────────────────────────────────────────────────── + const menuItems = [ + { key: "dashboard", icon: , label: "Dashboard" }, + { + key: "progress", + icon: , + label: isAdmin ? "Team Progress" : "My Progress", + }, + { + key: "quotas", + icon: , + label: isAdmin ? "Weekly Quotas" : "My Quota", + }, + { + key: "volumes", + icon: , + label: isWorker ? "My Volumes" : "Volume Tracker", + }, + // Admin-only items + ...(isAdmin + ? [ + { + key: "model-quality", + icon: , + label: "Model Quality", + }, + ] + : []), + { type: "divider" }, + { + key: "issues", + icon: , + label: "Issues & Flags", + disabled: true, + }, + { + key: "settings", + icon: , + label: "Settings", + disabled: true, + }, + ]; + + const renderSubView = () => { + switch (selectedKey) { + case "dashboard": + return ; + case "progress": + return ; + case "quotas": + return ; + case "volumes": + return ; + case "model-quality": + return ; + default: + return ; + } + }; + + return ( + + + {/* ── Brand header ── */} +
+ + Project Manager + + + v2.0 · seg.bio + +
+ + {/* ── User Profile & Role Tag ── */} +
+
+ + + {user?.name} + + + + {isAdmin ? "ADMIN" : "WORKER"} + + + + +
+
+ + {/* ── Navigation ── */} + setSelectedKey(e.key)} + items={menuItems} + style={{ borderRight: 0, flex: 1 }} + /> + + + + {renderSubView()} + + + ); +}; + +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 0000000..aa9229d --- /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); + const success = 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 }} + /> + + + + + + + + + + Credentials Template + + + +
+ + + Admin: admin / admin123 + + + Worker: alex / alex123 + + +
+
+
+ ); +} diff --git a/client/src/views/project-manager/ProofreaderProgress.js b/client/src/views/project-manager/ProofreaderProgress.js new file mode 100644 index 0000000..8d35699 --- /dev/null +++ b/client/src/views/project-manager/ProofreaderProgress.js @@ -0,0 +1,484 @@ +import React, { useMemo } from "react"; +import { + 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, + isWorker, + } = useProjectManager(); + + // Helper to get calculated stats for a proofreader from quotaData + const getStats = (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 }; + }; + + 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); + }, [proofreaderData, quotaData]); + + 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… + + )} + +
+ + + +
+ + {/* ── 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 0000000..e3d227d --- /dev/null +++ b/client/src/views/project-manager/QuotaManagement.js @@ -0,0 +1,474 @@ +import React, { useMemo } from "react"; +import { + Card, + Table, + DatePicker, + Button, + Input, + InputNumber, + Row, + Col, + Typography, + Space, + Tag, + Divider, + Progress, + Tooltip, + message, + Popconfirm, + Spin, +} from "antd"; +import { + ScheduleOutlined, + SendOutlined, + ThunderboltOutlined, + LeftOutlined, + RightOutlined, + EditOutlined, + ReloadOutlined, + SaveOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import weekday from "dayjs/plugin/weekday"; +import localeData from "dayjs/plugin/localeData"; +import { useState } from "react"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +dayjs.extend(weekday); +dayjs.extend(localeData); + +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, + msgPreview, + setQuotaData, + setMsgPreview, + saving, + resetData, + isWorker, + activeWorker, + } = useProjectManager(); + + const [selectedWeek, setSelectedWeek] = useState(dayjs().startOf("week")); + + const handleUpdateQuota = (key, day, val) => { + const updated = quotaData.map((item) => + item.key === key ? { ...item, [day]: val } : item, + ); + setQuotaData(updated); + }; + + 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 proofreaders."); + }; + + // 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)} + 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"} + + + Plan targets and track capacity utilization · Real-time Sync + {saving && ( + <> + {" "} + · saving… + + )} + + + + + + + + + + + + + {/* ── Quota Table ── */} + + + Targets vs Actuals + + Week 10 (Current) + + + } + extra={ + + Input numbers in blue cells to update targets instantly. + + } + 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()} + +
+
+
+ + + {/* Communication Preview */} + + + Team Communication Preview + + } + extra={ + + } + > +