,
document.body );
diff --git a/frontend/src/components/Popup/Popup.scss b/frontend/src/components/Popup/Popup.scss
index 70a6ba87..28d2e4f8 100644
--- a/frontend/src/components/Popup/Popup.scss
+++ b/frontend/src/components/Popup/Popup.scss
@@ -14,6 +14,11 @@
opacity: 0;
}
+ /* Second modal (e.g. revision) above another Popup or high z-index shells */
+ .popup-overlay.popup-overlay--elevated {
+ z-index: 10050;
+ }
+
.popup-content-overlay{
background-color: white;
padding: 20px;
@@ -33,7 +38,11 @@
max-width: 1000px;
width:95%;
-
+
+ }
+ &.-domain{
+ max-width: 1200px;
+ width: 95%;
}
}
@@ -97,6 +106,11 @@
}
+ &.wider-content{
+ max-width: 1400px;
+ width: 95%;
+ }
+
&.oie{
max-height:700px;
}
@@ -104,6 +118,27 @@
max-width: 600px;
}
}
+ .popup-content-new {
+ background-color: var(--background);
+ border-radius: 25px;
+ position: absolute;
+ max-width:100%;
+ box-sizing: border-box;
+ display:flex;
+ flex-direction: column;
+ overflow:hidden;
+ max-height: 90vh;
+ z-index: 1000;
+ padding:5px;
+ border: 1px solid var(--border);
+ .popup-content-inner{
+ border:1px solid var(--border);
+ border-radius: 20px;
+ box-sizing: border-box;
+ overflow: hidden;
+ }
+
+ }
.popup-overlay .popup-content.no-styling {
background-color: transparent;
@@ -131,7 +166,8 @@
.popup-overlay .popup-content.no-padding {
padding: 0;
}
- .popup-content .close-popup{
+ .popup-content .close-popup,
+ .popup-content-new .close-popup{
position: absolute;
width:15px;
top:15px;
@@ -140,6 +176,7 @@
right:15px;
z-index: 100;
cursor: pointer;
+
}
.close-popup.popout{
diff --git a/frontend/src/components/ProfilePopup/ProfilePopup.jsx b/frontend/src/components/ProfilePopup/ProfilePopup.jsx
index b4049f4b..4d612254 100644
--- a/frontend/src/components/ProfilePopup/ProfilePopup.jsx
+++ b/frontend/src/components/ProfilePopup/ProfilePopup.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef, useEffect } from 'react';
+import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Icon } from '@iconify-icon/react';
import { Squircle } from '@squircle-js/react';
@@ -44,6 +44,56 @@ function ProfilePopup({
setShowPopup(false);
}, ["profile"]);
+ /** Domains linked to active stakeholder memberships (from validate-token + legacy user fields). */
+ const domainDashboardLinks = useMemo(() => {
+ const fromToken = (user?.stakeholderDomainDashboards || [])
+ .map((row) => ({
+ domainId: String(row.domainId || row._id || '').trim(),
+ domainName: row.domainName || null
+ }))
+ .filter((row) => row.domainId);
+
+ if (fromToken.length) {
+ const seen = new Set();
+ return fromToken.filter((row) => {
+ if (seen.has(row.domainId)) return false;
+ seen.add(row.domainId);
+ return true;
+ });
+ }
+
+ const sources = [
+ user?.stakeholderAssignments,
+ user?.stakeholderRoles,
+ user?.domainStakeholderRoles,
+ user?.assignedStakeholderRoles
+ ];
+
+ const items = sources.flatMap((source) => (Array.isArray(source) ? source : []));
+ if (!items.length) return [];
+
+ const normalized = items.map((assignment) => {
+ const rawDomain = assignment?.domainId || assignment?.domain || assignment?.domain_id;
+ const domainId = typeof rawDomain === 'string'
+ ? rawDomain
+ : rawDomain?._id || rawDomain?.id || null;
+
+ if (!domainId) return null;
+
+ return {
+ domainId: String(domainId),
+ domainName: assignment?.domainName || assignment?.domain?.name || assignment?.domain_id?.name || null
+ };
+ }).filter(Boolean);
+
+ const seen = new Set();
+ return normalized.filter((item) => {
+ if (seen.has(item.domainId)) return false;
+ seen.add(item.domainId);
+ return true;
+ });
+ }, [user]);
+
// Safety check - don't render if user is not loaded
if (!user) {
return null;
@@ -129,7 +179,7 @@ function ProfilePopup({
>
}
{
- user && user.roles && user.approvalRoles.includes('root') &&
+ user && user.roles && (user.approvalRoles || []).includes('root') &&
<>
@@ -159,29 +209,26 @@ function ProfilePopup({
)}
>
}
- {
- user && user.approvalRoles && user.approvalRoles.length > 0 &&
+ {user && domainDashboardLinks.length > 0 && (
<>
-
-
APPROVALS
- {user.approvalRoles.map(
- (role) => {
- const url = role === 'root' ? '/root-dashboard' : `/approval-dashboard/${role}`
- if(role === 'root'){
- return null;
- }
- return(
-
-
-
- )
- }
- )}
+
+
DOMAIN DASHBOARDS
+ {domainDashboardLinks.map((assignment) => (
+
+
+
+
+ {assignment.domainName || 'Domain'}
+ Stakeholder workspace
+
+
+
+ ))}
>
- }
+ )}
diff --git a/frontend/src/components/ProfilePopup/ProfilePopup.scss b/frontend/src/components/ProfilePopup/ProfilePopup.scss
index 2f11cff8..ae114cf9 100644
--- a/frontend/src/components/ProfilePopup/ProfilePopup.scss
+++ b/frontend/src/components/ProfilePopup/ProfilePopup.scss
@@ -175,6 +175,19 @@
font-weight: 600;
color: var(--text);
font-size: 13.5px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ line-height: 1.25;
+ }
+
+ .menu-item-sub {
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--lighter-text);
+ text-transform: none;
+ letter-spacing: 0.01em;
}
&:hover {
diff --git a/frontend/src/components/TaskBoard/SharedTaskBoard.jsx b/frontend/src/components/TaskBoard/SharedTaskBoard.jsx
new file mode 100644
index 00000000..5aa98237
--- /dev/null
+++ b/frontend/src/components/TaskBoard/SharedTaskBoard.jsx
@@ -0,0 +1,535 @@
+import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { motion } from 'framer-motion';
+import './SharedTaskBoard.scss';
+
+function copyComputedStyles(fromNode, toNode) {
+ if (!fromNode || !toNode) return;
+ const computed = window.getComputedStyle(fromNode);
+ for (let i = 0; i < computed.length; i += 1) {
+ const key = computed[i];
+ toNode.style.setProperty(key, computed.getPropertyValue(key), computed.getPropertyPriority(key));
+ }
+ const fromChildren = Array.from(fromNode.children || []);
+ const toChildren = Array.from(toNode.children || []);
+ for (let i = 0; i < fromChildren.length; i += 1) {
+ copyComputedStyles(fromChildren[i], toChildren[i]);
+ }
+}
+
+function SharedTaskBoard({
+ viewMode,
+ tasks,
+ statuses,
+ groupedByStatus,
+ getTaskId,
+ getStatusLabel,
+ getTaskStatus,
+ onDropToStatus,
+ renderListItem,
+ renderKanbanCard,
+ renderEmptyList,
+ listClassName,
+ listTag,
+ kanbanClassName,
+ columnClassName,
+ columnDropTargetClassName,
+ cardsClassName,
+ emptyColumnClassName,
+ dragPreviewClassName,
+ draggingShellClassName,
+ onDragStartTask,
+ onDropTask,
+ onCommitColumnOrder
+}) {
+ const joinClasses = (...names) => names.filter(Boolean).join(' ');
+ const [draggingTaskId, setDraggingTaskId] = useState(null);
+ const [dropTargetStatus, setDropTargetStatus] = useState(null);
+ const [dropPosition, setDropPosition] = useState(null);
+ const [flashByTaskId, setFlashByTaskId] = useState({});
+ const columnCardsRefs = useRef(new Map());
+ const committedOrderRef = useRef(null);
+ const didDropRef = useRef(false);
+ const dragStartRef = useRef(null);
+ const flashTimeoutsRef = useRef(new Map());
+
+ const safeTasks = useMemo(() => tasks || [], [tasks]);
+ const safeStatuses = useMemo(() => statuses || [], [statuses]);
+ const kanbanStyle = useMemo(
+ () => ({ '--task-board-columns': String(Math.max(1, safeStatuses.length || 1)) }),
+ [safeStatuses.length]
+ );
+ const taskById = useMemo(() => {
+ const map = new Map();
+ safeTasks.forEach((task) => {
+ const id = String(getTaskId(task));
+ if (id) map.set(id, task);
+ });
+ return map;
+ }, [safeTasks, getTaskId]);
+ const [orderedIdsByStatus, setOrderedIdsByStatus] = useState(() => {
+ const next = {};
+ safeStatuses.forEach((status) => {
+ next[status] = (groupedByStatus?.[status] || []).map((task) => String(getTaskId(task)));
+ });
+ return next;
+ });
+
+ const moveTaskInGroups = useCallback((groups, taskId, nextStatus, nextIndex) => {
+ const targetTaskId = String(taskId);
+ if (!targetTaskId) return groups;
+ const next = {};
+ safeStatuses.forEach((status) => {
+ next[status] = (groups?.[status] || []).filter((id) => id !== targetTaskId);
+ });
+ if (!next[nextStatus]) next[nextStatus] = [];
+ const insertAt = Math.max(0, Math.min(Number(nextIndex) || 0, next[nextStatus].length));
+ next[nextStatus] = [
+ ...next[nextStatus].slice(0, insertAt),
+ targetTaskId,
+ ...next[nextStatus].slice(insertAt)
+ ];
+ return next;
+ }, [safeStatuses]);
+
+ useLayoutEffect(() => {
+ setOrderedIdsByStatus((previous) => {
+ const next = {};
+ safeStatuses.forEach((status) => {
+ const incomingIds = (groupedByStatus?.[status] || [])
+ .map((task) => String(getTaskId(task)))
+ .filter(Boolean);
+ const incomingSet = new Set(incomingIds);
+ const current = (previous?.[status] || []).filter((id) => incomingSet.has(id));
+ const missing = incomingIds.filter((id) => !current.includes(id));
+ next[status] = [...current, ...missing];
+ });
+ return next;
+ });
+ }, [groupedByStatus, getTaskId, safeStatuses]);
+
+ const orderedIdsRef = useRef(orderedIdsByStatus);
+ useLayoutEffect(() => {
+ orderedIdsRef.current = orderedIdsByStatus;
+ }, [orderedIdsByStatus]);
+
+ const setColumnCardsRef = useCallback((status, node) => {
+ const key = String(status);
+ if (!node) {
+ columnCardsRefs.current.delete(key);
+ return;
+ }
+ columnCardsRefs.current.set(key, node);
+ }, []);
+
+ const triggerMoveFlash = useCallback((taskId) => {
+ const key = String(taskId || '');
+ if (!key) return;
+ const existing = flashTimeoutsRef.current.get(key);
+ if (existing) {
+ window.clearTimeout(existing);
+ }
+ setFlashByTaskId((prev) => ({ ...prev, [key]: true }));
+ const timeoutId = window.setTimeout(() => {
+ setFlashByTaskId((prev) => {
+ if (!prev[key]) return prev;
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ flashTimeoutsRef.current.delete(key);
+ }, 700);
+ flashTimeoutsRef.current.set(key, timeoutId);
+ }, []);
+
+ useLayoutEffect(() => () => {
+ flashTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
+ flashTimeoutsRef.current.clear();
+ }, []);
+
+ useLayoutEffect(() => {
+ if (viewMode !== 'kanban' && viewMode !== 'list') return;
+ safeStatuses.forEach((status) => {
+ const node = columnCardsRefs.current.get(String(status));
+ if (!node?.isConnected) return;
+ const previousHeight = Number(node.dataset.prevHeight || 0);
+ const nextHeight = node.scrollHeight;
+ if (previousHeight > 0 && Math.abs(previousHeight - nextHeight) > 2) {
+ node.style.height = `${previousHeight}px`;
+ node.style.overflow = 'hidden';
+ node.style.transition = 'height 260ms cubic-bezier(0.2, 0.7, 0.2, 1)';
+ void node.offsetHeight;
+ node.style.height = `${nextHeight}px`;
+ const cleanup = () => {
+ node.style.height = '';
+ node.style.overflow = '';
+ node.style.transition = '';
+ node.removeEventListener('transitionend', cleanup);
+ };
+ node.addEventListener('transitionend', cleanup);
+ }
+ node.dataset.prevHeight = String(nextHeight);
+ });
+ }, [viewMode, groupedByStatus, safeStatuses]);
+
+ const resolveDropIndex = useCallback((status, clientY) => {
+ const container = columnCardsRefs.current.get(String(status));
+ if (!container) return (orderedIdsByStatus?.[status] || []).length;
+ const cardNodes = Array.from(container.querySelectorAll('[data-task-id]')).filter(
+ (node) => String(node.dataset.taskId || '') !== String(draggingTaskId || '')
+ );
+ if (!cardNodes.length) return 0;
+ const firstRect = cardNodes[0].getBoundingClientRect();
+ const lastRect = cardNodes[cardNodes.length - 1].getBoundingClientRect();
+ if (clientY <= firstRect.top) return 0;
+ if (clientY >= lastRect.bottom) return cardNodes.length;
+
+ // Cursor-anchored insertion: determine before/after the card currently under pointer.
+ const hoveredCard = cardNodes.find((node) => {
+ const rect = node.getBoundingClientRect();
+ return clientY >= rect.top && clientY <= rect.bottom;
+ });
+ if (hoveredCard) {
+ const hoveredRect = hoveredCard.getBoundingClientRect();
+ const hoveredId = String(hoveredCard.dataset.taskId || '');
+ const hoveredIndex = (orderedIdsByStatus?.[status] || []).indexOf(hoveredId);
+ if (hoveredIndex < 0) return 0;
+ const localY = clientY - hoveredRect.top;
+ const beforeThreshold = hoveredRect.height * 0.35;
+ const afterThreshold = hoveredRect.height * 0.65;
+ if (localY <= beforeThreshold) return hoveredIndex;
+ if (localY >= afterThreshold) return hoveredIndex + 1;
+ return dropPosition?.status === status ? dropPosition.index : hoveredIndex + 1;
+ }
+
+ for (let i = 0; i < cardNodes.length - 1; i += 1) {
+ const a = cardNodes[i].getBoundingClientRect();
+ const b = cardNodes[i + 1].getBoundingClientRect();
+ if (clientY > a.bottom && clientY < b.top) {
+ return i + 1;
+ }
+ }
+ return cardNodes.length;
+ }, [orderedIdsByStatus, draggingTaskId, dropPosition]);
+
+ const handleColumnDragOver = useCallback(
+ (status, event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ if (dropTargetStatus !== status) {
+ setDropTargetStatus(status);
+ }
+ if (draggingTaskId) {
+ const nextIndex = resolveDropIndex(status, event.clientY);
+ const current = dropPosition;
+ if (!current || current.status !== status || current.index !== nextIndex) {
+ setDropPosition({ status, index: nextIndex });
+ setOrderedIdsByStatus((previous) => moveTaskInGroups(previous, draggingTaskId, status, nextIndex));
+ }
+ }
+ },
+ [dropTargetStatus, draggingTaskId, dropPosition, resolveDropIndex, moveTaskInGroups]
+ );
+
+ const handleColumnDragLeave = useCallback((status, event) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) {
+ setDropTargetStatus((prev) => (prev === status ? null : prev));
+ }
+ }, []);
+
+ const handleColumnDrop = useCallback(
+ async (status, event) => {
+ event.preventDefault();
+ didDropRef.current = true;
+ const dropId = event.dataTransfer.getData('text/plain') || String(draggingTaskId || '');
+ const droppedTask = safeTasks.find((task) => String(getTaskId(task)) === String(dropId));
+ const finalIndex = resolveDropIndex(status, event.clientY);
+ const dragStart = dragStartRef.current;
+ const movedBetweenColumns = Boolean(dragStart && dragStart.status !== status);
+ const movedWithinColumn = Boolean(
+ dragStart && dragStart.status === status && dragStart.index !== finalIndex
+ );
+ const snap = orderedIdsRef.current;
+
+ onDropTask?.({
+ taskId: String(dropId || ''),
+ task: droppedTask,
+ sourceStatus: droppedTask ? getTaskStatus?.(droppedTask) : null,
+ targetStatus: status
+ });
+
+ try {
+ if (droppedTask && getTaskStatus?.(droppedTask) !== status) {
+ await onDropToStatus?.(droppedTask, status);
+ }
+ if (onCommitColumnOrder && (movedBetweenColumns || movedWithinColumn)) {
+ if (movedBetweenColumns && dragStart) {
+ const tgtKey = String(status);
+ const srcKey = String(dragStart.status);
+ await onCommitColumnOrder({ columnKey: tgtKey, taskIds: [...(snap[tgtKey] || [])] });
+ await onCommitColumnOrder({ columnKey: srcKey, taskIds: [...(snap[srcKey] || [])] });
+ } else if (movedWithinColumn) {
+ await onCommitColumnOrder({
+ columnKey: String(status),
+ taskIds: [...(snap[String(status)] || [])]
+ });
+ }
+ }
+ } catch (_err) {
+ if (committedOrderRef.current) {
+ setOrderedIdsByStatus(committedOrderRef.current);
+ }
+ }
+
+ if (droppedTask && (movedBetweenColumns || movedWithinColumn)) {
+ triggerMoveFlash(getTaskId(droppedTask));
+ }
+ setDraggingTaskId(null);
+ setDropTargetStatus(null);
+ setDropPosition(null);
+ dragStartRef.current = null;
+ },
+ [
+ draggingTaskId,
+ safeTasks,
+ getTaskId,
+ getTaskStatus,
+ resolveDropIndex,
+ onDropTask,
+ onDropToStatus,
+ onCommitColumnOrder,
+ triggerMoveFlash
+ ]
+ );
+
+ const startCardDrag = useCallback(
+ (task, status, event) => {
+ committedOrderRef.current = orderedIdsByStatus;
+ didDropRef.current = false;
+ const cardNode = event.currentTarget.firstElementChild || event.currentTarget;
+ const cardRect = cardNode.getBoundingClientRect();
+ const dragImageNode = cardNode.cloneNode(true);
+ copyComputedStyles(cardNode, dragImageNode);
+ dragImageNode.style.position = 'fixed';
+ dragImageNode.style.left = '-9999px';
+ dragImageNode.style.top = '-9999px';
+ dragImageNode.style.width = `${Math.max(220, Math.round(cardRect.width))}px`;
+ dragImageNode.style.pointerEvents = 'none';
+ dragImageNode.style.opacity = '0.96';
+ dragImageNode.style.transform = 'none';
+ dragImageNode.style.transition = 'none';
+ document.body.appendChild(dragImageNode);
+ const pointerOffsetX = Math.max(
+ 0,
+ Math.min(Math.round(event.clientX - cardRect.left), Math.round(cardRect.width))
+ );
+ const pointerOffsetY = Math.max(
+ 0,
+ Math.min(Math.round(event.clientY - cardRect.top), Math.round(cardRect.height))
+ );
+ event.dataTransfer.setDragImage(dragImageNode, pointerOffsetX, pointerOffsetY);
+ requestAnimationFrame(() => {
+ if (dragImageNode.parentNode) {
+ dragImageNode.parentNode.removeChild(dragImageNode);
+ }
+ });
+ const taskId = getTaskId(task);
+ event.dataTransfer.setData('text/plain', String(taskId));
+ event.dataTransfer.effectAllowed = 'move';
+ setDraggingTaskId(taskId);
+ const startingIndex = (orderedIdsByStatus?.[status] || []).indexOf(String(taskId));
+ setDropPosition({ status, index: startingIndex });
+ dragStartRef.current = { status, index: startingIndex };
+ onDragStartTask?.(task);
+ },
+ [orderedIdsByStatus, getTaskId, onDragStartTask]
+ );
+
+ const endCardDrag = useCallback(() => {
+ if (!didDropRef.current && committedOrderRef.current) {
+ setOrderedIdsByStatus(committedOrderRef.current);
+ }
+ setDraggingTaskId(null);
+ setDropTargetStatus(null);
+ setDropPosition(null);
+ dragStartRef.current = null;
+ }, []);
+
+ if (viewMode === 'list') {
+ if (!safeTasks.length) {
+ return renderEmptyList ? renderEmptyList() : null;
+ }
+ const ListTag = listTag || 'div';
+ const draggingTask = safeTasks.find((task) => String(getTaskId(task)) === String(draggingTaskId));
+ const draggingSourceStatus = draggingTask ? getTaskStatus?.(draggingTask) : null;
+
+ return (
+
+ {safeStatuses.map((status) => {
+ const statusTasks = (orderedIdsByStatus?.[status] || [])
+ .map((id) => taskById.get(String(id)))
+ .filter(Boolean);
+ const isDropTarget = dropTargetStatus === status;
+ return (
+ handleColumnDragOver(status, e)}
+ onDragLeave={(e) => handleColumnDragLeave(status, e)}
+ onDrop={(e) => handleColumnDrop(status, e)}
+ >
+
+ {getStatusLabel(status)}
+ {statusTasks.length}
+
+ setColumnCardsRef(status, node)}
+ >
+ {isDropTarget && draggingTaskId && draggingSourceStatus !== status && (
+
+
+ Drop to move here
+
+
+ )}
+ {statusTasks.length === 0 && (
+
+
+ No tasks
+
+
+ )}
+ {statusTasks.map((task) => {
+ const taskId = getTaskId(task);
+ const isDragging = String(draggingTaskId) === String(taskId);
+ return (
+
+ startCardDrag(task, status, e)}
+ onDragEnd={endCardDrag}
+ >
+ {renderListItem(task, {
+ isDragging,
+ isMoved: Boolean(flashByTaskId[String(taskId)])
+ })}
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+ }
+
+ if (viewMode !== 'kanban') return null;
+
+ const draggingTask = safeTasks.find((task) => String(getTaskId(task)) === String(draggingTaskId));
+ const draggingSourceStatus = draggingTask ? getTaskStatus?.(draggingTask) : null;
+
+ return (
+
+ {safeStatuses.map((status) => {
+ const statusTasks = (orderedIdsByStatus?.[status] || [])
+ .map((id) => taskById.get(String(id)))
+ .filter(Boolean);
+ const isDropTarget = dropTargetStatus === status;
+ return (
+
handleColumnDragOver(status, e)}
+ onDragLeave={(e) => handleColumnDragLeave(status, e)}
+ onDrop={(e) => handleColumnDrop(status, e)}
+ >
+
+ {getStatusLabel(status)}
+ {statusTasks.length}
+
+ setColumnCardsRef(status, node)}
+ >
+ {isDropTarget && draggingTaskId && draggingSourceStatus !== status && (
+
+ Drop to move here
+
+ )}
+ {statusTasks.length === 0 && (
+
No tasks
+ )}
+ {statusTasks.map((task) => {
+ const taskId = getTaskId(task);
+ const isDragging = String(draggingTaskId) === String(taskId);
+ return (
+
startCardDrag(task, status, e)}
+ onDragEnd={endCardDrag}
+ >
+ {renderKanbanCard(task, {
+ isDragging,
+ isMoved: Boolean(flashByTaskId[String(taskId)])
+ })}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
+
+SharedTaskBoard.defaultProps = {
+ statuses: [],
+ tasks: [],
+ groupedByStatus: {},
+ listClassName: '',
+ listTag: 'div',
+ kanbanClassName: '',
+ columnClassName: '',
+ columnDropTargetClassName: 'drop-target',
+ cardsClassName: '',
+ emptyColumnClassName: '',
+ dragPreviewClassName: '',
+ draggingShellClassName: 'dragging-origin',
+ getTaskStatus: null,
+ onDropToStatus: null,
+ onDragStartTask: null,
+ onDropTask: null,
+ onCommitColumnOrder: null,
+ renderEmptyList: null
+};
+
+export default SharedTaskBoard;
diff --git a/frontend/src/components/TaskBoard/SharedTaskBoard.scss b/frontend/src/components/TaskBoard/SharedTaskBoard.scss
new file mode 100644
index 00000000..98e02bd1
--- /dev/null
+++ b/frontend/src/components/TaskBoard/SharedTaskBoard.scss
@@ -0,0 +1,207 @@
+.shared-task-board {
+ --task-board-columns: 4;
+ --task-board-min-column-width: 280px;
+ --task-board-gap: 0.5rem;
+ --task-board-height: clamp(420px, 68vh, 920px);
+ --task-board-card-gap: 0.42rem;
+ --task-board-transition: 220ms ease;
+ --task-board-column-border: var(--lighterborder);
+ --task-board-column-bg: var(--lightbackground);
+ --task-board-muted: var(--light-text);
+ --task-board-token-bg: var(--background);
+ --task-board-drop-border: rgba(77, 170, 87, 0.5);
+ --task-board-drop-bg: rgba(77, 170, 87, 0.06);
+ --task-board-drop-shadow: inset 0 0 0 1px rgba(77, 170, 87, 0.25);
+ --task-board-ease: cubic-bezier(0.2, 0.7, 0.2, 1);
+}
+
+.shared-task-board__list-grouped {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.shared-task-board__list-section {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ min-width: 0;
+ border: 1px solid var(--task-board-column-border);
+ border-radius: 10px;
+ background-color: var(--task-board-column-bg);
+ padding: 0.55rem 0.6rem 0.6rem;
+ transition:
+ border-color var(--task-board-transition) var(--task-board-ease),
+ box-shadow var(--task-board-transition) var(--task-board-ease),
+ background-color var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__list-section.drop-target {
+ border-color: var(--task-board-drop-border);
+ background-color: var(--task-board-drop-bg);
+ box-shadow: var(--task-board-drop-shadow);
+}
+
+.shared-task-board__list-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.42rem;
+}
+
+.shared-task-board__list-section-header h4 {
+ margin: 0;
+ font-size: 0.76rem;
+ color: var(--task-board-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.shared-task-board__list-section-header span {
+ border: 1px solid var(--task-board-column-border);
+ border-radius: 999px;
+ padding: 0.08rem 0.42rem;
+ font-size: 0.72rem;
+ color: var(--task-board-muted);
+ background-color: var(--task-board-token-bg);
+}
+
+.shared-task-board__list-section-items {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--task-board-card-gap);
+ min-height: 0;
+}
+
+.shared-task-board__list-item-wrap,
+.shared-task-board__list-drop-preview-wrap,
+.shared-task-board__list-empty-wrap {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.shared-task-board__kanban {
+ display: grid;
+ grid-template-columns: repeat(var(--task-board-columns), minmax(var(--task-board-min-column-width), 1fr));
+ gap: var(--task-board-gap);
+ align-items: stretch;
+ min-height: 0;
+ height: var(--task-board-height);
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-bottom: 0.12rem;
+}
+
+.shared-task-board__column {
+ display: flex;
+ box-sizing: border-box;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ border: 1px solid var(--task-board-column-border);
+ border-radius: 10px;
+ background-color: var(--task-board-column-bg);
+ padding: 0.55rem;
+ transition: border-color var(--task-board-transition) var(--task-board-ease), box-shadow var(--task-board-transition) var(--task-board-ease), background-color var(--task-board-transition) var(--task-board-ease), color var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__column.drop-target {
+ border-color: var(--task-board-drop-border);
+ background-color: var(--task-board-drop-bg);
+ box-shadow: var(--task-board-drop-shadow);
+}
+
+.shared-task-board__column > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.45rem;
+}
+
+.shared-task-board__column > header h4 {
+ margin: 0;
+ font-size: 0.76rem;
+ color: var(--task-board-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ transition: color var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__column > header span {
+ border: 1px solid var(--task-board-column-border);
+ border-radius: 999px;
+ padding: 0.08rem 0.42rem;
+ font-size: 0.72rem;
+ color: var(--task-board-muted);
+ background-color: var(--task-board-token-bg);
+ transition: border-color var(--task-board-transition) var(--task-board-ease), background-color var(--task-board-transition) var(--task-board-ease), color var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__cards {
+ display: flex;
+ flex-direction: column;
+ gap: var(--task-board-card-gap);
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 0.12rem;
+}
+
+.shared-task-board__drop-preview {
+ border: 1px dashed var(--task-board-drop-border);
+ border-radius: 8px;
+ background-color: rgba(77, 170, 87, 0.09);
+ color: #2f8a41;
+ text-align: center;
+ font-size: 0.74rem;
+ padding: 0.42rem 0.5rem;
+ animation: sharedTaskBoardDropFadeIn 140ms ease;
+ transition: border-color var(--task-board-transition) var(--task-board-ease), background-color var(--task-board-transition) var(--task-board-ease), color var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__drag-shell {
+ transition: opacity var(--task-board-transition) var(--task-board-ease);
+}
+
+.shared-task-board__drag-shell.dragging-origin {
+ border-radius: 8px;
+ border: 1px dashed rgba(108, 117, 125, 0.3);
+ background: transparent;
+ min-height: 76px;
+}
+
+.shared-task-board__drag-shell.dragging-origin > * {
+ visibility: hidden;
+}
+
+.shared-task-board__empty {
+ margin: 0;
+ border: 1px dashed var(--task-board-column-border);
+ border-radius: 8px;
+ padding: 0.48rem 0.38rem;
+ color: var(--task-board-muted);
+ font-size: 0.75rem;
+ text-align: center;
+ transition: border-color var(--task-board-transition) var(--task-board-ease), color var(--task-board-transition) var(--task-board-ease);
+}
+
+@keyframes sharedTaskBoardDropFadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@media (max-width: 760px) {
+ .shared-task-board {
+ --task-board-min-column-width: 240px;
+ }
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskCardShared.scss b/frontend/src/components/TaskBoard/cards/EventTasksTaskCardShared.scss
new file mode 100644
index 00000000..9ced43dd
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskCardShared.scss
@@ -0,0 +1,126 @@
+.event-tasks-task-card__description {
+ margin: 0;
+ color: var(--task-muted);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ word-break: break-word;
+}
+
+.event-tasks-task-card__description--lines-2 {
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+}
+
+.event-tasks-task-card__description--lines-3 {
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+}
+
+.event-tasks-task-card__badges {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.event-tasks-task-card__status-pill,
+.event-tasks-task-card__priority-pill,
+.event-tasks-task-card__badge--critical,
+.event-tasks-task-card__badge--overdue {
+ border-radius: 4px;
+ border: none;
+ padding: 0.14rem 0.34rem;
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.event-tasks-task-card__status-pill {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.05);
+
+ &.todo {
+ color: #5c656e;
+ }
+
+ &.in_progress {
+ color: #2456a6;
+ background: rgba(46, 109, 200, 0.12);
+ }
+
+ &.blocked {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+
+ &.done {
+ color: #2f6b3c;
+ background: rgba(77, 170, 87, 0.14);
+ }
+}
+
+.event-tasks-task-card__priority-pill {
+ &.low {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.045);
+ }
+
+ &.medium {
+ color: #2456a6;
+ background: rgba(46, 109, 200, 0.12);
+ }
+
+ &.high {
+ color: #8f5218;
+ background: rgba(218, 143, 63, 0.14);
+ }
+
+ &.critical {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+}
+
+.event-tasks-task-card__badge--critical,
+.event-tasks-task-card__badge--overdue {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+}
+
+.event-tasks-task-card__actions {
+ display: inline-flex;
+ gap: 0.15rem;
+ flex-wrap: wrap;
+}
+
+.event-tasks-task-card__actions button {
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--task-muted);
+ padding: 0.32rem 0.45rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 120ms ease, background 120ms ease;
+
+ &:hover:not(:disabled) {
+ color: var(--text);
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+
+ &.event-tasks-task-card__btn--danger {
+ color: #c24f5d;
+
+ &:hover:not(:disabled) {
+ background: rgba(220, 53, 69, 0.08);
+ }
+ }
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.jsx b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.jsx
new file mode 100644
index 00000000..56f1067b
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import TaskCardAssigneePicker from '../../TaskWorkspace/TaskCardAssigneePicker';
+import { descriptionToPreviewPlain } from '../../TaskWorkspace/taskWorkspaceUtils';
+import { EventTasksPriorityPill } from './EventTasksTaskPills';
+import './EventTasksTaskCardShared.scss';
+import './EventTasksTaskKanbanCard.scss';
+
+function formatKanbanDue(dueAt) {
+ if (!dueAt) return 'none';
+ return new Date(dueAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+export default function EventTasksTaskKanbanCard({
+ task,
+ isDragging,
+ isMoved,
+ onOpenDetail,
+ members,
+ assigningTaskId,
+ onAssigneeChange
+}) {
+ return (
+
onOpenDetail(task)}
+ >
+
+
{task.title}
+
+
+ {task.isCritical && critical }
+
+
+ {task.description && (
+
+ {descriptionToPreviewPlain(task.description)}
+
+ )}
+
+ Due {formatKanbanDue(task.dueAt)}
+ onAssigneeChange(task, id)}
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss
new file mode 100644
index 00000000..b7949b50
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss
@@ -0,0 +1,105 @@
+@keyframes eventTasksTaskKanbanCardMovedFlash {
+ 0% {
+ background: rgba(77, 170, 87, 0.28);
+ border-color: rgba(77, 170, 87, 0.62);
+ box-shadow: 0 0 0 0 rgba(77, 170, 87, 0.35);
+ }
+
+ 100% {
+ background: var(--background);
+ border-color: var(--task-border);
+ box-shadow: 0 0 0 0 rgba(77, 170, 87, 0);
+ }
+}
+
+.event-tasks-task-kanban-card {
+ border: 1px solid var(--task-border);
+ border-radius: 10px;
+ background: var(--background);
+ padding: 0.52rem 0.58rem 0.48rem;
+ cursor: grab;
+ box-shadow: 0 1px 2px rgba(15, 18, 22, 0.04);
+ transition: border-color 220ms ease, opacity 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
+
+ &:hover {
+ border-color: rgba(77, 170, 87, 0.2);
+ box-shadow: 0 1px 3px rgba(77, 170, 87, 0.06);
+ }
+
+ &.event-tasks-task-kanban-card--dragging {
+ opacity: 0.5;
+ border-color: rgba(77, 170, 87, 0.35);
+ }
+
+ &.event-tasks-task-kanban-card--moved {
+ animation: eventTasksTaskKanbanCardMovedFlash 650ms ease;
+ }
+
+ &__title-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.4rem 0.5rem;
+ }
+
+ h5 {
+ margin: 0;
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ line-height: 1.25;
+ min-width: 0;
+ flex: 1;
+ }
+
+ .event-tasks-task-card__badges {
+ flex-shrink: 0;
+ }
+
+ .event-tasks-task-card__description {
+ margin: 0.28rem 0 0;
+ font-size: 0.74rem;
+ line-height: 1.4;
+ }
+
+ &__footer-meta {
+ margin-top: 0.42rem;
+ padding-top: 0.38rem;
+ border-top: 1px solid var(--task-border);
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.2rem 0.35rem;
+ font-size: 0.7rem;
+ color: var(--task-muted);
+
+ .event-tasks-task-kanban-card__meta-text {
+ display: inline-flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ }
+
+ > * + *::before {
+ content: '·';
+ margin-right: 0.42rem;
+ color: var(--task-muted);
+ opacity: 0.45;
+ font-weight: 700;
+ user-select: none;
+ }
+ }
+}
+
+.shared-task-board__drag-shell.dragging-origin .event-tasks-task-kanban-card {
+ border-style: dashed;
+ border-color: rgba(108, 117, 125, 0.28);
+ background: transparent;
+ box-shadow: none;
+ min-height: 88px;
+ cursor: grabbing;
+
+ > * {
+ visibility: hidden;
+ }
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.jsx b/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.jsx
new file mode 100644
index 00000000..d9db508a
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.jsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import TaskCardAssigneePicker from '../../TaskWorkspace/TaskCardAssigneePicker';
+import { descriptionToPreviewPlain } from '../../TaskWorkspace/taskWorkspaceUtils';
+import { EventTasksPriorityPill, EventTasksStatusPill } from './EventTasksTaskPills';
+import './EventTasksTaskCardShared.scss';
+import './EventTasksTaskListCard.scss';
+
+export default function EventTasksTaskListCard({
+ task,
+ isDragging,
+ isMoved,
+ getTaskStatus,
+ formatStatusLabel,
+ onOpenDetail,
+ members,
+ assigningTaskId,
+ onAssigneeChange,
+ boardStatuses,
+ doneStatusKey,
+ activeStatusKey,
+ activeQuickLabel,
+ doneQuickLabel,
+ onQuickStatusChange,
+ onDeleteTask,
+ actioningTaskId
+}) {
+ const st = getTaskStatus(task);
+ const statusRow = boardStatuses.find((s) => s.key === st);
+ const isDone = statusRow?.category === 'done';
+ const isBacklog = statusRow?.category === 'backlog';
+
+ return (
+
+ onOpenDetail(task)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onOpenDetail(task);
+ }
+ }}
+ >
+
+ {task.description && (
+
+ {descriptionToPreviewPlain(task.description)}
+
+ )}
+
+
+ Due {task.dueAt ? new Date(task.dueAt).toLocaleString() : 'none'}
+
+ onAssigneeChange(task, id)}
+ />
+
+
+
+
+ {!isDone && (
+ onQuickStatusChange(task, doneStatusKey)}
+ disabled={String(actioningTaskId) === String(task._id)}
+ >
+ {doneQuickLabel}
+
+ )}
+ {isBacklog && (
+ onQuickStatusChange(task, activeStatusKey)}
+ disabled={String(actioningTaskId) === String(task._id)}
+ >
+ {activeQuickLabel}
+
+ )}
+ onDeleteTask(task._id)}
+ disabled={String(actioningTaskId) === String(task._id)}
+ >
+ Delete
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.scss b/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.scss
new file mode 100644
index 00000000..f6b3164c
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskListCard.scss
@@ -0,0 +1,122 @@
+@keyframes eventTasksListCardMovedFlash {
+ 0% {
+ background: rgba(77, 170, 87, 0.22);
+ border-color: rgba(77, 170, 87, 0.55);
+ }
+
+ 100% {
+ background: var(--background);
+ border-color: var(--task-border);
+ }
+}
+
+.event-tasks-task-list-card {
+ border: 1px solid var(--task-border);
+ background: var(--background);
+ border-radius: 12px;
+ padding: 0.62rem 0.74rem;
+ box-shadow: 0 1px 2px rgba(15, 18, 22, 0.035);
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+
+ &:hover {
+ border-color: rgba(77, 170, 87, 0.22);
+ box-shadow: 0 1px 3px rgba(77, 170, 87, 0.06);
+ }
+
+ &__main--clickable {
+ cursor: pointer;
+ border-radius: 8px;
+ margin: -0.2rem;
+ padding: 0.2rem;
+ transition: background 0.12s ease;
+
+ &:hover {
+ background: rgba(77, 170, 87, 0.06);
+ }
+
+ &:focus-visible {
+ outline: 2px solid rgba(77, 170, 87, 0.45);
+ outline-offset: 2px;
+ }
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.2rem 0.35rem;
+ margin-top: 0.42rem;
+ padding-top: 0.42rem;
+ border-top: 1px solid var(--task-border);
+ font-size: 0.7rem;
+ line-height: 1.35;
+ color: var(--task-muted);
+
+ .event-tasks-task-list-card__meta-text {
+ display: inline-flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ color: inherit;
+ font-size: inherit;
+ }
+
+ > * + *::before {
+ content: '·';
+ margin-right: 0.42rem;
+ color: var(--task-muted);
+ opacity: 0.45;
+ font-weight: 700;
+ user-select: none;
+ }
+ }
+
+ header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.6rem;
+ margin-bottom: 0.35rem;
+
+ h5 {
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ letter-spacing: -0.005em;
+ }
+ }
+
+ .event-tasks-task-card__description {
+ font-size: 0.8rem;
+ line-height: 1.35;
+ }
+
+ footer {
+ margin-top: 0.55rem;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ }
+
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &.event-tasks-task-list-card--dragging {
+ opacity: 0.45;
+ }
+
+ &.event-tasks-task-list-card--moved {
+ animation: eventTasksListCardMovedFlash 650ms ease;
+ }
+}
+
+@media (max-width: 760px) {
+ .event-tasks-task-list-card footer {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskPills.jsx b/frontend/src/components/TaskBoard/cards/EventTasksTaskPills.jsx
new file mode 100644
index 00000000..15aef5af
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskPills.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import './EventTasksTaskCardShared.scss';
+
+export function EventTasksStatusPill({ status, label }) {
+ const safe = String(status || 'unknown').replace(/[^a-z0-9_-]/g, '') || 'unknown';
+ const text = label ?? String(status || '').replace(/_/g, ' ');
+ return
{text} ;
+}
+
+export function EventTasksPriorityPill({ priority }) {
+ return
{priority} ;
+}
diff --git a/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.jsx b/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.jsx
new file mode 100644
index 00000000..01129cd5
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import TaskCardAssigneePicker from '../../TaskWorkspace/TaskCardAssigneePicker';
+import { descriptionToPreviewPlain } from '../../TaskWorkspace/taskWorkspaceUtils';
+import './TasksHubTaskKanbanCard.scss';
+
+export default function TasksHubTaskKanbanCard({
+ task,
+ isDragging,
+ isMoved,
+ formatDate,
+ onOpenDetail,
+ members,
+ assigningTaskId,
+ onAssigneeChange,
+ activeStatusKey,
+ doneStatusKey,
+ activeQuickLabel,
+ doneQuickLabel,
+ getTaskStatus,
+ onTaskStatusChange
+}) {
+ const st = getTaskStatus(task);
+ return (
+
onOpenDetail(task)}
+ >
+
+
{task.title}
+
+ {task.priority}
+
+ {task.isCritical && critical }
+
+ {task.description && (
+
+ {descriptionToPreviewPlain(task.description)}
+
+ )}
+
+ Due {formatDate(task.dueAt)}
+ onAssigneeChange(task, id)}
+ />
+
+
+
+ );
+}
diff --git a/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.scss b/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.scss
new file mode 100644
index 00000000..30e44933
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/TasksHubTaskKanbanCard.scss
@@ -0,0 +1,188 @@
+@keyframes tasksHubTaskKanbanCardMovedFlash {
+ 0% {
+ background: rgba(77, 170, 87, 0.28);
+ border-color: rgba(77, 170, 87, 0.62);
+ box-shadow: 0 0 0 0 rgba(77, 170, 87, 0.35);
+ }
+
+ 100% {
+ background: var(--background);
+ border-color: var(--hub-border);
+ box-shadow: 0 0 0 0 rgba(77, 170, 87, 0);
+ }
+}
+
+.tasks-hub-task-kanban-card {
+ border: 1px solid var(--hub-border);
+ border-radius: 10px;
+ background: var(--background);
+ padding: 0.55rem 0.62rem 0.5rem;
+ cursor: pointer;
+ box-shadow: 0 1px 2px rgba(15, 18, 22, 0.04);
+ transition: opacity 220ms ease, background-color 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
+
+ &:hover {
+ border-color: rgba(77, 170, 87, 0.2);
+ box-shadow: 0 1px 3px rgba(77, 170, 87, 0.06);
+ background-color: var(--lighter);
+ }
+
+ &.tasks-hub-task-kanban-card--dragging {
+ opacity: 0.45;
+ }
+
+ &.tasks-hub-task-kanban-card--moved {
+ animation: tasksHubTaskKanbanCardMovedFlash 650ms ease;
+ }
+
+ h3 {
+ margin: 0;
+ font-size: 0.8rem;
+ line-height: 1.25;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ min-width: 0;
+ }
+
+ &__description {
+ margin: 0.28rem 0 0;
+ font-size: 0.74rem;
+ color: var(--hub-muted);
+ line-height: 1.4;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ word-break: break-word;
+ }
+
+ &__title-row {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.45rem;
+ margin-top: 0;
+
+ }
+
+ &__priority,
+ &__critical {
+ border-radius: 4px;
+ border: none;
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 0.14rem 0.34rem;
+ line-height: 1.2;
+ flex-shrink: 0;
+ }
+
+ &__priority {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.05);
+
+ &.tasks-hub-task-kanban-card__priority--low {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.045);
+ }
+
+ &.tasks-hub-task-kanban-card__priority--medium {
+ color: #2456a6;
+ background: rgba(46, 109, 200, 0.12);
+ }
+
+ &.tasks-hub-task-kanban-card__priority--high {
+ color: #8f5218;
+ background: rgba(218, 143, 63, 0.14);
+ }
+
+ &.tasks-hub-task-kanban-card__priority--critical {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+ }
+
+ &__critical {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+
+ &__meta {
+ margin-top: 0.42rem;
+ padding-top: 0.42rem;
+ border-top: 1px solid var(--hub-border);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.2rem;
+ font-size: 0.7rem;
+ line-height: 1.35;
+ color: var(--hub-muted);
+
+ .tasks-hub-task-kanban-card__meta-text {
+ display: inline-flex;
+ align-items: center;
+ border: none;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ color: inherit;
+ font-size: inherit;
+ }
+
+ > * + *::before {
+ content: '·';
+ margin-right: 0.42rem;
+ color: var(--hub-muted);
+ opacity: 0.45;
+ font-weight: 700;
+ user-select: none;
+ }
+ }
+
+ &__actions {
+ display: inline-flex;
+ gap: 0.15rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ margin-top: 0.42rem;
+
+ button {
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--hub-muted);
+ padding: 0.32rem 0.45rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 120ms ease, background 120ms ease;
+
+ &:hover:not(:disabled) {
+ color: var(--text);
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+ }
+ }
+}
+
+/* Drag placeholder: shell gets .dragging-origin from SharedTaskBoard */
+.shared-task-board__drag-shell.dragging-origin .tasks-hub-task-kanban-card {
+ border-style: dashed;
+ border-color: rgba(108, 117, 125, 0.28);
+ background: transparent;
+ box-shadow: none;
+ min-height: 96px;
+ cursor: grabbing;
+
+ > * {
+ visibility: hidden;
+ }
+}
diff --git a/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.jsx b/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.jsx
new file mode 100644
index 00000000..eb327246
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import TaskCardAssigneePicker from '../../TaskWorkspace/TaskCardAssigneePicker';
+import { descriptionToPreviewPlain } from '../../TaskWorkspace/taskWorkspaceUtils';
+import './TasksHubTaskListCard.scss';
+
+function statusModifier(status) {
+ return String(status || 'todo').replace(/[^a-z0-9_-]/g, '') || 'todo';
+}
+
+export default function TasksHubTaskListCard({
+ task,
+ isDragging,
+ isMoved,
+ getTaskStatus,
+ formatStatusLabel,
+ formatDate,
+ onOpenDetail,
+ members,
+ assigningTaskId,
+ onAssigneeChange,
+ activeStatusKey,
+ doneStatusKey,
+ activeQuickLabel,
+ doneQuickLabel,
+ onTaskStatusChange
+}) {
+ const st = getTaskStatus(task);
+ return (
+
+
onOpenDetail(task)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onOpenDetail(task);
+ }
+ }}
+ >
+
+
{task.title}
+
+ {task.priority}
+
+ {task.isCritical && critical }
+
+
+ {task.description ? descriptionToPreviewPlain(task.description) : 'No description provided.'}
+
+
+ {formatStatusLabel(st)}
+ Due {formatDate(task.dueAt)}
+ {task.eventId?.name || 'Org operations'}
+ onAssigneeChange(task, id)}
+ />
+ Urgency {Math.round(task.urgencyScore || 0)}
+
+
+ {/*
+ onTaskStatusChange(task, activeStatusKey)}
+ disabled={st === activeStatusKey}
+ >
+ {activeQuickLabel}
+
+ onTaskStatusChange(task, doneStatusKey)} disabled={st === doneStatusKey}>
+ {doneQuickLabel}
+
+
*/}
+
+ );
+}
diff --git a/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.scss b/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.scss
new file mode 100644
index 00000000..6887679b
--- /dev/null
+++ b/frontend/src/components/TaskBoard/cards/TasksHubTaskListCard.scss
@@ -0,0 +1,239 @@
+.tasks-hub-task-list-card {
+ border: 1px solid var(--hub-border);
+ background: var(--background);
+ border-radius: 12px;
+ overflow: hidden;
+ // padding: 0.58rem 0.64rem;
+ box-shadow: 0 1px 2px rgba(15, 18, 22, 0.035);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.58rem;
+ align-items: center;
+ position: relative;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ border-radius: 10px 0 0 10px;
+ background: rgba(108, 117, 125, 0.32);
+ }
+
+ &:hover {
+ border-color: rgba(77, 170, 87, 0.35);
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
+ }
+
+ &.tasks-hub-task-list-card--in_progress::before {
+ background: rgba(46, 109, 200, 0.55);
+ }
+
+ &.tasks-hub-task-list-card--done::before {
+ background: rgba(77, 170, 87, 0.62);
+ }
+
+ &.tasks-hub-task-list-card--blocked {
+ border-color: rgba(220, 53, 69, 0.35);
+ background: rgba(220, 53, 69, 0.04);
+
+ &::before {
+ background: rgba(220, 53, 69, 0.6);
+ }
+ }
+
+ &__main {
+ min-width: 0;
+
+ .tasks-hub-task-list-card__description {
+ margin: 0.28rem 0 0;
+ color: var(--hub-muted);
+ font-size: 0.78rem;
+ line-height: 1.35;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ word-break: break-word;
+ }
+ }
+
+ &__main--clickable {
+
+ cursor: pointer;
+ border-radius: 8px;
+ padding: 0.6rem;
+ width: 100%;
+ box-sizing: border-box;
+ transition: background 0.12s ease;
+
+ &:hover {
+ background: rgba(77, 170, 87, 0.06);
+ }
+
+ &:focus-visible {
+ outline: 2px solid rgba(77, 170, 87, 0.45);
+ outline-offset: 2px;
+ }
+ }
+
+ &__title-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.38rem;
+
+ h3 {
+ margin: 0;
+ font-size: 0.88rem;
+ line-height: 1.2;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ }
+ }
+
+ &__priority,
+ &__critical {
+ border-radius: 4px;
+ border: none;
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 0.14rem 0.34rem;
+ line-height: 1.2;
+ flex-shrink: 0;
+ }
+
+ &__priority {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.05);
+
+ &.tasks-hub-task-list-card__priority--low {
+ color: #5c656e;
+ background: rgba(0, 0, 0, 0.045);
+ }
+
+ &.tasks-hub-task-list-card__priority--medium {
+ color: #2456a6;
+ background: rgba(46, 109, 200, 0.12);
+ }
+
+ &.tasks-hub-task-list-card__priority--high {
+ color: #8f5218;
+ background: rgba(218, 143, 63, 0.14);
+ }
+
+ &.tasks-hub-task-list-card__priority--critical {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+ }
+
+ &__critical {
+ color: #a63d48;
+ background: rgba(220, 53, 69, 0.12);
+ }
+
+ &__meta {
+ margin-top: 0.42rem;
+ padding-top: 0.42rem;
+ border-top: 1px solid var(--hub-border);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem 0.2rem;
+ font-size: 0.7rem;
+ line-height: 1.35;
+ color: var(--hub-muted);
+
+ .tasks-hub-task-list-card__meta-text {
+ display: inline-flex;
+ align-items: center;
+ border: none;
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ color: inherit;
+ font-size: inherit;
+ }
+
+ > * + *::before {
+ content: '·';
+ margin-right: 0.42rem;
+ color: var(--hub-muted);
+ opacity: 0.45;
+ font-weight: 700;
+ user-select: none;
+ }
+ }
+
+ &__actions {
+ display: inline-flex;
+ gap: 0.15rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+
+ button {
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--hub-muted);
+ padding: 0.32rem 0.45rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 120ms ease, background 120ms ease;
+
+ &:hover:not(:disabled) {
+ color: var(--text);
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+ }
+ }
+
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &.tasks-hub-task-list-card--dragging {
+ opacity: 0.45;
+ }
+
+ &.tasks-hub-task-list-card--moved {
+ animation: tasksHubListCardMovedFlash 650ms ease;
+ }
+}
+
+@keyframes tasksHubListCardMovedFlash {
+ 0% {
+ background: rgba(77, 170, 87, 0.22);
+ border-color: rgba(77, 170, 87, 0.55);
+ }
+
+ 100% {
+ background: var(--background);
+ border-color: var(--hub-border);
+ }
+}
+
+@media (max-width: 860px) {
+ .tasks-hub-task-list-card {
+ grid-template-columns: 1fr;
+
+ .tasks-hub-task-list-card__actions {
+ justify-content: flex-start;
+ }
+ }
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskAssigneeAvatar.jsx b/frontend/src/components/TaskWorkspace/TaskAssigneeAvatar.jsx
new file mode 100644
index 00000000..2a62b8d0
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskAssigneeAvatar.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Icon } from '@iconify-icon/react';
+import { memberUserInitials, userDisplayName } from './taskWorkspaceUtils';
+import './TaskWorkspace.scss';
+
+/** Read-only assignee chip for list/kanban rows (Linear-style). */
+export default function TaskAssigneeAvatar({ ownerUserId, className = '' }) {
+ const user = ownerUserId && typeof ownerUserId === 'object' ? ownerUserId : null;
+ const label = user ? userDisplayName(user) : 'Unassigned';
+
+ return (
+
+ {user?.picture ? (
+
+ ) : (
+
+ {user ? memberUserInitials(user) : }
+
+ )}
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskAssigneePicker.jsx b/frontend/src/components/TaskWorkspace/TaskAssigneePicker.jsx
new file mode 100644
index 00000000..ac6158ea
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskAssigneePicker.jsx
@@ -0,0 +1,146 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Icon } from '@iconify-icon/react';
+import { memberUserInitials, userDisplayName } from './taskWorkspaceUtils';
+import './TaskWorkspace.scss';
+
+function memberToUser(member) {
+ return member?.user_id || null;
+}
+
+export default function TaskAssigneePicker({
+ members = [],
+ value,
+ onChange,
+ disabled = false,
+ compact = false,
+ className = ''
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const rootRef = useRef(null);
+
+ const selectedUser = useMemo(() => {
+ if (!value) return null;
+ const id = String(value);
+ for (const m of members) {
+ const u = memberToUser(m);
+ if (u && String(u._id) === id) return u;
+ }
+ return null;
+ }, [members, value]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return members;
+ return members.filter((m) => {
+ const u = memberToUser(m);
+ if (!u) return false;
+ const name = (u.name || '').toLowerCase();
+ const username = (u.username || '').toLowerCase();
+ const email = (u.email || '').toLowerCase();
+ return name.includes(q) || username.includes(q) || email.includes(q);
+ });
+ }, [members, query]);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ const onDoc = (e) => {
+ if (rootRef.current && !rootRef.current.contains(e.target)) {
+ setOpen(false);
+ setQuery('');
+ }
+ };
+ document.addEventListener('mousedown', onDoc);
+ return () => document.removeEventListener('mousedown', onDoc);
+ }, [open]);
+
+ const handlePick = useCallback(
+ (userId) => {
+ onChange?.(userId || null);
+ setOpen(false);
+ setQuery('');
+ },
+ [onChange]
+ );
+
+ const stop = (e) => {
+ e.stopPropagation();
+ };
+
+ return (
+
+
!disabled && setOpen((o) => !o)}
+ >
+ {selectedUser?.picture ? (
+
+ ) : (
+
+ {selectedUser ? memberUserInitials(selectedUser) : }
+
+ )}
+
+ {selectedUser ? userDisplayName(selectedUser) : 'Assign'}
+
+
+
+ {open && (
+
+
setQuery(e.target.value)}
+ autoFocus
+ />
+
+
+ handlePick(null)}
+ >
+ Unassigned
+
+
+ {filtered.map((m) => {
+ const u = memberToUser(m);
+ if (!u?._id) return null;
+ return (
+
+ handlePick(String(u._id))}
+ >
+ {u.picture ? (
+
+ ) : (
+
+ {memberUserInitials(u)}
+
+ )}
+ {userDisplayName(u)}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskBoardColumnsSettings.jsx b/frontend/src/components/TaskWorkspace/TaskBoardColumnsSettings.jsx
new file mode 100644
index 00000000..c4d22fd2
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskBoardColumnsSettings.jsx
@@ -0,0 +1,213 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Icon } from '@iconify-icon/react';
+import apiRequest from '../../utils/postRequest';
+import { useNotification } from '../../NotificationContext';
+import Popup from '../Popup/Popup';
+import {
+ DEFAULT_TASK_BOARD_STATUSES,
+ slugTaskStatusKey
+} from '../../constants/taskBoardDefaults';
+import './TaskWorkspace.scss';
+
+const MAX_COLS = 10;
+const CATEGORY_OPTIONS = [
+ { value: 'backlog', label: 'Backlog (not started)' },
+ { value: 'active', label: 'Active (in progress)' },
+ { value: 'done', label: 'Done (complete)' },
+ { value: 'cancelled', label: 'Cancelled' }
+];
+
+export default function TaskBoardColumnsSettings({ orgId, isOpen, onClose, onSaved }) {
+ const { addNotification } = useNotification();
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const load = useCallback(async () => {
+ if (!orgId) return;
+ setLoading(true);
+ try {
+ const res = await apiRequest(`/org-event-management/${orgId}/task-board-statuses`, null, {
+ method: 'GET'
+ });
+ const list = res?.data?.statuses;
+ setRows(Array.isArray(list) && list.length ? list.map((r) => ({ ...r })) : [...DEFAULT_TASK_BOARD_STATUSES]);
+ } catch (e) {
+ setRows([...DEFAULT_TASK_BOARD_STATUSES]);
+ addNotification({
+ title: 'Could not load columns',
+ message: e.message || 'Using defaults until refresh.',
+ type: 'error'
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [orgId, addNotification]);
+
+ useEffect(() => {
+ if (isOpen && orgId) load();
+ }, [isOpen, orgId, load]);
+
+ const move = (index, dir) => {
+ setRows((prev) => {
+ const j = index + dir;
+ if (j < 0 || j >= prev.length) return prev;
+ const next = [...prev];
+ [next[index], next[j]] = [next[j], next[index]];
+ return next;
+ });
+ };
+
+ const updateRow = (index, patch) => {
+ setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
+ };
+
+ const removeRow = (index) => {
+ setRows((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const addRow = () => {
+ setRows((prev) => {
+ if (prev.length >= MAX_COLS) return prev;
+ const keys = prev.map((r) => r.key);
+ const key = slugTaskStatusKey('New column', keys);
+ return [
+ ...prev,
+ { key, label: 'New column', category: 'backlog', order: prev.length }
+ ];
+ });
+ };
+
+ const save = async () => {
+ if (!orgId) return;
+ setSaving(true);
+ try {
+ const res = await apiRequest(
+ `/org-event-management/${orgId}/task-board-statuses`,
+ { statuses: rows.map((r, i) => ({ key: r.key, label: r.label, category: r.category, order: i })) },
+ { method: 'PUT' }
+ );
+ if (!res?.success) {
+ throw new Error(res?.message || res?.error || 'Save failed');
+ }
+ addNotification({ title: 'Board updated', message: 'Task columns saved for your organization.', type: 'success' });
+ onSaved?.(res?.data?.statuses || rows);
+ onClose?.();
+ } catch (e) {
+ addNotification({
+ title: 'Save failed',
+ message: e.message || 'Unable to save columns.',
+ type: 'error'
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const restoreDefaults = async () => {
+ if (!orgId) return;
+ setSaving(true);
+ try {
+ const res = await apiRequest(
+ `/org-event-management/${orgId}/task-board-statuses`,
+ { reset: true },
+ { method: 'PUT' }
+ );
+ if (!res?.success) throw new Error(res?.message || 'Reset failed');
+ addNotification({ title: 'Defaults restored', message: 'Using the standard three-column board.', type: 'success' });
+ onSaved?.(res?.data?.statuses || DEFAULT_TASK_BOARD_STATUSES);
+ onClose?.();
+ } catch (e) {
+ addNotification({ title: 'Reset failed', message: e.message || 'Try again.', type: 'error' });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+ Task board columns
+
+ Up to {MAX_COLS} columns. Each needs a done column and at least one backlog or{' '}
+ active column. Removing a column is blocked while tasks still use it.
+
+ {loading ? (
+ Loading…
+ ) : (
+
+ {rows.map((row, index) => (
+
+
+
+ move(index, -1)} disabled={index === 0}>
+
+
+ move(index, 1)}
+ disabled={index === rows.length - 1}
+ >
+
+
+
+
+ Label
+ updateRow(index, { label: e.target.value })}
+ maxLength={64}
+ />
+
+
+ Type
+ updateRow(index, { category: e.target.value })}
+ >
+ {CATEGORY_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+ {row.key}
+
+
removeRow(index)}
+ disabled={rows.length <= 1}
+ aria-label="Remove column"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
= MAX_COLS || loading}>
+
+ Add column ({rows.length}/{MAX_COLS})
+
+
+
+ Restore defaults
+
+
+ Cancel
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskCardAssigneePicker.jsx b/frontend/src/components/TaskWorkspace/TaskCardAssigneePicker.jsx
new file mode 100644
index 00000000..b1e4193e
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskCardAssigneePicker.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import TaskAssigneePicker from './TaskAssigneePicker';
+import { taskOwnerUserIdString } from './taskWorkspaceUtils';
+
+/**
+ * Inline assignee control for task list/kanban cards (compact picker, stops card click via TaskAssigneePicker).
+ */
+export default function TaskCardAssigneePicker({ task, members, onAssigneeChange, disabled = false, className = '' }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskDescriptionEditor.jsx b/frontend/src/components/TaskWorkspace/TaskDescriptionEditor.jsx
new file mode 100644
index 00000000..7ff102e4
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskDescriptionEditor.jsx
@@ -0,0 +1,127 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import { useEditor, EditorContent, useEditorState } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Placeholder from '@tiptap/extension-placeholder';
+import { Icon } from '@iconify-icon/react';
+import { descriptionToEditorContent, normalizeStoredTaskDescription } from './taskWorkspaceUtils';
+
+function Toolbar({ editor }) {
+ const { bold, italic, underline, bulletList, orderedList } = useEditorState({
+ editor,
+ selector: ({ editor: ed }) => ({
+ bold: ed.isActive('bold'),
+ italic: ed.isActive('italic'),
+ underline: ed.isActive('underline'),
+ bulletList: ed.isActive('bulletList'),
+ orderedList: ed.isActive('orderedList')
+ })
+ });
+
+ if (!editor) return null;
+
+ return (
+
+ editor.chain().focus().toggleBold().run()}
+ title="Bold"
+ >
+
+
+ editor.chain().focus().toggleItalic().run()}
+ title="Italic"
+ >
+
+
+ editor.chain().focus().toggleUnderline().run()}
+ title="Underline"
+ >
+
+
+
+ editor.chain().focus().toggleBulletList().run()}
+ title="Bullet list"
+ >
+
+
+ editor.chain().focus().toggleOrderedList().run()}
+ title="Numbered list"
+ >
+
+
+
+ );
+}
+
+export default function TaskDescriptionEditor({ value, onChange, placeholder, disabled, id }) {
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+
+ const extensions = useMemo(
+ () => [
+ StarterKit.configure({
+ blockquote: false,
+ code: false,
+ codeBlock: false,
+ heading: false,
+ horizontalRule: false,
+ strike: false,
+ link: false
+ }),
+ Placeholder.configure({
+ placeholder: placeholder || 'Add a description…'
+ })
+ ],
+ [placeholder]
+ );
+
+ const editor = useEditor(
+ {
+ extensions,
+ content: descriptionToEditorContent(value),
+ editable: !disabled,
+ editorProps: {
+ attributes: {
+ id: id || undefined,
+ class: 'task-description-editor__prose',
+ 'aria-multiline': 'true',
+ ...(id ? { 'aria-label': 'Description' } : {})
+ }
+ },
+ onUpdate: ({ editor: ed }) => {
+ onChangeRef.current(normalizeStoredTaskDescription(ed.getHTML()));
+ }
+ },
+ [extensions]
+ );
+
+ useEffect(() => {
+ if (!editor || editor.isDestroyed) return;
+ editor.setEditable(!disabled);
+ }, [disabled, editor]);
+
+ return (
+
+ {editor ? : null}
+
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskDetailFull.jsx b/frontend/src/components/TaskWorkspace/TaskDetailFull.jsx
new file mode 100644
index 00000000..cbefe356
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskDetailFull.jsx
@@ -0,0 +1,60 @@
+import React, { useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Icon } from '@iconify-icon/react';
+import './TaskWorkspace.scss';
+
+export default function TaskDetailFull({ open, onClose, title = 'Task', children }) {
+ useEffect(() => {
+ if (!open) return undefined;
+ const onKey = (e) => {
+ if (e.key === 'Escape') onClose?.();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+
+ if (typeof document === 'undefined') return null;
+
+ return createPortal(
+
+ {open && (
+
+
+
+
+ {children}
+
+
+ )}
+ ,
+ document.body
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskDetailPanel.jsx b/frontend/src/components/TaskWorkspace/TaskDetailPanel.jsx
new file mode 100644
index 00000000..802a6782
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskDetailPanel.jsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import { Icon } from '@iconify-icon/react';
+import TaskAssigneePicker from './TaskAssigneePicker';
+import TaskDescriptionEditor from './TaskDescriptionEditor';
+import TaskEventMiniCard from './TaskEventMiniCard';
+import { formatTaskDueDisplay } from './taskWorkspaceUtils';
+import { DEFAULT_TASK_BOARD_STATUSES } from '../../constants/taskBoardDefaults';
+import './TaskWorkspace.scss';
+
+function dueRuleSummary(task) {
+ const rule = task?.dueRule;
+ if (!rule || rule.anchorType === 'none' || rule.anchorType === 'absolute') return null;
+ const anchor = String(rule.anchorType || '').replace(/_/g, ' ');
+ const dir = rule.direction === 'after' ? 'after' : 'before';
+ const n = rule.offsetValue ?? 0;
+ const u = rule.offsetUnit || 'days';
+ return `Due ${n} ${u} ${dir} ${anchor}`;
+}
+
+export default function TaskDetailPanel({
+ task,
+ draft,
+ setDraft,
+ members = [],
+ orgId,
+ currentEventId = null,
+ taskBoardStatuses = null,
+ variant = 'sheet',
+ onClose,
+ onExpand,
+ onCollapse,
+ onSave,
+ saving = false,
+ saveError = ''
+}) {
+ const blockers = Array.isArray(task?.blockers) ? task.blockers : [];
+ const ruleLine = dueRuleSummary(task);
+ const statusOptions =
+ Array.isArray(taskBoardStatuses) && taskBoardStatuses.length
+ ? taskBoardStatuses
+ : DEFAULT_TASK_BOARD_STATUSES;
+
+ const showToolbar = (variant === 'sheet' && onExpand) || (variant === 'full' && onCollapse) || onClose;
+
+ return (
+
+ {showToolbar && (
+
+ {variant === 'sheet' && onExpand && (
+
+
+ Expand
+
+ )}
+ {variant === 'full' && onCollapse && (
+
+
+ Side panel
+
+ )}
+ {onClose && (
+
+
+ Close
+
+ )}
+
+ )}
+
+
+ {/*
+ Title
+ */}
+ setDraft((d) => ({ ...d, title: e.target.value }))}
+ maxLength={180}
+ placeholder="Untitled task"
+ autoComplete="off"
+ />
+
+
+
+
+ Description
+
+ setDraft((d) => ({ ...d, description: html }))}
+ placeholder="Add a description…"
+ disabled={saving}
+ />
+
+
+
+
+
+ Status
+
+
+ setDraft((d) => ({ ...d, status: e.target.value }))}
+ >
+ {statusOptions.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+
+
+ Priority
+
+
+ setDraft((d) => ({ ...d, priority: e.target.value }))}
+ >
+ Low
+ Medium
+ High
+ Critical
+
+
+
+
+
+
+ Critical
+
+
+ setDraft((d) => ({ ...d, isCritical: e.target.checked }))}
+ aria-labelledby="task-detail-critical-lbl"
+ />
+ Critical path
+
+
+
+
+
+ Assignee
+
+
+ setDraft((d) => ({ ...d, ownerUserId: id ? String(id) : '' }))}
+ disabled={saving}
+ />
+
+
+
+
+
+ Due
+
+
+
setDraft((d) => ({ ...d, dueAt: e.target.value }))}
+ />
+ {ruleLine && (
+
+ Rule: {ruleLine} (computed: {formatTaskDueDisplay(task?.dueAt)})
+
+ )}
+
+
+
+
+ {blockers.length > 0 && (
+
+
Blockers
+
+ {blockers.map((b, i) => (
+
+ {b.label || b.type || 'Blocker'}
+ {b.resolved ? ' (resolved)' : ''}
+
+ ))}
+
+
+ )}
+
+
+
+ {saveError ?
{saveError}
: null}
+
+
+
+ Cancel
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskDetailSheet.jsx b/frontend/src/components/TaskWorkspace/TaskDetailSheet.jsx
new file mode 100644
index 00000000..f05515c7
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskDetailSheet.jsx
@@ -0,0 +1,152 @@
+import React, { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Icon } from '@iconify-icon/react';
+import './TaskWorkspace.scss';
+
+export const TASK_DETAIL_SHEET_PANEL_MAX_PX = 420;
+
+const PANEL_WIDTH_PX = TASK_DETAIL_SHEET_PANEL_MAX_PX;
+
+/** Roots of list/kanban task cards (clicking these switches task or stays on card UI; do not close float sheet). */
+const TASK_BOARD_CARD_ROOT_SELECTOR = [
+ '.tasks-hub-task-list-card',
+ '.tasks-hub-task-kanban-card',
+ '.event-tasks-task-list-card',
+ '.event-tasks-task-kanban-card'
+].join(', ');
+
+export function getTaskDetailSheetPanelWidthPx() {
+ if (typeof window === 'undefined') return PANEL_WIDTH_PX;
+ return Math.min(PANEL_WIDTH_PX, window.innerWidth);
+}
+
+function usePushPanelWidth(enabled) {
+ const [w, setW] = useState(PANEL_WIDTH_PX);
+ useEffect(() => {
+ if (!enabled) return undefined;
+ const next = () => setW(Math.min(PANEL_WIDTH_PX, window.innerWidth));
+ next();
+ window.addEventListener('resize', next);
+ return () => window.removeEventListener('resize', next);
+ }, [enabled]);
+ return w;
+}
+
+export default function TaskDetailSheet({
+ open,
+ onClose,
+ title = 'Task',
+ children,
+ layout = 'overlay',
+ backdrop = true,
+ panelWidthPx = null
+}) {
+ useEffect(() => {
+ if (!open) return undefined;
+ const onKey = (e) => {
+ if (e.key === 'Escape') onClose?.();
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [open, onClose]);
+
+ const floatOverlay = layout === 'overlay' && !backdrop;
+
+ useEffect(() => {
+ if (!open || !floatOverlay || typeof document === 'undefined') return undefined;
+
+ const onPointerDownCapture = (e) => {
+ if (e.button !== 0) return;
+ const raw = e.target;
+ if (!(raw instanceof Element)) return;
+ if (raw.closest('.task-detail-sheet')) return;
+ if (raw.closest(TASK_BOARD_CARD_ROOT_SELECTOR)) return;
+ onClose?.();
+ };
+
+ document.addEventListener('pointerdown', onPointerDownCapture, true);
+ return () => document.removeEventListener('pointerdown', onPointerDownCapture, true);
+ }, [open, floatOverlay, onClose]);
+
+ const pushWidth = usePushPanelWidth(layout === 'push');
+
+ if (layout === 'push') {
+ return (
+
+ {open && (
+
+
+
+ )}
+
+ );
+ }
+
+ if (typeof document === 'undefined') return null;
+
+ const rootClass =
+ `task-detail-sheet task-detail-sheet--open${backdrop ? '' : ' task-detail-sheet--float'}`;
+ const panelStyle =
+ panelWidthPx != null
+ ? { width: panelWidthPx, maxWidth: '100vw', minWidth: 0 }
+ : undefined;
+
+ return createPortal(
+
+ {open && (
+
+ {backdrop && (
+
+ )}
+
+ {/* */}
+ {children}
+
+
+ )}
+ ,
+ document.body
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskEventMiniCard.jsx b/frontend/src/components/TaskWorkspace/TaskEventMiniCard.jsx
new file mode 100644
index 00000000..678052d3
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskEventMiniCard.jsx
@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+import { Icon } from '@iconify-icon/react';
+import { useDashboardOverlay } from '../../hooks/useDashboardOverlay';
+import './TaskWorkspace.scss';
+
+function formatEventWhen(start, end) {
+ if (!start) return 'Date TBD';
+ const s = new Date(start);
+ const e = end ? new Date(end) : null;
+ if (Number.isNaN(s.getTime())) return 'Date TBD';
+ const dOpts = { weekday: 'short', month: 'short', day: 'numeric' };
+ const tOpts = { hour: 'numeric', minute: '2-digit' };
+ if (e && !Number.isNaN(e.getTime())) {
+ const sameDay =
+ s.getDate() === e.getDate() &&
+ s.getMonth() === e.getMonth() &&
+ s.getFullYear() === e.getFullYear();
+ if (sameDay) {
+ return `${s.toLocaleString(undefined, dOpts)} · ${s.toLocaleString(undefined, tOpts)} – ${e.toLocaleString(undefined, tOpts)}`;
+ }
+ return `${s.toLocaleString(undefined, dOpts)} – ${e.toLocaleString(undefined, dOpts)}`;
+ }
+ return s.toLocaleString(undefined, { ...dOpts, ...tOpts });
+}
+
+export default function TaskEventMiniCard({ task, orgId, currentEventId = null }) {
+ const { showEventDashboard } = useDashboardOverlay();
+ const ev = task?.eventId;
+ const eventObj = ev && typeof ev === 'object' && ev._id ? ev : null;
+ const isCurrent = Boolean(
+ currentEventId && eventObj && String(eventObj._id) === String(currentEventId)
+ );
+
+ const onOpen = useCallback(() => {
+ if (isCurrent || !eventObj || !orgId) return;
+ showEventDashboard(
+ {
+ _id: eventObj._id,
+ name: eventObj.name,
+ start_time: eventObj.start_time,
+ end_time: eventObj.end_time
+ },
+ orgId,
+ { persistInUrl: true, className: 'full-width-event-dashboard' }
+ );
+ }, [eventObj, orgId, isCurrent, showEventDashboard]);
+
+ if (!eventObj || !orgId) return null;
+
+ return (
+
+
+
+
{eventObj.name || 'Event'}
+
+ {formatEventWhen(eventObj.start_time, eventObj.end_time)}
+
+ {isCurrent &&
Current event }
+
+
+ );
+}
diff --git a/frontend/src/components/TaskWorkspace/TaskWorkspace.scss b/frontend/src/components/TaskWorkspace/TaskWorkspace.scss
new file mode 100644
index 00000000..d604642e
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/TaskWorkspace.scss
@@ -0,0 +1,1133 @@
+.task-workspace-assignee {
+ position: relative;
+ min-width: 0;
+
+
+ /* Only while open: sit above sibling task rows/cards (same z-index on every row made lower cards paint over the popover). */
+ &--dropdown-open {
+ z-index: 4000;
+ }
+
+ &__trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ max-width: 100%;
+ padding: 0.35rem 0.5rem;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ background: var(--task-ws-surface, var(--lightbackground));
+ color: var(--text);
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.12s ease, border-color 0.12s ease;
+
+ &:hover:not(:disabled) {
+ border-color: var(--task-ws-border, var(--lighterborder));
+ background: var(--background);
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+ }
+
+ &__avatar {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: var(--background);
+ }
+
+ &__avatar-fallback {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.65rem;
+ font-weight: 600;
+ background: rgba(77, 170, 87, 0.2);
+ color: #2f5c36;
+ }
+
+ &__label {
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__chevron {
+ flex-shrink: 0;
+ opacity: 0.55;
+ font-size: 1rem;
+ }
+
+ &__dropdown {
+ position: absolute;
+ z-index: 5;
+ left: 0;
+ right: 0;
+ top: calc(100% + 4px);
+ max-height: 240px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ border: 1px solid var(--task-ws-border, var(--lighterborder));
+ background: var(--task-ws-popover, var(--background));
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
+ }
+
+ &__search {
+ padding: 0.45rem 0.55rem;
+ border: none;
+ border-bottom: 1px solid var(--task-ws-border, var(--lighterborder));
+ font-size: 0.8rem;
+ outline: none;
+ background: transparent;
+ color: var(--text);
+ }
+
+ &__list {
+ overflow-y: auto;
+ padding: 0.25rem;
+ margin: 0;
+ list-style: none;
+ }
+
+ &__option {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.4rem 0.45rem;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 0.78rem;
+ color: var(--text);
+ text-align: left;
+
+ &:hover {
+ background: var(--task-ws-hover, rgba(77, 170, 87, 0.08));
+ }
+ }
+
+ &__option--muted {
+ color: var(--light-text);
+ font-style: italic;
+ }
+}
+
+.task-workspace-event-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.55rem;
+ padding: 0.55rem 0.65rem;
+ border-radius: 10px;
+ border: 1px solid var(--task-ws-border, var(--lighterborder));
+ background: var(--task-ws-surface, var(--lightbackground));
+ text-align: left;
+ width: 100%;
+ cursor: pointer;
+ transition: border-color 0.12s ease, background 0.12s ease;
+
+ &:hover:not(:disabled) {
+ border-color: rgba(77, 170, 87, 0.4);
+ background: var(--background);
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.75;
+ }
+
+ &__icon {
+ flex-shrink: 0;
+ font-size: 1.25rem;
+ opacity: 0.65;
+ margin-top: 0.05rem;
+ }
+
+ &__body {
+ min-width: 0;
+ flex: 1;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 0.82rem;
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--text);
+ }
+
+ &__meta {
+ margin: 0.2rem 0 0;
+ font-size: 0.72rem;
+ color: var(--light-text);
+ }
+
+ &__badge {
+ display: inline-block;
+ margin-top: 0.35rem;
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--light-text);
+ }
+}
+
+.task-detail-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ min-height: 0;
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+ }
+
+ &__toolbar-btn {
+ border: 1px solid var(--task-ws-border, var(--lighterborder));
+ background: var(--task-ws-surface, var(--lightbackground));
+ color: var(--text);
+ border-radius: 8px;
+ padding: 0.35rem 0.55rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+
+ &:hover {
+ background: var(--background);
+ }
+ }
+
+ &__checkbox-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.82rem;
+ font-weight: 500;
+ color: var(--text);
+ text-transform: none;
+ letter-spacing: normal;
+ cursor: pointer;
+
+ input {
+ width: auto;
+ }
+ }
+
+ &__field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.28rem;
+
+ > label:not(.task-detail-panel__checkbox-row):not(.task-detail-panel__hint-label) {
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--light-text);
+ }
+
+ input:not([type='checkbox']):not(.task-detail-panel__title-input):not(.task-detail-panel__body-input):not(.task-detail-panel__ghost-datetime),
+ textarea:not(.task-detail-panel__body-input),
+ select:not(.task-detail-panel__ghost-select) {
+ font-family: inherit;
+ font-size: 0.85rem;
+ padding: 0.45rem 0.55rem;
+ border-radius: 8px;
+ border: 1px solid var(--task-ws-border, var(--lighterborder));
+ background: var(--background);
+ color: var(--text);
+ }
+
+ textarea:not(.task-detail-panel__body-input) {
+ min-height: 88px;
+ resize: vertical;
+ }
+ }
+
+ &__grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.65rem;
+
+ @media (max-width: 520px) {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ &__blockers {
+ margin: 0;
+ padding-left: 1.1rem;
+ font-size: 0.78rem;
+ color: var(--text);
+ }
+
+ &__footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.45rem;
+ margin-top: auto;
+ padding-top: 0.5rem;
+ }
+
+ &__save {
+ border: none;
+ background: rgba(77, 170, 87, 0.9);
+ color: #fff;
+ border-radius: 8px;
+ padding: 0.45rem 0.9rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+ }
+
+ &__error {
+ font-size: 0.75rem;
+ color: #b42318;
+ }
+
+ &__due-note {
+ margin: 0.35rem 0 0;
+ font-size: 0.72rem;
+ line-height: 1.35;
+ color: var(--light-text);
+ }
+
+ &.task-detail-panel--readable {
+ gap: 1rem;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+
+ .task-detail-panel__toolbar-btn {
+ border: none;
+ background: transparent;
+ color: var(--light-text);
+ padding: 0.3rem 0.45rem;
+ border-radius: 6px;
+
+ &:hover {
+ color: var(--text);
+ background: var(--task-ws-hover, rgba(77, 170, 87, 0.08));
+ }
+ }
+
+ .task-detail-panel__hint-label {
+ font-size: 0.68rem;
+ font-weight: 500;
+ text-transform: none;
+ letter-spacing: 0.02em;
+ color: var(--light-text);
+ }
+
+ .task-detail-panel__title-field,
+ .task-detail-panel__body-field {
+ gap: 0.2rem;
+ width: 100%;
+ min-width: 0;
+ align-self: stretch;
+ }
+
+ .task-detail-panel__title-input,
+ .task-detail-panel__body-input {
+ border: none;
+ background: transparent;
+ color: var(--text);
+ border-radius: 8px;
+ transition: background 0.12s ease, box-shadow 0.12s ease;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+
+ &::placeholder {
+ color: var(--light-text);
+ opacity: 0.55;
+ }
+
+ &:hover {
+ background: var(--task-ws-surface, var(--lightbackground));
+ }
+
+ &:focus {
+ outline: none;
+ background: var(--task-ws-surface, var(--lightbackground));
+ box-shadow: 0 0 0 2px rgba(77, 170, 87, 0.18);
+ }
+ }
+
+ .task-detail-panel__title-input {
+ font-size: 1.525rem;
+ font-weight: 650;
+ letter-spacing: -0.025em;
+ line-height: 1.28;
+ padding: 0.28rem 0.4rem;
+ margin: 0 -0.4rem;
+ }
+
+ .task-detail-panel__body-input {
+ font-size: 0.9rem;
+ line-height: 1.55;
+ padding: 0.45rem 0.5rem;
+ // margin: 0 -0.5rem;
+ min-height: 0;
+ max-height: none;
+ resize: none;
+ overflow-y: hidden;
+ }
+
+ .task-description-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin: 0 -0.5rem;
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ box-sizing: border-box;
+ align-self: stretch;
+
+ &--disabled {
+ opacity: 0.72;
+ pointer-events: none;
+ }
+
+ &__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.15rem;
+ // padding: 0 0.5rem;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ &__toolbar-sep {
+ display: inline-block;
+ width: 1px;
+ height: 1.1rem;
+ margin: 0 0.2rem;
+ background: var(--task-ws-border, var(--lighterborder));
+ opacity: 0.85;
+ }
+
+ &__toolbar-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.85rem;
+ height: 1.85rem;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--light-text);
+ cursor: pointer;
+ transition: background 0.12s ease, color 0.12s ease;
+
+ iconify-icon,
+ .iconify {
+ font-size: 1.1rem;
+ }
+
+ &:hover {
+ color: var(--text);
+ background: var(--task-ws-hover, rgba(77, 170, 87, 0.08));
+ }
+
+ &.is-active {
+ color: var(--text);
+ background: var(--task-ws-surface, var(--lightbackground));
+ box-shadow: 0 0 0 1px rgba(77, 170, 87, 0.22);
+ }
+ }
+
+ &__content {
+ min-height: 2.25rem;
+ overflow: visible;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+
+ /* TipTap root node (class names vary by version) */
+ > * {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ }
+
+ .task-description-editor__prose {
+ outline: none;
+ min-height: 2.25rem;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+
+ p {
+ margin: 0 0 0.4em;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ ul,
+ ol {
+ margin: 0 0 0.4em;
+ padding-left: 1.35rem;
+ }
+
+ li p {
+ margin: 0;
+ }
+
+ /* TipTap Placeholder extension: empty node decoration */
+ p.is-empty::before {
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ color: var(--light-text);
+ opacity: 0.55;
+ pointer-events: none;
+ }
+ }
+ }
+ }
+
+ .task-detail-panel__body-field .task-description-editor .task-detail-panel__body-input {
+ /* EditorContent wrapper: match textarea chrome */
+ &:hover .task-description-editor__prose {
+ background: var(--task-ws-surface, var(--lightbackground));
+ border-radius: 8px;
+ }
+
+ &:focus-within {
+ background: var(--task-ws-surface, var(--lightbackground));
+ border-radius: 8px;
+ box-shadow: 0 0 0 2px rgba(77, 170, 87, 0.18);
+ }
+ }
+
+ .task-detail-panel__meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.05rem;
+ padding-top: 0.75rem;
+ margin-top: 0.15rem;
+ border-top: 1px solid var(--task-ws-border, var(--lighterborder));
+ }
+
+ .task-detail-panel__meta-row {
+ display: grid;
+ grid-template-columns: 5.25rem minmax(0, 1fr);
+ align-items: center;
+ gap: 0.45rem 0.65rem;
+ padding: 0.22rem 0;
+ min-height: 2rem;
+
+ &--stack {
+ align-items: start;
+
+ .task-detail-panel__meta-value {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ }
+ }
+
+ @media (max-width: 360px) {
+ grid-template-columns: 1fr;
+ gap: 0.2rem;
+ }
+ }
+
+ .task-detail-panel__meta-label {
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: var(--light-text);
+ padding-top: 0.12rem;
+ }
+
+ .task-detail-panel__meta-value {
+ min-width: 0;
+ }
+
+ .task-detail-panel__ghost-select {
+ width: 100%;
+ max-width: 100%;
+ font-family: inherit;
+ font-size: 0.84rem;
+ font-weight: 500;
+ color: var(--text);
+ padding: 0.32rem 1.75rem 0.32rem 0.42rem;
+ margin: 0 -0.42rem;
+ border: none;
+ border-radius: 8px;
+ background-color: transparent;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.4rem center;
+ background-size: 12px;
+ cursor: pointer;
+ appearance: none;
+ transition: background-color 0.12s ease, box-shadow 0.12s ease;
+
+ &:hover {
+ background-color: var(--task-ws-surface, var(--lightbackground));
+ }
+
+ &:focus {
+ outline: none;
+ background-color: var(--task-ws-surface, var(--lightbackground));
+ box-shadow: 0 0 0 2px rgba(77, 170, 87, 0.18);
+ }
+ }
+
+ .task-detail-panel__ghost-datetime {
+ width: 100%;
+ max-width: 100%;
+ font-family: inherit;
+ font-size: 0.84rem;
+ color: var(--text);
+ padding: 0.32rem 0.42rem;
+ margin: 0 -0.42rem;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ transition: background 0.12s ease, box-shadow 0.12s ease;
+
+ &::-webkit-calendar-picker-indicator {
+ opacity: 0.55;
+ cursor: pointer;
+ }
+
+ &:hover {
+ background: var(--task-ws-surface, var(--lightbackground));
+ }
+
+ &:focus {
+ outline: none;
+ background: var(--task-ws-surface, var(--lightbackground));
+ box-shadow: 0 0 0 2px rgba(77, 170, 87, 0.18);
+ }
+ }
+
+ .task-detail-panel__meta-inline-check {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin: 0;
+ padding: 0.28rem 0.42rem;
+ margin-left: -0.42rem;
+ border-radius: 8px;
+ font-size: 0.84rem;
+ font-weight: 500;
+ color: var(--text);
+ cursor: pointer;
+ transition: background 0.12s ease;
+
+ input {
+ width: auto;
+ margin: 0;
+ accent-color: rgba(77, 170, 87, 0.95);
+ }
+
+ &:hover {
+ background: var(--task-ws-surface, var(--lightbackground));
+ }
+ }
+
+ .task-detail-panel__assignee-slot {
+ margin: 0 -0.35rem;
+
+ .task-workspace-assignee__trigger {
+ border: none;
+ background: transparent;
+ padding: 0.32rem 0.42rem;
+ width: 100%;
+ max-width: 100%;
+ justify-content: flex-start;
+ border-radius: 8px;
+ transition: background 0.12s ease, box-shadow 0.12s ease;
+
+ &:hover:not(:disabled) {
+ background: var(--task-ws-surface, var(--lightbackground));
+ }
+ }
+
+ .task-workspace-assignee--dropdown-open .task-workspace-assignee__trigger {
+ background: var(--task-ws-surface, var(--lightbackground));
+ box-shadow: 0 0 0 2px rgba(77, 170, 87, 0.18);
+ }
+ }
+
+ .task-detail-panel__blockers-field .task-detail-panel__blockers {
+ margin-top: 0.15rem;
+ }
+
+ .task-detail-panel__footer {
+ border-top: 1px solid var(--task-ws-border, var(--lighterborder));
+ padding-top: 0.75rem;
+ margin-top: 0.35rem;
+ }
+
+ .task-detail-panel__footer-text {
+ border: none;
+ background: transparent;
+ color: var(--light-text);
+ font-size: 0.8rem;
+ font-weight: 500;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--text);
+ background: var(--task-ws-hover, rgba(77, 170, 87, 0.06));
+ }
+ }
+
+ .task-detail-panel__save {
+ font-size: 0.8rem;
+ font-weight: 600;
+ padding: 0.45rem 0.85rem;
+ border-radius: 8px;
+ background: rgba(77, 170, 87, 0.92);
+ box-shadow: 0 1px 2px rgba(15, 18, 22, 0.06);
+ }
+
+ .task-workspace-event-card {
+ margin-top: 0.15rem;
+ border-color: transparent;
+ background: transparent;
+ padding-left: 0.4rem;
+ padding-right: 0.4rem;
+
+ &:hover:not(:disabled) {
+ border-color: var(--task-ws-border, var(--lighterborder));
+ background: var(--task-ws-surface, var(--lightbackground));
+ }
+ }
+ }
+}
+
+.task-detail-sheet {
+ position: fixed;
+ inset: 0;
+ z-index: 1205;
+ pointer-events: none;
+
+ &--push {
+ position: relative;
+ inset: auto;
+ z-index: 1;
+ flex-shrink: 0;
+ align-self: stretch;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ pointer-events: auto;
+ border-left: 1px solid var(--task-ws-border, var(--lighterborder));
+ box-shadow: -10px 0 36px rgba(0, 0, 0, 0.07);
+ }
+
+ &__push-inner {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ background: var(--background);
+ overflow: hidden;
+ }
+
+ &--open {
+ pointer-events: auto;
+ }
+
+ &--float.task-detail-sheet--open {
+ pointer-events: none;
+
+ .task-detail-sheet__panel {
+ pointer-events: auto;
+ }
+ }
+
+ &__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(15, 18, 22, 0.38);
+ backdrop-filter: blur(2px);
+ }
+
+ &__panel {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: min(420px, 100vw);
+ max-width: 100%;
+ background: var(--background);
+ border-left: 1px solid var(--task-ws-border, var(--lighterborder));
+ // box-shadow: -16px 0 48px rgba(0, 0, 0, 0.08);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.65rem 0.85rem;
+ border-bottom: 1px solid var(--task-ws-border, var(--lighterborder));
+ flex-shrink: 0;
+
+ h2 {
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ }
+ }
+
+ &__close {
+ border: none;
+ background: transparent;
+ padding: 0.35rem;
+ cursor: pointer;
+ border-radius: 6px;
+ color: var(--light-text);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: var(--lightbackground);
+ color: var(--text);
+ }
+ }
+
+ &__body {
+ flex: 1;
+ overflow: auto;
+ padding: 0.85rem;
+ }
+}
+
+.task-detail-full {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ padding: 0;
+
+ &__backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(10, 12, 16, 0.55);
+ backdrop-filter: blur(3px);
+ }
+
+ &__frame {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 720px;
+ margin: 0 auto;
+ background: var(--background);
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ box-shadow: 0 0 0 1px var(--task-ws-border, var(--lighterborder));
+
+ @media (min-width: 900px) {
+ margin: 2vh auto;
+ min-height: min(92vh, 900px);
+ max-height: 92vh;
+ border-radius: 12px;
+ overflow: hidden;
+ }
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--task-ws-border, var(--lighterborder));
+ flex-shrink: 0;
+
+ h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 600;
+ }
+ }
+
+ &__header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ &__body {
+ flex: 1;
+ overflow: auto;
+ padding: 1rem;
+ }
+}
+
+/* Card row assignee: avatar + name, no outer pill (Linear-style) */
+.task-assignee-avatar {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.38rem;
+ min-width: 0;
+ max-width: 100%;
+ border: none;
+ padding: 0;
+ background: transparent;
+ margin: 0;
+
+ .task-workspace-assignee__avatar,
+ .task-workspace-assignee__avatar-fallback {
+ width: 20px;
+ height: 20px;
+ font-size: 0.55rem;
+ flex-shrink: 0;
+ box-shadow: 0 0 0 1px var(--lighterborder, rgba(0, 0, 0, 0.08));
+ }
+
+ &__name {
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: var(--text);
+ opacity: 0.72;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.task-workspace-assignee--compact {
+ .task-workspace-assignee__trigger {
+ padding: 0.2rem 0.35rem;
+ border: none;
+ background: transparent;
+ font-size: 0.72rem;
+
+ &:hover:not(:disabled) {
+ background: rgba(77, 170, 87, 0.08);
+ }
+ }
+
+ .task-workspace-assignee__avatar,
+ .task-workspace-assignee__avatar-fallback {
+ width: 22px;
+ height: 22px;
+ font-size: 0.58rem;
+ }
+}
+
+.task-board-columns-settings {
+ padding: 0.25rem 0 0;
+
+ h2 {
+ margin: 0 0 0.35rem;
+ font-size: 1.15rem;
+ }
+
+ &__hint {
+ margin: 0 0 1rem;
+ font-size: 0.8rem;
+ color: var(--light-text);
+ line-height: 1.45;
+ }
+
+ &__loading {
+ margin: 1rem 0;
+ color: var(--light-text);
+ }
+
+ &__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-height: min(52vh, 420px);
+ overflow-y: auto;
+ }
+
+ &__row {
+ display: grid;
+ grid-template-columns: auto 1fr 1fr minmax(4.5rem, auto) auto;
+ gap: 0.5rem;
+ align-items: end;
+ }
+
+ &__order {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.1rem;
+ border: none;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ border-radius: 4px;
+ opacity: 0.7;
+ &:hover:not(:disabled) {
+ opacity: 1;
+ background: var(--lightbackground);
+ }
+ &:disabled {
+ opacity: 0.25;
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ &__label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ font-size: 0.72rem;
+ color: var(--light-text);
+ min-width: 0;
+ span {
+ font-weight: 600;
+ }
+ input,
+ select {
+ font-size: 0.85rem;
+ padding: 0.35rem 0.45rem;
+ border-radius: 6px;
+ border: 1px solid var(--lighterborder);
+ background: var(--background);
+ color: var(--text);
+ }
+ }
+
+ &__key {
+ font-size: 0.65rem;
+ align-self: center;
+ padding: 0.25rem 0.35rem;
+ border-radius: 4px;
+ background: var(--lightbackground);
+ color: var(--light-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__remove {
+ align-self: center;
+ border: none;
+ background: transparent;
+ color: var(--light-text);
+ cursor: pointer;
+ padding: 0.35rem;
+ border-radius: 6px;
+ &:hover:not(:disabled) {
+ color: var(--red, #c00);
+ background: rgba(200, 0, 0, 0.06);
+ }
+ &:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+ }
+
+ &__footer {
+ margin-top: 1rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--lighterborder);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+
+ > button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ align-self: flex-start;
+ padding: 0.4rem 0.65rem;
+ font-size: 0.85rem;
+ border-radius: 8px;
+ border: 1px dashed var(--lighterborder);
+ background: transparent;
+ cursor: pointer;
+ color: var(--text);
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ &__footer-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ button {
+ padding: 0.45rem 0.85rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ border: 1px solid var(--lighterborder);
+ background: var(--background);
+ cursor: pointer;
+ color: var(--text);
+ &.ghost {
+ border-color: transparent;
+ background: transparent;
+ }
+ }
+ button:last-child {
+ background: var(--green, #4daa57);
+ color: #fff;
+ border-color: transparent;
+ }
+ }
+}
diff --git a/frontend/src/components/TaskWorkspace/taskWorkspaceUtils.js b/frontend/src/components/TaskWorkspace/taskWorkspaceUtils.js
new file mode 100644
index 00000000..0690e84a
--- /dev/null
+++ b/frontend/src/components/TaskWorkspace/taskWorkspaceUtils.js
@@ -0,0 +1,128 @@
+import DOMPurify from 'dompurify';
+
+const TASK_DESCRIPTION_ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li'];
+
+function escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+/** Sanitized HTML for task descriptions (bold, italic, underline, lists only). */
+export function sanitizeTaskDescriptionHtml(html) {
+ return DOMPurify.sanitize(html || '', {
+ ALLOWED_TAGS: TASK_DESCRIPTION_ALLOWED_TAGS,
+ ALLOWED_ATTR: []
+ });
+}
+
+/**
+ * Plain text or legacy HTML → safe HTML for the rich-text editor.
+ * Plain text is wrapped in paragraphs; double newlines become new paragraphs.
+ */
+export function descriptionToEditorContent(raw) {
+ if (raw == null || String(raw).trim() === '') return '';
+ const s = String(raw);
+ const trimmed = s.trim();
+ if (/^<[a-z][a-z0-9]*/i.test(trimmed)) {
+ return sanitizeTaskDescriptionHtml(s);
+ }
+ const blocks = s.split(/\n\n+/);
+ const html = blocks
+ .map((block) => {
+ const inner = escapeHtml(block).replace(/\n/g, '
');
+ return `
${inner}
`;
+ })
+ .join('');
+ return sanitizeTaskDescriptionHtml(html);
+}
+
+/** After editing: sanitize and collapse visually-empty docs to ''. */
+export function normalizeStoredTaskDescription(html) {
+ const s = sanitizeTaskDescriptionHtml(html);
+ const div = document.createElement('div');
+ div.innerHTML = s;
+ const text = (div.textContent || '').replace(/\u00a0/g, ' ').trim();
+ return text ? s : '';
+}
+
+/** Strip tags for task card previews / search-friendly snippets. */
+export function descriptionToPreviewPlain(htmlOrPlain) {
+ if (htmlOrPlain == null || htmlOrPlain === '') return '';
+ const str = String(htmlOrPlain);
+ if (!str.includes('<')) return str;
+ const clean = sanitizeTaskDescriptionHtml(str);
+ const div = document.createElement('div');
+ div.innerHTML = clean;
+ return (div.textContent || '').replace(/\s+/g, ' ').trim();
+}
+
+export function userDisplayName(user) {
+ if (!user) return '';
+ return user.name || user.username || user.email || 'Member';
+}
+
+export function toDatetimeLocalValue(isoOrDate) {
+ if (!isoOrDate) return '';
+ const d = new Date(isoOrDate);
+ if (Number.isNaN(d.getTime())) return '';
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+export function formatTaskDueDisplay(dateLike) {
+ if (!dateLike) return 'No due date';
+ const d = new Date(dateLike);
+ if (Number.isNaN(d.getTime())) return 'No due date';
+ return d.toLocaleString();
+}
+
+/** Stable string id for Task.ownerUserId whether populated or raw ObjectId. */
+export function taskOwnerUserIdString(task) {
+ const o = task?.ownerUserId;
+ if (o == null || o === '') return '';
+ if (typeof o === 'object' && o._id) return String(o._id);
+ return String(o);
+}
+
+export function buildTaskDraft(task, getTaskStatusFn) {
+ return {
+ title: task.title || '',
+ description: task.description || '',
+ status: getTaskStatusFn(task),
+ priority: task.priority || 'medium',
+ isCritical: Boolean(task.isCritical),
+ ownerUserId: taskOwnerUserIdString(task),
+ dueAt: toDatetimeLocalValue(task.dueAt)
+ };
+}
+
+/** Shape compatible with populated Task.ownerUserId for list/card UI */
+export function ownerUserFromMembers(members, userId) {
+ if (!userId) return null;
+ const id = String(userId);
+ const list = members || [];
+ for (let i = 0; i < list.length; i += 1) {
+ const u = list[i]?.user_id;
+ if (u && String(u._id) === id) {
+ return {
+ _id: u._id,
+ name: u.name,
+ username: u.username,
+ picture: u.picture
+ };
+ }
+ }
+ return { _id: id };
+}
+
+export function memberUserInitials(user) {
+ const name = userDisplayName(user);
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
+ }
+ return (name[0] || '?').toUpperCase();
+}
diff --git a/frontend/src/constants/taskBoardDefaults.js b/frontend/src/constants/taskBoardDefaults.js
new file mode 100644
index 00000000..cb3ac0f5
--- /dev/null
+++ b/frontend/src/constants/taskBoardDefaults.js
@@ -0,0 +1,50 @@
+/** Mirrors backend DEFAULT_TASK_BOARD_STATUSES when API is unavailable */
+export const DEFAULT_TASK_BOARD_STATUSES = [
+ { key: 'todo', label: 'To do', category: 'backlog', order: 0 },
+ { key: 'in_progress', label: 'In progress', category: 'active', order: 1 },
+ { key: 'done', label: 'Done', category: 'done', order: 2 }
+];
+
+export function pickFirstBacklogKey(statuses) {
+ const row = (statuses || []).find((s) => s.category === 'backlog');
+ return row?.key || (statuses && statuses[0]?.key) || 'todo';
+}
+
+export function pickFirstActiveKey(statuses) {
+ const row = (statuses || []).find((s) => s.category === 'active');
+ return row?.key || 'in_progress';
+}
+
+export function pickFirstDoneKey(statuses) {
+ const row = (statuses || []).find((s) => s.category === 'done');
+ return row?.key || 'done';
+}
+
+export function formatTaskStatusLabel(statusKey, statuses, options = {}) {
+ const { effectiveBlocked } = options;
+ if (effectiveBlocked || statusKey === 'blocked') return 'Blocked';
+ const row = (statuses || []).find((s) => s.key === statusKey);
+ if (row) return row.label;
+ return String(statusKey || '')
+ .split('_')
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
+ .join(' ');
+}
+
+export function slugTaskStatusKey(label, existingKeys) {
+ let base = String(label || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .slice(0, 30);
+ if (!base) base = 'column';
+ if (!/^[a-z]/.test(base)) base = `c_${base}`;
+ const set = new Set(existingKeys);
+ let key = base;
+ let n = 2;
+ while (set.has(key)) {
+ const suffix = `_${n++}`;
+ key = (base + suffix).slice(0, 40);
+ }
+ return key.slice(0, 40);
+}
diff --git a/frontend/src/hooks/useDashboardOverlay.js b/frontend/src/hooks/useDashboardOverlay.js
index 31196d4d..a2641cdf 100644
--- a/frontend/src/hooks/useDashboardOverlay.js
+++ b/frontend/src/hooks/useDashboardOverlay.js
@@ -7,7 +7,7 @@ const EVENT_DASHBOARD_OVERLAY_KEY = 'event-dashboard';
/**
* Custom hook for easy overlay management in Dashboard components.
* When outside DashboardProvider (e.g. EventsHub), overlay helpers fall back to navigation.
- * @returns {Object} Object containing showOverlay, hideOverlay, showEventViewer, showEventWorkspace, showEventDashboard
+ * @returns {Object} Overlay helpers including showAdminEventOperator for tenant admin event detail
*/
export const useDashboardOverlay = () => {
const context = useDashboardOptional();
@@ -125,13 +125,33 @@ export const useDashboardOverlay = () => {
});
};
+ /**
+ * Tenant admin event panel. Navigates when Dashboard overlay is unavailable.
+ * @param {string} eventId
+ * @param {{ className?: string }} [options]
+ */
+ const showAdminEventOperator = (eventId, options = {}) => {
+ if (!eventId) return;
+ const { className = 'full-width-admin-event-operator' } = options;
+ if (!hasOverlay || !showOverlay) {
+ navigate(`/operator-event/${eventId}`);
+ return;
+ }
+ import('../pages/RootDash/AdminEventOperatorPage').then(({ AdminEventOperatorContent }) => {
+ showOverlay(
+
+ );
+ });
+ };
+
return {
showOverlay: show,
hideOverlay: hide,
showEventViewer,
showEventWorkspace,
showEventDashboard,
- showEventPostMortem
+ showEventPostMortem,
+ showAdminEventOperator,
};
};
diff --git a/frontend/src/hooks/useFetch.js b/frontend/src/hooks/useFetch.js
index 0d8f045b..a462cfcb 100644
--- a/frontend/src/hooks/useFetch.js
+++ b/frontend/src/hooks/useFetch.js
@@ -49,6 +49,10 @@ export const authenticatedRequest = async (url, options = {}) => {
}
};
+/**
+ * @param options.params For GET requests, pass a stable object (e.g. from useMemo([])). Inline `{ ... }` changes
+ * identity every render and will refetch in a tight loop because params is in the memo dependency array.
+ */
export const useFetch = (url, options = { method: "GET", data: null }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/frontend/src/pages/Admin/Admin.jsx b/frontend/src/pages/Admin/Admin.jsx
index 2440eb7b..4243a586 100644
--- a/frontend/src/pages/Admin/Admin.jsx
+++ b/frontend/src/pages/Admin/Admin.jsx
@@ -4,6 +4,7 @@ import useAuth from '../../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import Dashboard from '../../components/Dashboard/Dashboard';
import General from './General/General';
+import OperatorHubMode from './OperatorHubMode/OperatorHubMode';
import WebSocketConnectionsPage from './WebSocketConnectionsPage/WebSocketConnectionsPage';
import PlatformAdminsPage from './PlatformAdminsPage/PlatformAdminsPage';
import BadgeManager from './BadgeManager/BadgeManager';
@@ -37,6 +38,11 @@ function Admin(){
icon: 'ic:round-dashboard',
element:
},
+ {
+ label: 'Community organizer',
+ icon: 'mdi:view-dashboard-variant',
+ element:
,
+ },
{
label: 'Analytics',
icon: 'bx:stats',
diff --git a/frontend/src/pages/Admin/General/General.jsx b/frontend/src/pages/Admin/General/General.jsx
index 09dcad52..60a1a2a2 100644
--- a/frontend/src/pages/Admin/General/General.jsx
+++ b/frontend/src/pages/Admin/General/General.jsx
@@ -10,7 +10,7 @@ import { useFetch } from '../../../hooks/useFetch';
function General() {
const { addNotification } = useNotification();
- const [migrating, setMigrating] = useState(false);
+ const [classroomBuildingMigrating, setClassroomBuildingMigrating] = useState(false);
const [savingAutoClaim, setSavingAutoClaim] = useState(false);
const { data: configData, refetch: refetchConfig } = useFetch('/org-management/config');
const config = configData?.data;
@@ -33,31 +33,47 @@ function General() {
}
};
- const runOrgUnlistedMigration = async () => {
- setMigrating(true);
+ const runClassroomBuildingMigration = async () => {
+ if (
+ !window.confirm(
+ 'Create Building documents from classroom building names and switch classrooms to ObjectId refs? This is intended to run once per school database. Continue?'
+ )
+ ) {
+ return;
+ }
+ setClassroomBuildingMigrating(true);
try {
- const res = await apiRequest('/migrate/org-add-unlisted-field', {});
+ const res = await apiRequest('/admin/migrate-classroom-building-refs', {});
if (res?.success) {
- addNotification({
- title: 'Migration complete',
- message: `Orgs updated: ${res.data?.orgsUpdated ?? 0}`,
- type: 'success'
- });
+ const d = res.data || {};
+ if (d.skipped) {
+ addNotification({
+ title: 'Already completed',
+ message: d.reason === 'already_run' ? 'This migration was already run for this tenant.' : 'Skipped.',
+ type: 'info',
+ });
+ } else {
+ addNotification({
+ title: 'Migration complete',
+ message: `Rooms updated: ${d.classroomsUpdated ?? 0}. New buildings: ${d.buildingsCreatedCount ?? 0}.`,
+ type: 'success',
+ });
+ }
} else {
addNotification({
title: 'Migration failed',
message: res?.message || res?.error || 'Unknown error',
- type: 'error'
+ type: 'error',
});
}
} catch (e) {
addNotification({
title: 'Migration failed',
message: e?.message || 'Request failed',
- type: 'error'
+ type: 'error',
});
} finally {
- setMigrating(false);
+ setClassroomBuildingMigrating(false);
}
};
@@ -84,23 +100,30 @@ function General() {