From 9dc5a97579e751b7718eea43106d6192164fc873 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 04:06:30 +0000 Subject: [PATCH] feat(org): Phase 3 in-host Tasks org-ui slice Add Organization Tasks as a host-owned work-item store under FREEOS_HOME/org/tasks.sqlite, with org-ui pages at /organization/tasks and /organization/tasks/:id. This is not Octop cron, project chat, or the optional sidecar. --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 6 +- dashboard/src/locales/zh.json | 6 +- dashboard/src/org-ui/api/createClient.test.ts | 7 + dashboard/src/org-ui/api/createClient.ts | 200 +++++++- dashboard/src/org-ui/contract.ts | 1 + dashboard/src/org-ui/index.ts | 13 + .../pages/tasks/TaskDetailPage.test.tsx | 120 +++++ .../src/org-ui/pages/tasks/TaskDetailPage.tsx | 391 ++++++++++++++++ .../org-ui/pages/tasks/TasksPage.module.css | 186 ++++++++ .../src/org-ui/pages/tasks/TasksPage.test.tsx | 99 ++++ .../src/org-ui/pages/tasks/TasksPage.tsx | 344 ++++++++++++++ dashboard/src/org-ui/pages/tasks/labels.ts | 121 +++++ .../src/pages/Organization/index.test.tsx | 11 + dashboard/src/pages/Organization/index.tsx | 15 + .../src/pages/Organization/orgPathTabs.tsx | 13 +- .../src/pages/Organization/tasks/detail.tsx | 52 +++ .../src/pages/Organization/tasks/index.tsx | 49 ++ dashboard/src/routes/controlAdminPath.test.ts | 3 + dashboard/src/routes/index.tsx | 13 + docs/architecture-integration.md | 2 +- docs/org-merge-plan.md | 46 +- scripts/org-export/README.md | 7 +- src/octop/api/openapi_meta.py | 2 +- src/octop/api/routers/org_module.py | 2 + src/octop/api/routers/org_tasks.py | 344 ++++++++++++++ src/octop/i18n/en.json | 9 + src/octop/i18n/zh.json | 9 + src/octop/modules/org_os/contract.py | 1 + src/octop/modules/org_os/export_standalone.py | 40 +- src/octop/modules/org_os/tasks/__init__.py | 13 + src/octop/modules/org_os/tasks/store.py | 439 ++++++++++++++++++ tests/unit/cli/test_org_export_standalone.py | 9 + tests/unit/test_org_tasks.py | 201 ++++++++ 34 files changed, 2758 insertions(+), 17 deletions(-) create mode 100644 dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx create mode 100644 dashboard/src/org-ui/pages/tasks/TasksPage.module.css create mode 100644 dashboard/src/org-ui/pages/tasks/TasksPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/tasks/TasksPage.tsx create mode 100644 dashboard/src/org-ui/pages/tasks/labels.ts create mode 100644 dashboard/src/pages/Organization/tasks/detail.tsx create mode 100644 dashboard/src/pages/Organization/tasks/index.tsx create mode 100644 src/octop/api/routers/org_tasks.py create mode 100644 src/octop/modules/org_os/tasks/__init__.py create mode 100644 src/octop/modules/org_os/tasks/store.py create mode 100644 tests/unit/test_org_tasks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11eb6ef8..e201028c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 3(任务切片):宿主内 Tasks。Dashboard `/organization/tasks` 与 `/organization/tasks/:id` 使用共享 `dashboard/src/org-ui` 的 `TasksPage` / `TaskDetailPage`。CRUD、状态流转、子任务与人工评论走 `/api/org-module/tasks*`,SQLite 在 `{FREEOS_HOME}/org/tasks.sqlite`。这是组织待办,不是 Octop cron,也不是项目/Chat 对话。`freeos org export-standalone` 同时列出 Tasks。未迁:Chat、附件上传、边车任务库、其余 Open-12。 - Organization Phase 3(知识切片):宿主内 Knowledge。Dashboard `/organization/knowledge` 使用共享 `dashboard/src/org-ui` 的 `KnowledgePage`。列表/详情/笔记走 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`(Octop `knowledge_bases` / `knowledge_documents`),与 Chat 检索同一套行。不克隆 openXYOS sidecar notes/files DB。上传、文件夹、嵌入模型仍在 `/knowledge-bases`。`freeos org export-standalone` 同时列出 Knowledge。未迁:Chat、边车知识库、其余 Open-12。 - Organization Phase 3(技能切片):宿主内 Skills。Dashboard `/organization/skills` 使用共享 `dashboard/src/org-ui` 的 `SkillsPage`。列表/详情走 `GET /api/org-module/skills` 与 `GET /api/org-module/skills/{slug}`,生成/发布仍是已有 `POST …/generate|publish`(`{FREEOS_HOME}/org-skills`,`skill_bridge`)。宿主 skill packages 只读列出,编辑仍在「个性化 → 技能包」。这不是第二套技能运行时。`freeos org export-standalone` 同时列出 Skills。未迁:Chat、边车市场/插件中心、其余 Open-12。 - Organization Phase 3(员工目录切片):宿主内 Employees。Dashboard `/organization/employees` 与 `/organization/employees/:id` 使用共享 `dashboard/src/org-ui` 的 `EmployeesPage` / `EmployeeDetailPage`。目录 list/detail/create/update 走 `/api/org-module/org/employees*`,与组织架构共用 `{FREEOS_HOME}/org/org_chart.sqlite`,不另开第二套员工库。lifecycle 同事仍是 `GET /api/org-module/employees`。`freeos org export-standalone` 同时列出 Announcements、Org chart、Employees。未迁:Chat、人才市场/备选/离职清算、绩效、汇报线、技能绑定、头像、其余 Open-12。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 991abf9c..af0fc2cb 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -188,6 +188,7 @@ "navSkills": "Skills", "navGovernance": "Governance", "navKnowledge": "Knowledge", + "navTasks": "Tasks", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -205,7 +206,10 @@ "openGovernance": "Open governance", "knowledgeTitle": "Knowledge", "knowledgeBody": "Lists the same FreeOS knowledge bases Chat retrieves from. Notes become host documents — no sidecar knowledge DB.", - "openKnowledge": "Open knowledge" + "openKnowledge": "Open knowledge", + "tasksTitle": "Tasks", + "tasksBody": "Organization work items on the host org store (FREEOS_HOME/org). Not Octop cron, not project chat, and not a sidecar.", + "openTasks": "Open tasks" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 2d8ff639..29c6a56c 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -188,6 +188,7 @@ "navSkills": "技能", "navGovernance": "治理", "navKnowledge": "知识", + "navTasks": "任务", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -205,7 +206,10 @@ "openGovernance": "打开治理", "knowledgeTitle": "知识", "knowledgeBody": "列出 Chat 检索用的同一套 FreeOS 知识库。笔记写入宿主文档,没有边车知识库。", - "openKnowledge": "打开知识" + "openKnowledge": "打开知识", + "tasksTitle": "任务", + "tasksBody": "宿主组织库里的待办(FREEOS_HOME/org)。不是 Octop 定时任务,也不是项目对话或边车。", + "openTasks": "打开任务" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index d5434452..08051e3a 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -63,6 +63,12 @@ describe("createOrgApiClient", () => { stats: { bases: 1, documents: 0, shared: 0, owned: 1 }, }; } + if (path === "/org-module/tasks") { + return { + success: true, + data: [{ id: 3, title: "Ship Tasks slice", status: "todo" }], + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -79,6 +85,7 @@ describe("createOrgApiClient", () => { expect((await client.governance.pauses()).pauses[0]?.pause_id).toBe("p1"); expect((await client.skills.list()).skills[0]?.slug).toBe("org-employees"); expect((await client.knowledge.list()).bases[0]?.name).toBe("Policies"); + expect((await client.tasks.list())[0]?.title).toBe("Ship Tasks slice"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 5f15b6cd..931d5fc7 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -365,6 +365,86 @@ export interface OrgKnowledgeClient { ): Promise; } +export type OrgTaskStatus = "todo" | "in_progress" | "review" | "done"; +export type OrgTaskPriority = "low" | "medium" | "high" | "critical"; + +export interface OrgTask { + id: number; + title: string; + description?: string; + status: OrgTaskStatus | string; + priority: OrgTaskPriority | string; + assigned_to?: number | null; + assignee_name?: string | null; + created_by?: number; + creator_name?: string; + created_at?: string; + updated_at?: string | null; + subtask_count?: number; + subtask_done?: number; + comment_count?: number; + subtasks?: OrgTaskSubtask[]; + comments?: OrgTaskComment[]; +} + +export interface OrgTaskSubtask { + id: number; + task_id: number; + title: string; + completed: number; + sort_order: number; +} + +export interface OrgTaskComment { + id: number; + task_id: number; + user_id?: number | null; + user_name?: string; + content: string; + comment_type: string; + created_at: string; +} + +export interface OrgTaskStats { + total: number; + todo: number; + in_progress: number; + review: number; + done: number; +} + +export interface OrgTaskWrite { + title?: string; + description?: string; + priority?: string; + assigned_to?: number | null; +} + +export interface OrgTaskListParams { + status?: string; + priority?: string; + assigned_to?: number; + search?: string; +} + +export interface OrgTasksClient { + list(params?: OrgTaskListParams): Promise; + stats(): Promise; + get(id: number): Promise; + create(body: OrgTaskWrite): Promise; + update(id: number, body: OrgTaskWrite): Promise; + remove(id: number): Promise; + transition(id: number, to: string): Promise; + addSubtask(id: number, title: string): Promise; + updateSubtask( + id: number, + subtaskId: number, + body: { title?: string; completed?: boolean | number }, + ): Promise; + removeSubtask(id: number, subtaskId: number): Promise; + addComment(id: number, content: string): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; @@ -372,6 +452,7 @@ export interface OrgApiClient { governance: OrgGovernanceClient; skills: OrgSkillsClient; knowledge: OrgKnowledgeClient; + tasks: OrgTasksClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -392,12 +473,14 @@ export function createOrgApiClient(opts: { governancePrefix?: string; skillsPrefix?: string; knowledgePrefix?: string; + tasksPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; const governancePrefix = opts.governancePrefix ?? "/org-module/governance"; const skillsPrefix = opts.skillsPrefix ?? "/org-module/skills"; const knowledgePrefix = opts.knowledgePrefix ?? "/org-module/knowledge"; + const tasksPrefix = opts.tasksPrefix ?? "/org-module/tasks"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -689,5 +772,120 @@ export function createOrgApiClient(opts: { }, }; - return { announcements, org, employees, governance, skills, knowledge }; + const tasks: OrgTasksClient = { + async list(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.priority) query.set("priority", params.priority); + if (params.assigned_to != null) { + query.set("assigned_to", String(params.assigned_to)); + } + if (params.search) query.set("search", params.search); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return unwrap( + await fetchJson>(`${tasksPrefix}${suffix}`), + ); + }, + async stats() { + return unwrap( + await fetchJson>(`${tasksPrefix}/stats`), + ); + }, + async get(id) { + return unwrap( + await fetchJson>(`${tasksPrefix}/${id}`), + ); + }, + async create(body) { + return unwrap( + await fetchJson>(tasksPrefix, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async update(id, body) { + return unwrap( + await fetchJson>(`${tasksPrefix}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + }, + async remove(id) { + await unwrap( + await fetchJson>(`${tasksPrefix}/${id}`, { + method: "DELETE", + }), + ); + }, + async transition(id, to) { + return unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/transition`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ to }), + }, + ), + ); + }, + async addSubtask(id, title) { + return unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/subtasks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }, + ), + ); + }, + async updateSubtask(id, subtaskId, body) { + return unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/subtasks/${subtaskId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + ); + }, + async removeSubtask(id, subtaskId) { + await unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/subtasks/${subtaskId}`, + { method: "DELETE" }, + ), + ); + }, + async addComment(id, content) { + return unwrap( + await fetchJson>( + `${tasksPrefix}/${id}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ), + ); + }, + }; + + return { + announcements, + org, + employees, + governance, + skills, + knowledge, + tasks, + }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index b02cb759..79c9bee8 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -6,6 +6,7 @@ export const SHARED_ORG_UI_MODULES = [ "skills", "governance", "knowledge", + "tasks", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 0502b26e..49e9ae8d 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -33,6 +33,15 @@ export type { OrgKnowledgePreview, OrgKnowledgeStats, OrgKnowledgeWrite, + OrgTask, + OrgTaskComment, + OrgTaskListParams, + OrgTaskPriority, + OrgTaskStats, + OrgTaskStatus, + OrgTaskSubtask, + OrgTaskWrite, + OrgTasksClient, OrgSkill, OrgSkillCatalogRow, OrgSkillGenerateResult, @@ -54,6 +63,10 @@ export { GovernancePage } from "./pages/governance/GovernancePage"; export type { GovernancePageProps } from "./pages/governance/GovernancePage"; export { KnowledgePage } from "./pages/knowledge/KnowledgePage"; export type { KnowledgePageProps } from "./pages/knowledge/KnowledgePage"; +export { TasksPage } from "./pages/tasks/TasksPage"; +export type { TasksPageProps } from "./pages/tasks/TasksPage"; +export { TaskDetailPage } from "./pages/tasks/TaskDetailPage"; +export type { TaskDetailPageProps } from "./pages/tasks/TaskDetailPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx new file mode 100644 index 00000000..8a3b5a75 --- /dev/null +++ b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TaskDetailPage } from "./TaskDetailPage"; +import type { OrgTasksClient } from "../../api/createClient"; + +function mockClient(): OrgTasksClient { + return { + list: vi.fn(), + stats: vi.fn(), + get: vi.fn(async () => ({ + id: 3, + title: "Ship Tasks slice", + description: "Host org tasks", + status: "todo", + priority: "high", + creator_name: "Ada", + created_at: "2026-09-19T00:00:00Z", + subtasks: [ + { id: 1, task_id: 3, title: "Draft", completed: 0, sort_order: 1 }, + ], + comments: [], + })), + create: vi.fn(), + update: vi.fn(async (_id, body) => ({ + id: 3, + title: body.title || "Ship Tasks slice", + description: body.description || "", + status: "todo", + priority: body.priority || "high", + subtasks: [], + comments: [], + })), + remove: vi.fn(), + transition: vi.fn(async (_id, to) => ({ + id: 3, + title: "Ship Tasks slice", + status: to, + priority: "high", + subtasks: [], + comments: [], + })), + addSubtask: vi.fn(async () => ({ + id: 2, + task_id: 3, + title: "Review", + completed: 0, + sort_order: 2, + })), + updateSubtask: vi.fn(), + removeSubtask: vi.fn(), + addComment: vi.fn(async () => ({ + id: 8, + task_id: 3, + content: "Looks good", + comment_type: "user", + created_at: "2026-09-19T00:00:00Z", + })), + }; +} + +const session = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +describe("TaskDetailPage", () => { + it("renders task detail without an iframe or chat runtime", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-task-detail")).toBeInTheDocument(); + expect(screen.getAllByText("Ship Tasks slice").length).toBeGreaterThan(0); + expect(screen.getByText("Host org tasks")).toBeInTheDocument(); + expect( + screen.getByText( + "Human notes on this work item. This is not agent chat.", + ), + ).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.get).toHaveBeenCalledWith(3); + }); + + it("saves edits and adds a comment through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-task-edit")); + fireEvent.change(screen.getByTestId("org-task-title"), { + target: { value: "Ship host Tasks" }, + }); + fireEvent.click(screen.getByTestId("org-task-save")); + await waitFor(() => { + expect(client.update).toHaveBeenCalledWith( + 3, + expect.objectContaining({ title: "Ship host Tasks" }), + ); + }); + fireEvent.change(screen.getByTestId("org-task-comment-input"), { + target: { value: "Looks good" }, + }); + fireEvent.click(screen.getByTestId("org-task-comment-add")); + await waitFor(() => { + expect(client.addComment).toHaveBeenCalledWith(3, "Looks good"); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx new file mode 100644 index 00000000..323f8ca6 --- /dev/null +++ b/dashboard/src/org-ui/pages/tasks/TaskDetailPage.tsx @@ -0,0 +1,391 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Input, Select, Space, Tag, Typography } from "antd"; +import { ArrowLeft, ListTodo } from "lucide-react"; +import type { OrgTask, OrgTasksClient } from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { taskLabels } from "./labels"; +import styles from "./TasksPage.module.css"; + +export interface TaskDetailPageProps { + client: OrgTasksClient; + session: OrgSession; + locale: OrgLocale; + taskId: number; + onBack?: () => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +function nextStatus(status: string): string | null { + if (status === "todo") return "in_progress"; + if (status === "in_progress") return "review"; + if (status === "review") return "done"; + return null; +} + +export function TaskDetailPage({ + client, + session: _session, + locale, + taskId, + onBack, +}: TaskDetailPageProps) { + const labels = taskLabels(locale); + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [newSubtask, setNewSubtask] = useState(""); + const [newComment, setNewComment] = useState(""); + const [error, setError] = useState(""); + + const statusLabel = (status: string) => + ({ + todo: labels.todo, + in_progress: labels.inProgress, + review: labels.review, + done: labels.done, + })[status] || status; + + const priorityLabel = (value: string) => + ({ + low: labels.priorityLow, + medium: labels.priorityMedium, + high: labels.priorityHigh, + critical: labels.priorityCritical, + })[value] || value; + + const nextLabel = (status: string) => + ({ + todo: labels.startWork, + in_progress: labels.submitReview, + review: labels.approveReview, + })[status] || ""; + + const load = useCallback(async () => { + setLoading(true); + try { + const row = await client.get(taskId); + setTask(row); + setTitle(row.title); + setDescription(row.description || ""); + setPriority(row.priority); + } catch { + setTask(null); + } finally { + setLoading(false); + } + }, [client, taskId]); + + useEffect(() => { + void load(); + }, [load]); + + const save = async () => { + if (!title.trim()) { + setError(labels.required); + return; + } + try { + const row = await client.update(taskId, { + title: title.trim(), + description, + priority, + }); + setTask(row); + setEditing(false); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const remove = async () => { + if (!window.confirm(labels.confirmDelete)) return; + try { + await client.remove(taskId); + onBack?.(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.confirmDelete); + } + }; + + const transition = async () => { + if (!task) return; + const to = nextStatus(task.status); + if (!to) return; + try { + setTask(await client.transition(taskId, to)); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const addSubtask = async () => { + if (!newSubtask.trim()) return; + try { + await client.addSubtask(taskId, newSubtask.trim()); + setNewSubtask(""); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const toggleSubtask = async (subtaskId: number, completed: number) => { + try { + await client.updateSubtask(taskId, subtaskId, { + completed: completed ? 0 : 1, + }); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const addComment = async () => { + if (!newComment.trim()) return; + try { + await client.addComment(taskId, newComment.trim()); + setNewComment(""); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + if (loading) { + return ( +
+

{labels.loading}

+
+ ); + } + + if (!task) { + return ( +
+

+ {labels.notFound} +

+ {onBack ? ( + + ) : null} +
+ ); + } + + const done = (task.subtasks || []).filter((row) => row.completed).length; + const total = (task.subtasks || []).length; + + return ( +
+
+
+ {onBack ? ( +
+ + + + +
+ + {error ? {error} : null} +

{labels.commentHint}

+ +
+
+
+ {editing ? ( + <> + setTitle(event.target.value)} + data-testid="org-task-title" + /> + setDescription(event.target.value)} + rows={4} + style={{ marginTop: 8 }} + data-testid="org-task-description" + /> + setNewSubtask(event.target.value)} + placeholder={labels.addSubtask} + onPressEnter={() => void addSubtask()} + data-testid="org-task-subtask-input" + /> + +
+
+ +
+ + {labels.comments} ({(task.comments || []).length}) + + {(task.comments || []).map((comment) => ( +
+
+ {comment.user_name || labels.unknown} ·{" "} + {(comment.created_at || "").replace("T", " ").slice(0, 16)} +
+

{comment.content}

+
+ ))} +
+ setNewComment(event.target.value)} + placeholder={labels.addComment} + rows={2} + data-testid="org-task-comment-input" + /> + +
+
+
+ +
+
+ {labels.status} + {nextStatus(task.status) ? ( + + ) : ( +

{labels.completed}

+ )} +
+
+ {labels.assignee} +

{task.assignee_name || labels.unassigned}

+
+
+
+ + ); +} diff --git a/dashboard/src/org-ui/pages/tasks/TasksPage.module.css b/dashboard/src/org-ui/pages/tasks/TasksPage.module.css new file mode 100644 index 00000000..c1b305d1 --- /dev/null +++ b/dashboard/src/org-ui/pages/tasks/TasksPage.module.css @@ -0,0 +1,186 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.stat { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 12px 14px; + background: var(--fn-bg-container, #fff); +} + +.statValue { + font-size: 22px; + font-weight: 650; + line-height: 1.2; +} + +.statLabel { + margin-top: 4px; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 12px 14px; + background: var(--fn-bg-container, #fff); + cursor: pointer; + text-align: left; + width: 100%; +} + +.rowBody { + flex: 1; + min-width: 160px; +} + +.rowTitle { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.rowMeta { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 12px; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.detailGrid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); + gap: 16px; +} + +@media (max-width: 720px) { + .detailGrid { + grid-template-columns: 1fr; + } +} + +.detailCard { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 16px; + background: var(--fn-bg-container, #fff); +} + +.rowLine { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + font-size: 14px; + border-bottom: 1px solid var(--fn-border, #f0f0f0); +} + +.rowLine:last-child { + border-bottom: none; +} + +.muted { + color: var(--fn-text-secondary, #667085); +} + +.body { + margin: 0; + white-space: pre-wrap; + line-height: 1.6; + font-size: 14px; +} + +.subtask { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; +} + +.comment { + padding: 8px 0; + border-bottom: 1px solid var(--fn-border, #f0f0f0); +} + +.comment:last-child { + border-bottom: none; +} + +.commentMeta { + font-size: 12px; + color: var(--fn-text-secondary, #667085); + margin-bottom: 4px; +} + +.addRow { + display: flex; + gap: 8px; + margin-top: 10px; +} diff --git a/dashboard/src/org-ui/pages/tasks/TasksPage.test.tsx b/dashboard/src/org-ui/pages/tasks/TasksPage.test.tsx new file mode 100644 index 00000000..33fb3586 --- /dev/null +++ b/dashboard/src/org-ui/pages/tasks/TasksPage.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TasksPage } from "./TasksPage"; +import type { OrgTasksClient } from "../../api/createClient"; + +function mockClient(): OrgTasksClient { + return { + list: vi.fn(async () => [ + { + id: 3, + title: "Ship Tasks slice", + description: "Host org tasks", + status: "todo", + priority: "high", + subtask_count: 0, + subtask_done: 0, + comment_count: 0, + }, + ]), + stats: vi.fn(async () => ({ + total: 1, + todo: 1, + in_progress: 0, + review: 0, + done: 0, + })), + get: vi.fn(), + create: vi.fn(async (body) => ({ + id: 9, + title: body.title || "New", + description: body.description || "", + status: "todo", + priority: body.priority || "medium", + })), + update: vi.fn(), + remove: vi.fn(), + transition: vi.fn(async (id, to) => ({ + id, + title: "Ship Tasks slice", + status: to, + priority: "high", + })), + addSubtask: vi.fn(), + updateSubtask: vi.fn(), + removeSubtask: vi.fn(), + addComment: vi.fn(), + }; +} + +const session = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +describe("TasksPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-tasks")).toBeInTheDocument(); + expect(screen.getByText("Tasks")).toBeInTheDocument(); + expect(screen.getByText("Ship Tasks slice")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets a signed-in user create through the injected client", async () => { + const client = mockClient(); + const onOpenTask = vi.fn(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-tasks-create")); + fireEvent.change(screen.getByTestId("org-tasks-title"), { + target: { value: "Write tests" }, + }); + fireEvent.click(screen.getByTestId("org-tasks-submit")); + await waitFor(() => { + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ title: "Write tests" }), + ); + }); + }); + + it("advances status from the list without opening chat", async () => { + const client = mockClient(); + render(); + fireEvent.click(await screen.findByTestId("org-task-next-3")); + await waitFor(() => { + expect(client.transition).toHaveBeenCalledWith(3, "in_progress"); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/tasks/TasksPage.tsx b/dashboard/src/org-ui/pages/tasks/TasksPage.tsx new file mode 100644 index 00000000..a5d9d520 --- /dev/null +++ b/dashboard/src/org-ui/pages/tasks/TasksPage.tsx @@ -0,0 +1,344 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type MouseEvent, +} from "react"; +import { Button, Input, Modal, Select, Space, Tag, Typography } from "antd"; +import { ListTodo, Plus, RefreshCw, Search } from "lucide-react"; +import type { OrgTask, OrgTasksClient } from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { taskLabels } from "./labels"; +import styles from "./TasksPage.module.css"; + +export interface TasksPageProps { + client: OrgTasksClient; + session: OrgSession; + locale: OrgLocale; + onOpenTask?: (id: number) => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +const EMPTY_STATS = { + total: 0, + todo: 0, + in_progress: 0, + review: 0, + done: 0, +}; + +function nextStatus(status: string): string | null { + if (status === "todo") return "in_progress"; + if (status === "in_progress") return "review"; + if (status === "review") return "done"; + return null; +} + +export function TasksPage({ + client, + session: _session, + locale, + onOpenTask, +}: TasksPageProps) { + const labels = taskLabels(locale); + const [items, setItems] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterPriority, setFilterPriority] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [error, setError] = useState(""); + + const statusLabel = (status: string) => + ({ + todo: labels.todo, + in_progress: labels.inProgress, + review: labels.review, + done: labels.done, + })[status] || status; + + const priorityLabel = (value: string) => + ({ + low: labels.priorityLow, + medium: labels.priorityMedium, + high: labels.priorityHigh, + critical: labels.priorityCritical, + })[value] || value; + + const nextLabel = (status: string) => + ({ + todo: labels.start, + in_progress: labels.submit, + review: labels.approve, + })[status] || ""; + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [rows, nextStats] = await Promise.all([ + client.list({ + status: filterStatus, + priority: filterPriority || undefined, + }), + client.stats(), + ]); + setItems(rows); + setStats(nextStats); + } catch { + setItems([]); + setStats(EMPTY_STATS); + } finally { + setLoading(false); + } + }, [client, filterPriority, filterStatus]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const query = search.trim().toLowerCase(); + const visible = useMemo( + () => + query + ? items.filter((row) => { + const hay = `${row.title} ${row.description || ""}`.toLowerCase(); + return hay.includes(query); + }) + : items, + [items, query], + ); + + const openCreate = () => { + setError(""); + setTitle(""); + setDescription(""); + setPriority("medium"); + setShowCreate(true); + }; + + const submitCreate = async () => { + if (!title.trim()) { + setError(labels.required); + return; + } + try { + const created = await client.create({ + title: title.trim(), + description: description.trim(), + priority, + }); + setShowCreate(false); + await fetchAll(); + onOpenTask?.(created.id); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const transition = async (task: OrgTask, event: MouseEvent) => { + event.stopPropagation(); + const to = nextStatus(task.status); + if (!to) return; + try { + await client.transition(task.id, to); + await fetchAll(); + } catch { + setError(labels.required); + } + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ + + + +
+ + {error ? {error} : null} +

{labels.hostHint}

+ +
+
+
{stats.total}
+
{labels.total}
+
+
+
{stats.todo}
+
{labels.todo}
+
+
+
{stats.in_progress}
+
{labels.inProgress}
+
+
+
{stats.done}
+
{labels.done}
+
+
+ +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={labels.search} + allowClear + data-testid="org-tasks-search" + /> + +
+ + {loading ? ( +

{labels.loading}

+ ) : visible.length === 0 ? ( +
+

{items.length === 0 ? labels.empty : labels.noMatch}

+

{labels.emptyHint}

+
+ ) : ( +
+ {visible.map((task) => ( + + ) : null} + + ))} +
+ )} + + setShowCreate(false)} + footer={ + + + + + } + > +
+ {labels.fieldTitle} + setTitle(event.target.value)} + data-testid="org-tasks-title" + /> +
+
+ {labels.fieldDescription} + setDescription(event.target.value)} + rows={3} + data-testid="org-tasks-description" + /> +
+
+ {labels.fieldPriority} +