feat: enhance task management with due date handling and mention support
This commit is contained in:
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import DateTime, case
|
from sqlalchemy import DateTime, case
|
||||||
from sqlalchemy import cast as sql_cast
|
from sqlalchemy import cast as sql_cast
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -18,6 +18,7 @@ from app.core.time import utcnow
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
|
from app.models.boards import Board
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.metrics import (
|
from app.schemas.metrics import (
|
||||||
DashboardBucketKey,
|
DashboardBucketKey,
|
||||||
@@ -38,6 +39,8 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
|
|||||||
ERROR_EVENT_PATTERN = "%failed"
|
ERROR_EVENT_PATTERN = "%failed"
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
|
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
|
||||||
RANGE_QUERY = Query(default="24h")
|
RANGE_QUERY = Query(default="24h")
|
||||||
|
BOARD_ID_QUERY = Query(default=None)
|
||||||
|
GROUP_ID_QUERY = Query(default=None)
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
|
|
||||||
@@ -385,16 +388,54 @@ async def _tasks_in_progress(
|
|||||||
return int(result)
|
return int(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_dashboard_board_ids(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
ctx: OrganizationContext,
|
||||||
|
board_id: UUID | None,
|
||||||
|
group_id: UUID | None,
|
||||||
|
) -> list[UUID]:
|
||||||
|
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||||
|
if not board_ids:
|
||||||
|
return []
|
||||||
|
allowed = set(board_ids)
|
||||||
|
|
||||||
|
if board_id is not None and board_id not in allowed:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
if group_id is None:
|
||||||
|
return [board_id] if board_id is not None else board_ids
|
||||||
|
|
||||||
|
group_board_ids = list(
|
||||||
|
await session.exec(
|
||||||
|
select(Board.id)
|
||||||
|
.where(col(Board.organization_id) == ctx.member.organization_id)
|
||||||
|
.where(col(Board.board_group_id) == group_id)
|
||||||
|
.where(col(Board.id).in_(board_ids)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if board_id is not None:
|
||||||
|
return [board_id] if board_id in set(group_board_ids) else []
|
||||||
|
return group_board_ids
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_model=DashboardMetrics)
|
@router.get("/dashboard", response_model=DashboardMetrics)
|
||||||
async def dashboard_metrics(
|
async def dashboard_metrics(
|
||||||
range_key: DashboardRangeKey = RANGE_QUERY,
|
range_key: DashboardRangeKey = RANGE_QUERY,
|
||||||
|
board_id: UUID | None = BOARD_ID_QUERY,
|
||||||
|
group_id: UUID | None = GROUP_ID_QUERY,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> DashboardMetrics:
|
) -> DashboardMetrics:
|
||||||
"""Return dashboard KPIs and time-series data for accessible boards."""
|
"""Return dashboard KPIs and time-series data for accessible boards."""
|
||||||
primary = _resolve_range(range_key)
|
primary = _resolve_range(range_key)
|
||||||
comparison = _comparison_range(primary)
|
comparison = _comparison_range(primary)
|
||||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
board_ids = await _resolve_dashboard_board_ids(
|
||||||
|
session,
|
||||||
|
ctx=ctx,
|
||||||
|
board_id=board_id,
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
|
||||||
throughput_primary = await _query_throughput(session, primary, board_ids)
|
throughput_primary = await _query_throughput(session, primary, board_ids)
|
||||||
throughput_comparison = await _query_throughput(session, comparison, board_ids)
|
throughput_comparison = await _query_throughput(session, comparison, board_ids)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.core.error_handling import (
|
|||||||
_error_payload,
|
_error_payload,
|
||||||
_get_request_id,
|
_get_request_id,
|
||||||
_http_exception_exception_handler,
|
_http_exception_exception_handler,
|
||||||
|
_json_safe,
|
||||||
_request_validation_exception_handler,
|
_request_validation_exception_handler,
|
||||||
_response_validation_exception_handler,
|
_response_validation_exception_handler,
|
||||||
install_error_handling,
|
install_error_handling,
|
||||||
@@ -209,6 +210,20 @@ def test_error_payload_omits_request_id_when_none() -> None:
|
|||||||
assert _error_payload(detail="x", request_id=None) == {"detail": "x"}
|
assert _error_payload(detail="x", request_id=None) == {"detail": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_safe_handles_binary_inputs() -> None:
|
||||||
|
assert _json_safe(b"\xf0\x9f\x92\xa1") == "💡"
|
||||||
|
assert _json_safe(bytearray(b"hello")) == "hello"
|
||||||
|
assert _json_safe(memoryview(b"world")) == "world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_safe_falls_back_to_string_for_unknown_objects() -> None:
|
||||||
|
class Weird:
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "weird-value"
|
||||||
|
|
||||||
|
assert _json_safe(Weird()) == "weird-value"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
|
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
|
||||||
req = Request({"type": "http", "headers": [], "state": {}})
|
req = Request({"type": "http", "headers": [], "state": {}})
|
||||||
|
|||||||
128
backend/tests/test_metrics_filters.py
Normal file
128
backend/tests/test_metrics_filters.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api import metrics as metrics_api
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, exec_result: list[object]) -> None:
|
||||||
|
self._exec_result = exec_result
|
||||||
|
|
||||||
|
async def exec(self, _statement: object) -> list[object]:
|
||||||
|
return self._exec_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_dashboard_board_ids_returns_requested_board(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
board_id = uuid4()
|
||||||
|
|
||||||
|
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||||
|
return [board_id]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metrics_api,
|
||||||
|
"list_accessible_board_ids",
|
||||||
|
_accessible,
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||||
|
|
||||||
|
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||||
|
_FakeSession([]),
|
||||||
|
ctx=ctx,
|
||||||
|
board_id=board_id,
|
||||||
|
group_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved == [board_id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_dashboard_board_ids_rejects_inaccessible_board(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
accessible_board_id = uuid4()
|
||||||
|
requested_board_id = uuid4()
|
||||||
|
|
||||||
|
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||||
|
return [accessible_board_id]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metrics_api,
|
||||||
|
"list_accessible_board_ids",
|
||||||
|
_accessible,
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await metrics_api._resolve_dashboard_board_ids(
|
||||||
|
_FakeSession([]),
|
||||||
|
ctx=ctx,
|
||||||
|
board_id=requested_board_id,
|
||||||
|
group_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_dashboard_board_ids_filters_by_group(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
board_a = uuid4()
|
||||||
|
board_b = uuid4()
|
||||||
|
group_id = uuid4()
|
||||||
|
|
||||||
|
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||||
|
return [board_a, board_b]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metrics_api,
|
||||||
|
"list_accessible_board_ids",
|
||||||
|
_accessible,
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||||
|
session = _FakeSession([board_b])
|
||||||
|
|
||||||
|
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||||
|
session,
|
||||||
|
ctx=ctx,
|
||||||
|
board_id=None,
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved == [board_b]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_dashboard_board_ids_returns_empty_when_board_not_in_group(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
board_id = uuid4()
|
||||||
|
group_id = uuid4()
|
||||||
|
|
||||||
|
async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
|
||||||
|
return [board_id]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metrics_api,
|
||||||
|
"list_accessible_board_ids",
|
||||||
|
_accessible,
|
||||||
|
)
|
||||||
|
ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
|
||||||
|
session = _FakeSession([])
|
||||||
|
|
||||||
|
resolved = await metrics_api._resolve_dashboard_board_ids(
|
||||||
|
session,
|
||||||
|
ctx=ctx,
|
||||||
|
board_id=board_id,
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved == []
|
||||||
@@ -16,6 +16,10 @@ export interface ApprovalCreate {
|
|||||||
task_id?: string | null;
|
task_id?: string | null;
|
||||||
task_ids?: string[];
|
task_ids?: string[];
|
||||||
payload?: ApprovalCreatePayload;
|
payload?: ApprovalCreatePayload;
|
||||||
|
/**
|
||||||
|
* @minimum 0
|
||||||
|
* @maximum 100
|
||||||
|
*/
|
||||||
confidence: number;
|
confidence: number;
|
||||||
rubric_scores?: ApprovalCreateRubricScores;
|
rubric_scores?: ApprovalCreateRubricScores;
|
||||||
status?: ApprovalCreateStatus;
|
status?: ApprovalCreateStatus;
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ export interface ApprovalRead {
|
|||||||
task_id?: string | null;
|
task_id?: string | null;
|
||||||
task_ids?: string[];
|
task_ids?: string[];
|
||||||
payload?: ApprovalReadPayload;
|
payload?: ApprovalReadPayload;
|
||||||
|
/**
|
||||||
|
* @minimum 0
|
||||||
|
* @maximum 100
|
||||||
|
*/
|
||||||
confidence: number;
|
confidence: number;
|
||||||
rubric_scores?: ApprovalReadRubricScores;
|
rubric_scores?: ApprovalReadRubricScores;
|
||||||
status?: ApprovalReadStatus;
|
status?: ApprovalReadStatus;
|
||||||
id: string;
|
id: string;
|
||||||
board_id: string;
|
board_id: string;
|
||||||
|
task_titles?: string[];
|
||||||
agent_id?: string | null;
|
agent_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
resolved_at?: string | null;
|
resolved_at?: string | null;
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa
|
|||||||
|
|
||||||
export type DashboardMetricsApiV1MetricsDashboardGetParams = {
|
export type DashboardMetricsApiV1MetricsDashboardGetParams = {
|
||||||
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
|
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
|
||||||
|
board_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ const SSE_RECONNECT_BACKOFF = {
|
|||||||
jitter: 0.2,
|
jitter: 0.2,
|
||||||
maxMs: 5 * 60_000,
|
maxMs: 5 * 60_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
const HAS_ALL_MENTION_RE = /(^|\s)@all\b/i;
|
||||||
|
|
||||||
type HeartbeatUnit = "s" | "m" | "h" | "d";
|
type HeartbeatUnit = "s" | "m" | "h" | "d";
|
||||||
|
|
||||||
@@ -231,6 +232,17 @@ export default function BoardGroupDetailPage() {
|
|||||||
});
|
});
|
||||||
return ids;
|
return ids;
|
||||||
}, [boards]);
|
}, [boards]);
|
||||||
|
const groupMentionSuggestions = useMemo(() => {
|
||||||
|
const options = new Set<string>(["lead", "all"]);
|
||||||
|
boards.forEach((item) => {
|
||||||
|
(item.tasks ?? []).forEach((task) => {
|
||||||
|
if (task.assignee) {
|
||||||
|
options.add(task.assignee);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...options];
|
||||||
|
}, [boards]);
|
||||||
|
|
||||||
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
|
||||||
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
@@ -599,7 +611,9 @@ export default function BoardGroupDetailPage() {
|
|||||||
setIsChatSending(true);
|
setIsChatSending(true);
|
||||||
setChatError(null);
|
setChatError(null);
|
||||||
try {
|
try {
|
||||||
const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])];
|
const shouldBroadcast =
|
||||||
|
chatBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
|
||||||
|
const tags = ["chat", ...(shouldBroadcast ? ["broadcast"] : [])];
|
||||||
const result =
|
const result =
|
||||||
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
||||||
groupId,
|
groupId,
|
||||||
@@ -641,7 +655,9 @@ export default function BoardGroupDetailPage() {
|
|||||||
setIsNoteSending(true);
|
setIsNoteSending(true);
|
||||||
setNoteSendError(null);
|
setNoteSendError(null);
|
||||||
try {
|
try {
|
||||||
const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])];
|
const shouldBroadcast =
|
||||||
|
notesBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
|
||||||
|
const tags = ["note", ...(shouldBroadcast ? ["broadcast"] : [])];
|
||||||
const result =
|
const result =
|
||||||
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
|
||||||
groupId,
|
groupId,
|
||||||
@@ -1156,6 +1172,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
isSending={isChatSending}
|
isSending={isChatSending}
|
||||||
onSend={sendGroupChat}
|
onSend={sendGroupChat}
|
||||||
disabled={!canWriteGroup}
|
disabled={!canWriteGroup}
|
||||||
|
mentionSuggestions={groupMentionSuggestions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() {
|
|||||||
isSending={isNoteSending}
|
isSending={isNoteSending}
|
||||||
onSend={sendGroupNote}
|
onSend={sendGroupNote}
|
||||||
disabled={!canWriteGroup}
|
disabled={!canWriteGroup}
|
||||||
|
mentionSuggestions={groupMentionSuggestions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,12 @@ import type {
|
|||||||
TaskRead,
|
TaskRead,
|
||||||
} from "@/api/generated/model";
|
} from "@/api/generated/model";
|
||||||
import { createExponentialBackoff } from "@/lib/backoff";
|
import { createExponentialBackoff } from "@/lib/backoff";
|
||||||
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
|
import {
|
||||||
|
apiDatetimeToMs,
|
||||||
|
localDateInputToUtcIso,
|
||||||
|
parseApiDatetime,
|
||||||
|
toLocalDateInput,
|
||||||
|
} from "@/lib/datetime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePageActive } from "@/hooks/usePageActive";
|
import { usePageActive } from "@/hooks/usePageActive";
|
||||||
|
|
||||||
@@ -738,8 +743,6 @@ export default function BoardDetailPage() {
|
|||||||
const liveFeedHistoryLoadedRef = useRef(false);
|
const liveFeedHistoryLoadedRef = useRef(false);
|
||||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||||
const [newComment, setNewComment] = useState("");
|
|
||||||
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||||
const [postCommentError, setPostCommentError] = useState<string | null>(null);
|
const [postCommentError, setPostCommentError] = useState<string | null>(null);
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
@@ -1001,6 +1004,7 @@ export default function BoardDetailPage() {
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState("medium");
|
const [priority, setPriority] = useState("medium");
|
||||||
|
const [createDueDate, setCreateDueDate] = useState("");
|
||||||
const [createTagIds, setCreateTagIds] = useState<string[]>([]);
|
const [createTagIds, setCreateTagIds] = useState<string[]>([]);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -1009,6 +1013,7 @@ export default function BoardDetailPage() {
|
|||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
|
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
|
||||||
const [editPriority, setEditPriority] = useState("medium");
|
const [editPriority, setEditPriority] = useState("medium");
|
||||||
|
const [editDueDate, setEditDueDate] = useState("");
|
||||||
const [editAssigneeId, setEditAssigneeId] = useState("");
|
const [editAssigneeId, setEditAssigneeId] = useState("");
|
||||||
const [editTagIds, setEditTagIds] = useState<string[]>([]);
|
const [editTagIds, setEditTagIds] = useState<string[]>([]);
|
||||||
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
|
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
|
||||||
@@ -1484,6 +1489,7 @@ export default function BoardDetailPage() {
|
|||||||
setEditDescription("");
|
setEditDescription("");
|
||||||
setEditStatus("inbox");
|
setEditStatus("inbox");
|
||||||
setEditPriority("medium");
|
setEditPriority("medium");
|
||||||
|
setEditDueDate("");
|
||||||
setEditAssigneeId("");
|
setEditAssigneeId("");
|
||||||
setEditTagIds([]);
|
setEditTagIds([]);
|
||||||
setEditDependsOnTaskIds([]);
|
setEditDependsOnTaskIds([]);
|
||||||
@@ -1494,6 +1500,7 @@ export default function BoardDetailPage() {
|
|||||||
setEditDescription(selectedTask.description ?? "");
|
setEditDescription(selectedTask.description ?? "");
|
||||||
setEditStatus(selectedTask.status);
|
setEditStatus(selectedTask.status);
|
||||||
setEditPriority(selectedTask.priority);
|
setEditPriority(selectedTask.priority);
|
||||||
|
setEditDueDate(toLocalDateInput(selectedTask.due_at));
|
||||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||||
setEditTagIds(selectedTask.tag_ids ?? []);
|
setEditTagIds(selectedTask.tag_ids ?? []);
|
||||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||||
@@ -1802,6 +1809,7 @@ export default function BoardDetailPage() {
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setPriority("medium");
|
setPriority("medium");
|
||||||
|
setCreateDueDate("");
|
||||||
setCreateTagIds([]);
|
setCreateTagIds([]);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
};
|
};
|
||||||
@@ -1821,6 +1829,7 @@ export default function BoardDetailPage() {
|
|||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
status: "inbox",
|
status: "inbox",
|
||||||
priority,
|
priority,
|
||||||
|
due_at: localDateInputToUtcIso(createDueDate),
|
||||||
tag_ids: createTagIds,
|
tag_ids: createTagIds,
|
||||||
});
|
});
|
||||||
if (result.status !== 200) throw new Error("Unable to create task.");
|
if (result.status !== 200) throw new Error("Unable to create task.");
|
||||||
@@ -1973,6 +1982,15 @@ export default function BoardDetailPage() {
|
|||||||
() => agents.filter((agent) => !agent.is_board_lead),
|
() => agents.filter((agent) => !agent.is_board_lead),
|
||||||
[agents],
|
[agents],
|
||||||
);
|
);
|
||||||
|
const boardChatMentionSuggestions = useMemo(() => {
|
||||||
|
const options = new Set<string>(["lead"]);
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
if (agent.name) {
|
||||||
|
options.add(agent.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...options];
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
const tagById = useMemo(() => {
|
const tagById = useMemo(() => {
|
||||||
const map = new Map<string, TagRead>();
|
const map = new Map<string, TagRead>();
|
||||||
@@ -2045,6 +2063,7 @@ export default function BoardDetailPage() {
|
|||||||
const normalizedTitle = editTitle.trim();
|
const normalizedTitle = editTitle.trim();
|
||||||
const normalizedDescription = editDescription.trim();
|
const normalizedDescription = editDescription.trim();
|
||||||
const currentDescription = (selectedTask.description ?? "").trim();
|
const currentDescription = (selectedTask.description ?? "").trim();
|
||||||
|
const currentDueDate = toLocalDateInput(selectedTask.due_at);
|
||||||
const currentAssignee = selectedTask.assigned_agent_id ?? "";
|
const currentAssignee = selectedTask.assigned_agent_id ?? "";
|
||||||
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
||||||
const nextTags = [...editTagIds].sort().join("|");
|
const nextTags = [...editTagIds].sort().join("|");
|
||||||
@@ -2057,12 +2076,14 @@ export default function BoardDetailPage() {
|
|||||||
normalizedDescription !== currentDescription ||
|
normalizedDescription !== currentDescription ||
|
||||||
editStatus !== selectedTask.status ||
|
editStatus !== selectedTask.status ||
|
||||||
editPriority !== selectedTask.priority ||
|
editPriority !== selectedTask.priority ||
|
||||||
|
editDueDate !== currentDueDate ||
|
||||||
editAssigneeId !== currentAssignee ||
|
editAssigneeId !== currentAssignee ||
|
||||||
currentTags !== nextTags ||
|
currentTags !== nextTags ||
|
||||||
currentDeps !== nextDeps
|
currentDeps !== nextDeps
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
editAssigneeId,
|
editAssigneeId,
|
||||||
|
editDueDate,
|
||||||
editTagIds,
|
editTagIds,
|
||||||
editDependsOnTaskIds,
|
editDependsOnTaskIds,
|
||||||
editDescription,
|
editDescription,
|
||||||
@@ -2205,7 +2226,6 @@ export default function BoardDetailPage() {
|
|||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
setComments([]);
|
setComments([]);
|
||||||
setCommentsError(null);
|
setCommentsError(null);
|
||||||
setNewComment("");
|
|
||||||
setPostCommentError(null);
|
setPostCommentError(null);
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
};
|
};
|
||||||
@@ -2237,12 +2257,12 @@ export default function BoardDetailPage() {
|
|||||||
setIsLiveFeedOpen(false);
|
setIsLiveFeedOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePostComment = async () => {
|
const handlePostComment = async (message: string): Promise<boolean> => {
|
||||||
if (!selectedTask || !boardId || !isSignedIn) return;
|
if (!selectedTask || !boardId || !isSignedIn) return false;
|
||||||
const trimmed = newComment.trim();
|
const trimmed = message.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setPostCommentError("Write a message before sending.");
|
setPostCommentError("Write a message before sending.");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setIsPostingComment(true);
|
setIsPostingComment(true);
|
||||||
setPostCommentError(null);
|
setPostCommentError(null);
|
||||||
@@ -2256,14 +2276,14 @@ export default function BoardDetailPage() {
|
|||||||
if (result.status !== 200) throw new Error("Unable to send message.");
|
if (result.status !== 200) throw new Error("Unable to send message.");
|
||||||
const created = result.data;
|
const created = result.data;
|
||||||
setComments((prev) => [created, ...prev]);
|
setComments((prev) => [created, ...prev]);
|
||||||
setNewComment("");
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = formatActionError(err, "Unable to send message.");
|
const message = formatActionError(err, "Unable to send message.");
|
||||||
setPostCommentError(message);
|
setPostCommentError(message);
|
||||||
pushToast(message);
|
pushToast(message);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsPostingComment(false);
|
setIsPostingComment(false);
|
||||||
taskCommentInputRef.current?.focus();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2285,6 +2305,8 @@ export default function BoardDetailPage() {
|
|||||||
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
|
||||||
const nextTags = [...editTagIds].sort().join("|");
|
const nextTags = [...editTagIds].sort().join("|");
|
||||||
const tagsChanged = currentTags !== nextTags;
|
const tagsChanged = currentTags !== nextTags;
|
||||||
|
const currentDueDate = toLocalDateInput(selectedTask.due_at);
|
||||||
|
const dueDateChanged = editDueDate !== currentDueDate;
|
||||||
|
|
||||||
const updatePayload: Parameters<
|
const updatePayload: Parameters<
|
||||||
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
|
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
|
||||||
@@ -2302,6 +2324,9 @@ export default function BoardDetailPage() {
|
|||||||
if (tagsChanged) {
|
if (tagsChanged) {
|
||||||
updatePayload.tag_ids = editTagIds;
|
updatePayload.tag_ids = editTagIds;
|
||||||
}
|
}
|
||||||
|
if (dueDateChanged) {
|
||||||
|
updatePayload.due_at = localDateInputToUtcIso(editDueDate);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
|
||||||
boardId,
|
boardId,
|
||||||
@@ -2362,6 +2387,7 @@ export default function BoardDetailPage() {
|
|||||||
setEditDescription(selectedTask.description ?? "");
|
setEditDescription(selectedTask.description ?? "");
|
||||||
setEditStatus(selectedTask.status);
|
setEditStatus(selectedTask.status);
|
||||||
setEditPriority(selectedTask.priority);
|
setEditPriority(selectedTask.priority);
|
||||||
|
setEditDueDate(toLocalDateInput(selectedTask.due_at));
|
||||||
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
|
||||||
setEditTagIds(selectedTask.tag_ids ?? []);
|
setEditTagIds(selectedTask.tag_ids ?? []);
|
||||||
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
|
||||||
@@ -3520,27 +3546,16 @@ export default function BoardDetailPage() {
|
|||||||
Comments
|
Comments
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
<Textarea
|
<BoardChatComposer
|
||||||
ref={taskCommentInputRef}
|
|
||||||
value={newComment}
|
|
||||||
onChange={(event) => setNewComment(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== "Enter") return;
|
|
||||||
if (event.nativeEvent.isComposing) return;
|
|
||||||
if (event.shiftKey) return;
|
|
||||||
if (!canWrite) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (isPostingComment) return;
|
|
||||||
if (!newComment.trim()) return;
|
|
||||||
void handlePostComment();
|
|
||||||
}}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
canWrite
|
canWrite
|
||||||
? "Write a message for the assigned agent…"
|
? "Write a message for the assigned agent. Tag @lead or @name."
|
||||||
: "Read-only access. Comments are disabled."
|
: "Read-only access. Comments are disabled."
|
||||||
}
|
}
|
||||||
className="min-h-[80px] bg-white"
|
isSending={isPostingComment}
|
||||||
disabled={!canWrite || isPostingComment}
|
onSend={handlePostComment}
|
||||||
|
disabled={!canWrite}
|
||||||
|
mentionSuggestions={boardChatMentionSuggestions}
|
||||||
/>
|
/>
|
||||||
{postCommentError ? (
|
{postCommentError ? (
|
||||||
<p className="text-xs text-rose-600">{postCommentError}</p>
|
<p className="text-xs text-rose-600">{postCommentError}</p>
|
||||||
@@ -3550,18 +3565,6 @@ export default function BoardDetailPage() {
|
|||||||
Read-only access. You cannot post comments on this board.
|
Read-only access. You cannot post comments on this board.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePostComment}
|
|
||||||
disabled={
|
|
||||||
!canWrite || isPostingComment || !newComment.trim()
|
|
||||||
}
|
|
||||||
title={canWrite ? "Send message" : "Read-only access"}
|
|
||||||
>
|
|
||||||
{isPostingComment ? "Sending…" : "Send message"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isCommentsLoading ? (
|
{isCommentsLoading ? (
|
||||||
<p className="text-sm text-slate-500">Loading comments…</p>
|
<p className="text-sm text-slate-500">Loading comments…</p>
|
||||||
@@ -3638,6 +3641,7 @@ export default function BoardDetailPage() {
|
|||||||
isSending={isChatSending}
|
isSending={isChatSending}
|
||||||
onSend={handleSendChat}
|
onSend={handleSendChat}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
|
mentionSuggestions={boardChatMentionSuggestions}
|
||||||
placeholder={
|
placeholder={
|
||||||
canWrite
|
canWrite
|
||||||
? "Message the board lead. Tag agents with @name."
|
? "Message the board lead. Tag agents with @name."
|
||||||
@@ -3803,6 +3807,17 @@ export default function BoardDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Due date
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editDueDate}
|
||||||
|
onChange={(event) => setEditDueDate(event.target.value)}
|
||||||
|
disabled={!selectedTask || isSavingTask || !canWrite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
@@ -4094,6 +4109,17 @@ export default function BoardDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-strong">
|
||||||
|
Due date
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={createDueDate}
|
||||||
|
onChange={(event) => setCreateDueDate(event.target.value)}
|
||||||
|
disabled={!canWrite || isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-strong">Tags</label>
|
<label className="text-sm font-medium text-strong">Tags</label>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
@@ -24,9 +25,19 @@ import { Activity, PenSquare, Timer, Users } from "lucide-react";
|
|||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
import DropdownSelect, {
|
||||||
|
type DropdownSelectOption,
|
||||||
|
} from "@/components/ui/dropdown-select";
|
||||||
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import {
|
||||||
|
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||||
|
useListBoardGroupsApiV1BoardGroupsGet,
|
||||||
|
} from "@/api/generated/board-groups/board-groups";
|
||||||
|
import {
|
||||||
|
type listBoardsApiV1BoardsGetResponse,
|
||||||
|
useListBoardsApiV1BoardsGet,
|
||||||
|
} from "@/api/generated/boards/boards";
|
||||||
import {
|
import {
|
||||||
type dashboardMetricsApiV1MetricsDashboardGetResponse,
|
type dashboardMetricsApiV1MetricsDashboardGetResponse,
|
||||||
useDashboardMetricsApiV1MetricsDashboardGet,
|
useDashboardMetricsApiV1MetricsDashboardGet,
|
||||||
@@ -85,6 +96,7 @@ const DASHBOARD_RANGE_OPTIONS: Array<{ value: RangeKey; label: string }> = [
|
|||||||
const DASHBOARD_RANGE_SET = new Set<RangeKey>(
|
const DASHBOARD_RANGE_SET = new Set<RangeKey>(
|
||||||
DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
|
DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
|
||||||
);
|
);
|
||||||
|
const ALL_FILTER_VALUE = "all";
|
||||||
const DEFAULT_RANGE: RangeKey = "7d";
|
const DEFAULT_RANGE: RangeKey = "7d";
|
||||||
|
|
||||||
const formatPeriod = (value: string, bucket: BucketKey) => {
|
const formatPeriod = (value: string, bucket: BucketKey) => {
|
||||||
@@ -251,16 +263,111 @@ export default function DashboardPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const selectedRangeParam = searchParams.get("range");
|
const selectedRangeParam = searchParams.get("range");
|
||||||
|
const selectedGroupParam = searchParams.get("group");
|
||||||
|
const selectedBoardParam = searchParams.get("board");
|
||||||
const selectedRange: RangeKey =
|
const selectedRange: RangeKey =
|
||||||
selectedRangeParam &&
|
selectedRangeParam &&
|
||||||
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
|
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
|
||||||
? (selectedRangeParam as RangeKey)
|
? (selectedRangeParam as RangeKey)
|
||||||
: DEFAULT_RANGE;
|
: DEFAULT_RANGE;
|
||||||
|
const selectedGroupId =
|
||||||
|
selectedGroupParam && selectedGroupParam !== ALL_FILTER_VALUE
|
||||||
|
? selectedGroupParam
|
||||||
|
: null;
|
||||||
|
const selectedBoardId =
|
||||||
|
selectedBoardParam && selectedBoardParam !== ALL_FILTER_VALUE
|
||||||
|
? selectedBoardParam
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
||||||
|
listBoardsApiV1BoardsGetResponse,
|
||||||
|
ApiError
|
||||||
|
>(
|
||||||
|
{ limit: 200 },
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const boardGroupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
||||||
|
listBoardGroupsApiV1BoardGroupsGetResponse,
|
||||||
|
ApiError
|
||||||
|
>(
|
||||||
|
{ limit: 200 },
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const boards = useMemo(
|
||||||
|
() =>
|
||||||
|
boardsQuery.data?.status === 200
|
||||||
|
? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[boardsQuery.data],
|
||||||
|
);
|
||||||
|
const boardGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
boardGroupsQuery.data?.status === 200
|
||||||
|
? [...(boardGroupsQuery.data.data.items ?? [])].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[boardGroupsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredBoards = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedGroupId
|
||||||
|
? boards.filter((board) => board.board_group_id === selectedGroupId)
|
||||||
|
: boards,
|
||||||
|
[boards, selectedGroupId],
|
||||||
|
);
|
||||||
|
const selectedBoard = useMemo(
|
||||||
|
() => boards.find((board) => board.id === selectedBoardId) ?? null,
|
||||||
|
[boards, selectedBoardId],
|
||||||
|
);
|
||||||
|
const selectedGroup = useMemo(
|
||||||
|
() => boardGroups.find((group) => group.id === selectedGroupId) ?? null,
|
||||||
|
[boardGroups, selectedGroupId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const boardGroupOptions = useMemo<DropdownSelectOption[]>(
|
||||||
|
() => [
|
||||||
|
{ value: ALL_FILTER_VALUE, label: "All groups" },
|
||||||
|
...boardGroups.map((group) => ({ value: group.id, label: group.name })),
|
||||||
|
],
|
||||||
|
[boardGroups],
|
||||||
|
);
|
||||||
|
const boardOptions = useMemo<DropdownSelectOption[]>(
|
||||||
|
() => [
|
||||||
|
{ value: ALL_FILTER_VALUE, label: "All boards" },
|
||||||
|
...filteredBoards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[filteredBoards],
|
||||||
|
);
|
||||||
|
|
||||||
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
|
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
|
||||||
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
>(
|
>(
|
||||||
{ range_key: selectedRange },
|
{
|
||||||
|
range_key: selectedRange,
|
||||||
|
board_id: selectedBoardId ?? undefined,
|
||||||
|
group_id: selectedGroupId ?? undefined,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn),
|
||||||
@@ -356,6 +463,75 @@ export default function DashboardPage() {
|
|||||||
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
contentClassName="rounded-lg border border-slate-200"
|
contentClassName="rounded-lg border border-slate-200"
|
||||||
/>
|
/>
|
||||||
|
<DropdownSelect
|
||||||
|
value={selectedGroupId ?? ALL_FILTER_VALUE}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextGroupId =
|
||||||
|
value === ALL_FILTER_VALUE ? null : value;
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (nextGroupId) {
|
||||||
|
params.set("group", nextGroupId);
|
||||||
|
} else {
|
||||||
|
params.delete("group");
|
||||||
|
}
|
||||||
|
if (selectedBoardId) {
|
||||||
|
const selectedBoardRecord = boards.find(
|
||||||
|
(board) => board.id === selectedBoardId,
|
||||||
|
);
|
||||||
|
const boardVisibleInScope = nextGroupId
|
||||||
|
? selectedBoardRecord?.board_group_id === nextGroupId
|
||||||
|
: true;
|
||||||
|
if (!boardVisibleInScope) {
|
||||||
|
params.delete("board");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
options={boardGroupOptions}
|
||||||
|
ariaLabel="Dashboard board group filter"
|
||||||
|
placeholder="All groups"
|
||||||
|
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
contentClassName="rounded-lg border border-slate-200"
|
||||||
|
searchEnabled={false}
|
||||||
|
disabled={boardGroupsQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<DropdownSelect
|
||||||
|
value={selectedBoardId ?? ALL_FILTER_VALUE}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextBoardId =
|
||||||
|
value === ALL_FILTER_VALUE ? null : value;
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (nextBoardId) {
|
||||||
|
params.set("board", nextBoardId);
|
||||||
|
} else {
|
||||||
|
params.delete("board");
|
||||||
|
}
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
options={boardOptions}
|
||||||
|
ariaLabel="Dashboard board filter"
|
||||||
|
placeholder="All boards"
|
||||||
|
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||||
|
contentClassName="rounded-lg border border-slate-200"
|
||||||
|
searchEnabled={false}
|
||||||
|
disabled={boardsQuery.isLoading || boardOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
{selectedGroup ? (
|
||||||
|
<Link
|
||||||
|
href={`/board-groups/${selectedGroup.id}`}
|
||||||
|
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Open group
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{selectedBoard ? (
|
||||||
|
<Link
|
||||||
|
href={`/boards/${selectedBoard.id}`}
|
||||||
|
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Open board
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
const MENTION_MAX_OPTIONS = 8;
|
||||||
|
const MENTION_PATTERN = /(?:^|\s)@([A-Za-z0-9_-]{0,31})$/;
|
||||||
|
|
||||||
|
type MentionTarget = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BoardChatComposerProps = {
|
type BoardChatComposerProps = {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isSending?: boolean;
|
isSending?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
mentionSuggestions?: string[];
|
||||||
onSend: (content: string) => Promise<boolean>;
|
onSend: (content: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeMentionHandle = (raw: string): string | null => {
|
||||||
|
const trimmed = raw.trim().replace(/^@+/, "");
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const token = trimmed.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_-]/g, "") ?? "";
|
||||||
|
if (!token) return null;
|
||||||
|
if (!/^[A-Za-z]/.test(token)) return null;
|
||||||
|
return token.slice(0, 32).toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMentionTarget = (
|
||||||
|
text: string,
|
||||||
|
caret: number,
|
||||||
|
): MentionTarget | null => {
|
||||||
|
if (caret < 0 || caret > text.length) return null;
|
||||||
|
const prefix = text.slice(0, caret);
|
||||||
|
const match = prefix.match(MENTION_PATTERN);
|
||||||
|
if (!match) return null;
|
||||||
|
const query = (match[1] ?? "").toLowerCase();
|
||||||
|
const start = caret - query.length - 1;
|
||||||
|
return { start, end: caret, query };
|
||||||
|
};
|
||||||
|
|
||||||
function BoardChatComposerImpl({
|
function BoardChatComposerImpl({
|
||||||
placeholder = "Message the board lead. Tag agents with @name.",
|
placeholder = "Message the board lead. Tag agents with @name.",
|
||||||
isSending = false,
|
isSending = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
mentionSuggestions,
|
||||||
onSend,
|
onSend,
|
||||||
}: BoardChatComposerProps) {
|
}: BoardChatComposerProps) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [mentionTarget, setMentionTarget] = useState<MentionTarget | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const closeMenuTimeoutRef = useRef<number | null>(null);
|
||||||
const shouldFocusAfterSendRef = useRef(false);
|
const shouldFocusAfterSendRef = useRef(false);
|
||||||
|
|
||||||
|
const mentionOptions = useMemo(() => {
|
||||||
|
const handles = new Set<string>(["lead"]);
|
||||||
|
(mentionSuggestions ?? []).forEach((candidate) => {
|
||||||
|
const handle = normalizeMentionHandle(candidate);
|
||||||
|
if (handle) {
|
||||||
|
handles.add(handle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...handles];
|
||||||
|
}, [mentionSuggestions]);
|
||||||
|
|
||||||
|
const filteredMentionOptions = useMemo(() => {
|
||||||
|
if (!mentionTarget) return [];
|
||||||
|
const query = mentionTarget.query;
|
||||||
|
const startsWithMatches = mentionOptions.filter((option) =>
|
||||||
|
option.startsWith(query),
|
||||||
|
);
|
||||||
|
return startsWithMatches.slice(0, MENTION_MAX_OPTIONS);
|
||||||
|
}, [mentionOptions, mentionTarget]);
|
||||||
|
|
||||||
|
const activeIndex =
|
||||||
|
filteredMentionOptions.length > 0
|
||||||
|
? Math.min(activeMentionIndex, filteredMentionOptions.length - 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSending) return;
|
if (isSending) return;
|
||||||
if (!shouldFocusAfterSendRef.current) return;
|
if (!shouldFocusAfterSendRef.current) return;
|
||||||
@@ -29,6 +92,43 @@ function BoardChatComposerImpl({
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}, [isSending]);
|
}, [isSending]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeMenuTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshMentionTarget = useCallback(
|
||||||
|
(nextValue: string, caret: number) => {
|
||||||
|
const nextTarget = findMentionTarget(nextValue, caret);
|
||||||
|
setMentionTarget(nextTarget);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyMentionSelection = useCallback(
|
||||||
|
(handle: string) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea || !mentionTarget) return;
|
||||||
|
const replacement = `@${handle} `;
|
||||||
|
const nextValue =
|
||||||
|
value.slice(0, mentionTarget.start) +
|
||||||
|
replacement +
|
||||||
|
value.slice(mentionTarget.end);
|
||||||
|
setValue(nextValue);
|
||||||
|
setMentionTarget(null);
|
||||||
|
setActiveMentionIndex(0);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const nextCaret = mentionTarget.start + replacement.length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(nextCaret, nextCaret);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mentionTarget, value],
|
||||||
|
);
|
||||||
|
|
||||||
const send = useCallback(async () => {
|
const send = useCallback(async () => {
|
||||||
if (isSending || disabled) return;
|
if (isSending || disabled) return;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -37,16 +137,85 @@ function BoardChatComposerImpl({
|
|||||||
shouldFocusAfterSendRef.current = true;
|
shouldFocusAfterSendRef.current = true;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setValue("");
|
setValue("");
|
||||||
|
setMentionTarget(null);
|
||||||
|
setActiveMentionIndex(0);
|
||||||
}
|
}
|
||||||
}, [disabled, isSending, onSend, value]);
|
}, [disabled, isSending, onSend, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="relative">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setValue(nextValue);
|
||||||
|
refreshMentionTarget(
|
||||||
|
nextValue,
|
||||||
|
event.target.selectionStart ?? nextValue.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
refreshMentionTarget(
|
||||||
|
value,
|
||||||
|
event.currentTarget.selectionStart ?? value.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
refreshMentionTarget(
|
||||||
|
value,
|
||||||
|
event.currentTarget.selectionStart ?? value.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (closeMenuTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(closeMenuTimeoutRef.current);
|
||||||
|
}
|
||||||
|
closeMenuTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setMentionTarget(null);
|
||||||
|
setActiveMentionIndex(0);
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
refreshMentionTarget(
|
||||||
|
value,
|
||||||
|
event.currentTarget.selectionStart ?? value.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (filteredMentionOptions.length > 0 && mentionTarget) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveMentionIndex(
|
||||||
|
(prev) => (prev + 1) % filteredMentionOptions.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveMentionIndex(
|
||||||
|
(prev) =>
|
||||||
|
(prev - 1 + filteredMentionOptions.length) %
|
||||||
|
filteredMentionOptions.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" || event.key === "Tab") {
|
||||||
|
event.preventDefault();
|
||||||
|
const selected = filteredMentionOptions[activeIndex];
|
||||||
|
if (selected) {
|
||||||
|
applyMentionSelection(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setMentionTarget(null);
|
||||||
|
setActiveMentionIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.key !== "Enter") return;
|
if (event.key !== "Enter") return;
|
||||||
if (event.nativeEvent.isComposing) return;
|
if (event.nativeEvent.isComposing) return;
|
||||||
if (event.shiftKey) return;
|
if (event.shiftKey) return;
|
||||||
@@ -57,6 +226,31 @@ function BoardChatComposerImpl({
|
|||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
disabled={isSending || disabled}
|
disabled={isSending || disabled}
|
||||||
/>
|
/>
|
||||||
|
{mentionTarget && filteredMentionOptions.length > 0 ? (
|
||||||
|
<div className="absolute bottom-full left-0 z-20 mb-2 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||||
|
<div className="max-h-52 overflow-y-auto py-1">
|
||||||
|
{filteredMentionOptions.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
applyMentionSelection(option);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition ${
|
||||||
|
index === activeIndex
|
||||||
|
? "bg-slate-100 text-slate-900"
|
||||||
|
: "text-slate-700 hover:bg-slate-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono">@{option}</span>
|
||||||
|
<span className="text-xs text-slate-400">mention</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void send()}
|
onClick={() => void send()}
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ const renderMentionsInText = (text: string, keyPrefix: string): ReactNode => {
|
|||||||
return nodes;
|
return nodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMentions = (content: ReactNode, keyPrefix = "mention"): ReactNode => {
|
const renderMentions = (
|
||||||
|
content: ReactNode,
|
||||||
|
keyPrefix = "mention",
|
||||||
|
): ReactNode => {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return renderMentionsInText(content, keyPrefix);
|
return renderMentionsInText(content, keyPrefix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface TaskCardProps {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
due?: string;
|
due?: string;
|
||||||
|
isOverdue?: boolean;
|
||||||
approvalsPendingCount?: number;
|
approvalsPendingCount?: number;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
isBlocked?: boolean;
|
isBlocked?: boolean;
|
||||||
@@ -27,6 +28,7 @@ export function TaskCard({
|
|||||||
priority,
|
priority,
|
||||||
assignee,
|
assignee,
|
||||||
due,
|
due,
|
||||||
|
isOverdue = false,
|
||||||
approvalsPendingCount = 0,
|
approvalsPendingCount = 0,
|
||||||
tags = [],
|
tags = [],
|
||||||
isBlocked = false,
|
isBlocked = false,
|
||||||
@@ -157,8 +159,18 @@ export function TaskCard({
|
|||||||
<span>{assignee ?? "Unassigned"}</span>
|
<span>{assignee ?? "Unassigned"}</span>
|
||||||
</div>
|
</div>
|
||||||
{due ? (
|
{due ? (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<CalendarClock className="h-4 w-4 text-slate-400" />
|
className={cn(
|
||||||
|
"flex items-center gap-2",
|
||||||
|
isOverdue && "font-semibold text-rose-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarClock
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isOverdue ? "text-rose-500" : "text-slate-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<span>{due}</span>
|
<span>{due}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -82,14 +82,20 @@ const columns: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDueDate = (value?: string | null) => {
|
const resolveDueState = (
|
||||||
if (!value) return undefined;
|
task: Task,
|
||||||
const date = parseApiDatetime(value);
|
): { due: string | undefined; isOverdue: boolean } => {
|
||||||
if (!date) return undefined;
|
const date = parseApiDatetime(task.due_at);
|
||||||
return date.toLocaleDateString(undefined, {
|
if (!date) return { due: undefined, isOverdue: false };
|
||||||
|
const dueLabel = date.toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
||||||
|
return {
|
||||||
|
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
||||||
|
isOverdue,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type CardPosition = { left: number; top: number };
|
type CardPosition = { left: number; top: number };
|
||||||
@@ -359,10 +365,12 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
const filteredTasks =
|
const filteredTasks =
|
||||||
column.status === "review" && reviewBucket !== "all"
|
column.status === "review" && reviewBucket !== "all"
|
||||||
? columnTasks.filter((task) => {
|
? columnTasks.filter((task) => {
|
||||||
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
|
if (reviewBucket === "blocked")
|
||||||
|
return Boolean(task.is_blocked);
|
||||||
if (reviewBucket === "approval_needed")
|
if (reviewBucket === "approval_needed")
|
||||||
return (
|
return (
|
||||||
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
|
(task.approvals_pending_count ?? 0) > 0 &&
|
||||||
|
!task.is_blocked
|
||||||
);
|
);
|
||||||
if (reviewBucket === "waiting_lead")
|
if (reviewBucket === "waiting_lead")
|
||||||
return (
|
return (
|
||||||
@@ -384,7 +392,9 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
)}
|
)}
|
||||||
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
||||||
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
||||||
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
onDragLeave={
|
||||||
|
readOnly ? undefined : handleDragLeave(column.status)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -445,14 +455,17 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredTasks.map((task) => (
|
{filteredTasks.map((task) => {
|
||||||
|
const dueState = resolveDueState(task);
|
||||||
|
return (
|
||||||
<div key={task.id} ref={setCardRef(task.id)}>
|
<div key={task.id} ref={setCardRef(task.id)}>
|
||||||
<TaskCard
|
<TaskCard
|
||||||
title={task.title}
|
title={task.title}
|
||||||
status={task.status}
|
status={task.status}
|
||||||
priority={task.priority}
|
priority={task.priority}
|
||||||
assignee={task.assignee ?? undefined}
|
assignee={task.assignee ?? undefined}
|
||||||
due={formatDueDate(task.due_at)}
|
due={dueState.due}
|
||||||
|
isOverdue={dueState.isOverdue}
|
||||||
approvalsPendingCount={task.approvals_pending_count}
|
approvalsPendingCount={task.approvals_pending_count}
|
||||||
tags={task.tags}
|
tags={task.tags}
|
||||||
isBlocked={task.is_blocked}
|
isBlocked={task.is_blocked}
|
||||||
@@ -460,11 +473,14 @@ export const TaskBoard = memo(function TaskBoard({
|
|||||||
onClick={() => onTaskSelect?.(task)}
|
onClick={() => onTaskSelect?.(task)}
|
||||||
draggable={!readOnly && !task.is_blocked}
|
draggable={!readOnly && !task.is_blocked}
|
||||||
isDragging={draggingId === task.id}
|
isDragging={draggingId === task.id}
|
||||||
onDragStart={readOnly ? undefined : handleDragStart(task)}
|
onDragStart={
|
||||||
|
readOnly ? undefined : handleDragStart(task)
|
||||||
|
}
|
||||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user