diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e567208..5cb5f42b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### 新增 -- Organization Phase 3(组织架构切片):宿主内 org chart。Dashboard `/organization/org` 使用共享 `dashboard/src/org-ui` 的 `OrgChartPage`;树与 CRUD 走 `/api/org-module/org`(`{FREEOS_HOME}/org/org_chart.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 同时列出 Announcements 与 Org chart。未迁: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。 +- Organization Phase 3(组织架构切片):宿主内 org chart。Dashboard `/organization/org` 使用共享 `dashboard/src/org-ui` 的 `OrgChartPage`;树与 CRUD 走 `/api/org-module/org`(`{FREEOS_HOME}/org/org_chart.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 同时列出 Announcements 与 Org chart。未迁:Chat、汇报线/版本/导入导出图、其余 Open-12。 - Organization Phase 2:宿主内通知公告。Dashboard `/organization/announcements` 使用共享 `dashboard/src/org-ui` 页面;CRUD 走 `/api/org-module/announcements`(`{FREEOS_HOME}/org/announcements.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 写出同一页面的独立站骨架(完整打包仍待 Phase 5)。 ### 修复 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index a77940c7..bb9930db 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -184,12 +184,16 @@ "navWorkbench": "Workbench", "navAnnouncements": "Announcements", "navOrgChart": "Org chart", + "navEmployees": "Employees", "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", "orgChartTitle": "Organization chart", "orgChartBody": "Departments and people on the host org tree. This page lives in the host — no sidecar or second port.", - "openOrgChart": "Open org chart" + "openOrgChart": "Open org chart", + "employeesTitle": "Employees", + "employeesBody": "Directory of people and AI colleagues on the host org tree. Same store as the org chart — no sidecar or second port.", + "openEmployees": "Open employees" }, "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 d74a8e46..1537a7b8 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -184,12 +184,16 @@ "navWorkbench": "工作台", "navAnnouncements": "通知公告", "navOrgChart": "组织架构", + "navEmployees": "员工目录", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", "orgChartTitle": "组织架构图", "orgChartBody": "宿主内的部门与人员树。此页跑在宿主内,不需要边车或第二端口。", - "openOrgChart": "打开组织架构" + "openOrgChart": "打开组织架构", + "employeesTitle": "员工目录", + "employeesBody": "宿主组织树上的人机员工名册。与组织架构共用同一份数据,不需要边车或第二端口。", + "openEmployees": "打开员工目录" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 926fbae8..ed73bb72 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -24,6 +24,22 @@ describe("createOrgApiClient", () => { data: [{ id: 1, name: "HQ", parent_id: null, sort_order: 0 }], }; } + if (path === "/org-module/org/employees") { + return { + success: true, + data: [ + { + id: 4, + name: "Ada", + role: "Lead", + department_id: 1, + department_name: "HQ", + employee_type: "human", + status: "active", + }, + ], + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -36,6 +52,7 @@ describe("createOrgApiClient", () => { expect(listed.list[0]?.title).toBe("Hi"); expect(await client.announcements.unread()).toEqual({ count: 2 }); expect((await client.org.tree())[0]?.name).toBe("HQ"); + expect((await client.employees.list())[0]?.name).toBe("Ada"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 91387ee0..ec35f506 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -71,8 +71,11 @@ export interface OrgEmployee { skills?: string; avatar_emoji?: string; department_id: number; + department_name?: string | null; status: string; is_online?: boolean; + created_at?: string; + updated_at?: string | null; } export interface OrgDepartment { @@ -121,9 +124,35 @@ export interface OrgChartClient { removeEmployee(id: number): Promise; } +export interface EmployeeListParams { + type?: string; + department_id?: number; + status?: string; + search?: string; +} + +export interface EmployeeStats { + total: number; + ai: number; + human: number; + byDepartment: { department: string; count: number }[]; + byRole: { role: string; count: number }[]; +} + +export interface OrgEmployeesClient { + list(params?: EmployeeListParams): Promise; + get(id: number): Promise; + stats(): Promise; + listDepartments(): Promise; + create(body: EmployeeWrite): Promise; + update(id: number, body: EmployeeWrite): Promise; + remove(id: number): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; + employees: OrgEmployeesClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -302,5 +331,49 @@ export function createOrgApiClient(opts: { }, }; - return { announcements, org }; + const employees: OrgEmployeesClient = { + async list(params = {}) { + const query = new URLSearchParams(); + if (params.type) query.set("type", params.type); + if (params.department_id != null) { + query.set("department_id", String(params.department_id)); + } + if (params.status) query.set("status", params.status); + if (params.search) query.set("search", params.search); + const suffix = query.toString() ? `?${query.toString()}` : ""; + return unwrap( + await fetchJson>( + `${orgPrefix}/employees${suffix}`, + ), + ); + }, + async get(id) { + return unwrap( + await fetchJson>( + `${orgPrefix}/employees/${id}`, + ), + ); + }, + async stats() { + return unwrap( + await fetchJson>( + `${orgPrefix}/employees/stats`, + ), + ); + }, + async listDepartments() { + return org.listDepartments(); + }, + async create(body) { + return org.createEmployee(body); + }, + async update(id, body) { + return org.updateEmployee(id, body); + }, + async remove(id) { + return org.removeEmployee(id); + }, + }; + + return { announcements, org, employees }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 314265bc..9b848c25 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -1,4 +1,8 @@ /** Catalog keys that have a shared org-ui page (keep in sync with contract.py). */ -export const SHARED_ORG_UI_MODULES = ["announcements", "organization"] as const; +export const SHARED_ORG_UI_MODULES = [ + "announcements", + "organization", + "employees", +] 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 6ede59e3..eb47367a 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -6,18 +6,25 @@ export type { AnnouncementListData, AnnouncementWrite, DepartmentWrite, + EmployeeListParams, + EmployeeStats, EmployeeWrite, OrgAnnouncementsClient, OrgApiClient, OrgChartClient, OrgDepartment, OrgEmployee, + OrgEmployeesClient, OrgEnvelope, } from "./api/createClient"; export { AnnouncementPage } from "./pages/announcements/AnnouncementPage"; export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage"; export { OrgChartPage } from "./pages/org/OrgChartPage"; export type { OrgChartPageProps } from "./pages/org/OrgChartPage"; +export { EmployeesPage } from "./pages/employees/EmployeesPage"; +export type { EmployeesPageProps } from "./pages/employees/EmployeesPage"; +export { EmployeeDetailPage } from "./pages/employees/EmployeeDetailPage"; +export type { EmployeeDetailPageProps } from "./pages/employees/EmployeeDetailPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.test.tsx b/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.test.tsx new file mode 100644 index 00000000..d2fc5573 --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmployeeDetailPage } from "./EmployeeDetailPage"; +import type { OrgEmployeesClient } from "../../api/createClient"; + +function mockClient(): OrgEmployeesClient { + return { + list: vi.fn(), + get: vi.fn(async () => ({ + id: 4, + name: "Ada", + role: "Lead", + description: "Builds the team", + employee_type: "human", + department_id: 1, + department_name: "HQ", + skills: "org,python", + status: "active", + })), + stats: vi.fn(), + listDepartments: vi.fn(async () => [ + { id: 1, name: "HQ", parent_id: null, sort_order: 0 }, + ]), + create: vi.fn(), + update: vi.fn(async (_id, body) => ({ + id: 4, + name: body.name || "Ada", + role: body.role || "", + description: body.description || "", + employee_type: body.employee_type || "human", + department_id: body.department_id || 1, + department_name: "HQ", + skills: body.skills || "", + status: "active", + })), + remove: vi.fn(), + }; +} + +describe("EmployeeDetailPage", () => { + it("renders directory detail without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect( + await screen.findByTestId("org-ui-employee-detail"), + ).toBeInTheDocument(); + expect(screen.getAllByText("Ada").length).toBeGreaterThan(0); + expect(screen.getByText("Builds the team")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.get).toHaveBeenCalledWith(4); + }); + + it("lets an admin save edits through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-employee-edit")); + fireEvent.change(screen.getByTestId("org-employee-name"), { + target: { value: "Ada Lovelace" }, + }); + fireEvent.click(screen.getByTestId("org-employee-save")); + await waitFor(() => { + expect(client.update).toHaveBeenCalledWith( + 4, + expect.objectContaining({ name: "Ada Lovelace", department_id: 1 }), + ); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.tsx b/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.tsx new file mode 100644 index 00000000..8061d218 --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/EmployeeDetailPage.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Input, Select, Space, Tag, Typography } from "antd"; +import { ArrowLeft, User } from "lucide-react"; +import type { + OrgDepartment, + OrgEmployee, + OrgEmployeesClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { employeeLabels } from "./labels"; +import styles from "./EmployeesPage.module.css"; + +export interface EmployeeDetailPageProps { + client: OrgEmployeesClient; + session: OrgSession; + locale: OrgLocale; + employeeId: number; + onBack?: () => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +export function EmployeeDetailPage({ + client, + session, + locale, + employeeId, + onBack, +}: EmployeeDetailPageProps) { + const labels = employeeLabels(locale); + const [employee, setEmployee] = useState(null); + const [departments, setDepartments] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(""); + const [role, setRole] = useState(""); + const [description, setDescription] = useState(""); + const [skills, setSkills] = useState(""); + const [employeeType, setEmployeeType] = useState("human"); + const [departmentId, setDepartmentId] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + try { + const [row, depts] = await Promise.all([ + client.get(employeeId), + client.listDepartments(), + ]); + setEmployee(row); + setDepartments(depts); + setName(row.name); + setRole(row.role || ""); + setDescription(row.description || ""); + setSkills(row.skills || ""); + setEmployeeType(row.employee_type || "human"); + setDepartmentId(row.department_id); + } catch { + setEmployee(null); + } finally { + setLoading(false); + } + }, [client, employeeId]); + + useEffect(() => { + void load(); + }, [load]); + + const save = async () => { + if (!name.trim() || departmentId == null) { + setError(labels.required); + return; + } + try { + const row = await client.update(employeeId, { + name: name.trim(), + role, + description, + skills, + employee_type: employeeType, + department_id: departmentId, + }); + setEmployee(row); + setEditing(false); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + const deactivate = async () => { + if (!window.confirm(labels.confirmDeactivate)) return; + try { + await client.remove(employeeId); + onBack?.(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.confirmDeactivate); + } + }; + + if (loading) { + return ( +
+

{labels.loading}

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

+ {labels.notFound} +

+ {onBack ? ( + + ) : null} +
+ ); + } + + return ( +
+
+
+ {onBack ? ( +
+ {session.isAdmin ? ( + + {editing ? ( + <> + + + + ) : ( + + )} + + + ) : null} +
+ + {error ? {error} : null} + +
+
+ {labels.basic} + {editing ? ( +
+ setName(event.target.value)} + data-testid="org-employee-name" + /> + setRole(event.target.value)} + placeholder={labels.fieldRole} + /> + ({ + value: dept.id, + label: dept.name, + }))} + /> + setSkills(event.target.value)} + placeholder={labels.fieldSkillsHint} + /> +
+ ) : ( + <> +
+ {labels.fieldName} + {employee.name} +
+
+ {labels.fieldRole} + {employee.role || labels.noRole} +
+
+ {labels.fieldDepartment} + {employee.department_name || labels.noDepartment} +
+
+ {labels.fieldType} + + {employee.employee_type === "ai" + ? labels.typeAi + : labels.typeHuman} + +
+ + )} +
+ +
+ + {labels.fieldDescription} + + {editing ? ( + setDescription(event.target.value)} + /> + ) : ( +

+ {employee.description || labels.noDescription} +

+ )} +
+ +
+ {labels.fieldSkills} + {employee.skills ? ( +
+ {employee.skills + .split(",") + .map((skill) => skill.trim()) + .filter(Boolean) + .map((skill) => ( + {skill} + ))} +
+ ) : ( +

{labels.noSkills}

+ )} +
+
+
+ ); +} diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.module.css b/dashboard/src/org-ui/pages/employees/EmployeesPage.module.css new file mode 100644 index 00000000..85ddda5a --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.module.css @@ -0,0 +1,163 @@ +.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(140px, 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; + justify-content: space-between; + gap: 12px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.card { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px; + background: var(--fn-bg-container, #fff); + cursor: pointer; + text-align: left; + width: 100%; +} + +.cardHead { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.avatar { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--fn-bg, #f9fafb); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.cardTitle { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.cardMeta { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.skills { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.detailGrid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.detailCard { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 16px; + background: var(--fn-bg-container, #fff); +} + +.row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + font-size: 14px; + border-bottom: 1px solid var(--fn-border, #f0f0f0); +} + +.row: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; +} diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx new file mode 100644 index 00000000..5d8a235c --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EmployeesPage } from "./EmployeesPage"; +import type { OrgEmployeesClient } from "../../api/createClient"; + +function mockClient(): OrgEmployeesClient { + return { + list: vi.fn(async () => []), + get: vi.fn(), + stats: vi.fn(async () => ({ + total: 0, + ai: 0, + human: 0, + byDepartment: [], + byRole: [], + })), + listDepartments: vi.fn(async () => [ + { id: 1, name: "HQ", parent_id: null, sort_order: 0 }, + ]), + create: vi.fn(async (body) => ({ + id: 9, + name: body.name || "Ada", + role: body.role || "", + employee_type: body.employee_type || "human", + department_id: body.department_id || 1, + status: "active", + })), + update: vi.fn(), + remove: vi.fn(), + }; +} + +describe("EmployeesPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-employees")).toBeInTheDocument(); + expect(screen.getByText("Employees")).toBeInTheDocument(); + expect(screen.getByTestId("org-employees-empty")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets an admin create through the injected client", async () => { + const client = mockClient(); + const onOpenEmployee = vi.fn(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-employees-create")); + fireEvent.change(screen.getByTestId("org-employees-name"), { + target: { value: "Ada" }, + }); + fireEvent.click(screen.getByTestId("org-employees-submit")); + await waitFor(() => { + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ name: "Ada", department_id: 1 }), + ); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx b/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx new file mode 100644 index 00000000..06130a8e --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.tsx @@ -0,0 +1,323 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input, Modal, Select, Tag, Typography } from "antd"; +import { Plus, Search, Users } from "lucide-react"; +import type { + OrgDepartment, + OrgEmployee, + OrgEmployeesClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { employeeLabels } from "./labels"; +import styles from "./EmployeesPage.module.css"; + +export interface EmployeesPageProps { + client: OrgEmployeesClient; + session: OrgSession; + locale: OrgLocale; + onOpenEmployee?: (id: number) => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +type EmpForm = { + name: string; + role: string; + description: string; + employee_type: string; + skills: string; + avatar_emoji: string; + department_id: number | null; +}; + +const EMPTY_FORM: EmpForm = { + name: "", + role: "", + description: "", + employee_type: "human", + skills: "", + avatar_emoji: "👤", + department_id: null, +}; + +export function EmployeesPage({ + client, + session, + locale, + onOpenEmployee, +}: EmployeesPageProps) { + const labels = employeeLabels(locale); + const [items, setItems] = useState([]); + const [departments, setDepartments] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [filterType, setFilterType] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(""); + const [stats, setStats] = useState({ total: 0, ai: 0, human: 0 }); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [rows, nextStats, depts] = await Promise.all([ + client.list({ + type: filterType || undefined, + status: "active", + }), + client.stats(), + client.listDepartments(), + ]); + setItems(rows); + setStats({ + total: nextStats.total, + ai: nextStats.ai, + human: nextStats.human, + }); + setDepartments(depts); + } catch { + setItems([]); + setStats({ total: 0, ai: 0, human: 0 }); + } finally { + setLoading(false); + } + }, [client, filterType]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const query = search.trim().toLowerCase(); + const visible = useMemo( + () => + query + ? items.filter( + (emp) => + emp.name.toLowerCase().includes(query) || + (emp.role || "").toLowerCase().includes(query) || + (emp.skills || "").toLowerCase().includes(query), + ) + : items, + [items, query], + ); + + const openCreate = () => { + setError(""); + setForm({ + ...EMPTY_FORM, + department_id: departments[0]?.id ?? null, + avatar_emoji: "👤", + }); + setShowCreate(true); + }; + + const submitCreate = async () => { + const name = form.name.trim(); + if (!name || form.department_id == null) { + setError(labels.required); + return; + } + try { + const created = await client.create({ + name, + role: form.role, + description: form.description, + employee_type: form.employee_type, + skills: form.skills, + avatar_emoji: form.avatar_emoji || "👤", + department_id: form.department_id, + agent_type: form.employee_type === "ai" ? form.role || null : null, + }); + setShowCreate(false); + await fetchAll(); + onOpenEmployee?.(created.id); + } catch (err) { + setError(err instanceof Error ? err.message : labels.required); + } + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ {session.isAdmin ? ( + + ) : null} +
+ +
+
+
{stats.total}
+
{labels.total}
+
+
+
{stats.ai}
+
{labels.typeAi}
+
+
+
{stats.human}
+
{labels.typeHuman}
+
+
+ +
+ } + placeholder={labels.search} + value={search} + onChange={(event) => setSearch(event.target.value)} + style={{ maxWidth: 320 }} + data-testid="org-employees-search" + /> + + setForm((prev) => ({ ...prev, name: event.target.value })) + } + data-testid="org-employees-name" + /> + + setForm((prev) => ({ ...prev, role: event.target.value })) + } + /> + + setForm((prev) => ({ ...prev, department_id: value })) + } + placeholder={labels.fieldDepartment} + options={departments.map((dept) => ({ + value: dept.id, + label: dept.name, + }))} + data-testid="org-employees-department" + /> + + setForm((prev) => ({ ...prev, skills: event.target.value })) + } + /> + + setForm((prev) => ({ ...prev, description: event.target.value })) + } + /> +
+ +
+ ); +} diff --git a/dashboard/src/org-ui/pages/employees/labels.ts b/dashboard/src/org-ui/pages/employees/labels.ts new file mode 100644 index 00000000..f5c69abb --- /dev/null +++ b/dashboard/src/org-ui/pages/employees/labels.ts @@ -0,0 +1,85 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Employees", + subtitle: "People and AI colleagues on the host org directory.", + search: "Search name, role, or skills...", + loading: "Loading employees...", + empty: "No employees yet. Add a person after a department exists.", + emptyHint: + "Create a department on the org chart first, then add people here.", + add: "New employee", + allTypes: "All types", + typeHuman: "Human", + typeAi: "AI colleague", + fieldName: "Name", + fieldRole: "Role", + fieldDescription: "Responsibilities", + fieldType: "Type", + fieldDepartment: "Department", + fieldSkills: "Skills", + fieldSkillsHint: "Comma-separated tags", + fieldEmoji: "Avatar", + noRole: "No role assigned", + noDepartment: "Unassigned", + noSkills: "No skill tags", + noDescription: "No responsibilities have been set", + save: "Save", + create: "Create", + cancel: "Cancel", + edit: "Edit", + deactivate: "Deactivate", + confirmDeactivate: + "Deactivate this employee? They leave the active directory.", + required: "Name and department are required", + noMatch: "No people match this filter", + total: "Total", + back: "Back to directory", + notFound: "Employee not found", + basic: "Basic information", + details: "Profile", +}; + +const ZH: typeof EN = { + title: "员工目录", + subtitle: "宿主内的人机员工名册。", + search: "搜索姓名、岗位或技能...", + loading: "加载员工...", + empty: "还没有员工。先有部门后再添加人员。", + emptyHint: "请先在组织架构中创建部门,再回到这里加人。", + add: "新建员工", + allTypes: "全部类型", + typeHuman: "人类", + typeAi: "AI 同事", + fieldName: "姓名", + fieldRole: "岗位", + fieldDescription: "岗位职责", + fieldType: "类型", + fieldDepartment: "部门", + fieldSkills: "技能", + fieldSkillsHint: "逗号分隔的标签", + fieldEmoji: "头像", + noRole: "未分配岗位", + noDepartment: "未分配", + noSkills: "暂无技能标签", + noDescription: "暂未设置岗位职责", + save: "保存", + create: "创建", + cancel: "取消", + edit: "编辑", + deactivate: "停用", + confirmDeactivate: "确定停用该员工?停用后会离开在职名册。", + required: "姓名和部门不能为空", + noMatch: "没有匹配的人员", + total: "合计", + back: "返回名册", + notFound: "未找到该员工", + basic: "基本信息", + details: "资料", +}; + +export type EmployeeLabels = typeof EN; + +export function employeeLabels(locale: OrgLocale): EmployeeLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/employees/detail.tsx b/dashboard/src/pages/Organization/employees/detail.tsx new file mode 100644 index 00000000..f02871ba --- /dev/null +++ b/dashboard/src/pages/Organization/employees/detail.tsx @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + EmployeeDetailPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationEmployeeDetailPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("employees"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + const employeeId = Number(id); + + return ( + + navigate("/organization/employees")} + /> + + ); +} diff --git a/dashboard/src/pages/Organization/employees/index.tsx b/dashboard/src/pages/Organization/employees/index.tsx new file mode 100644 index 00000000..10b6e966 --- /dev/null +++ b/dashboard/src/pages/Organization/employees/index.tsx @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + EmployeesPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationEmployeesPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("employees"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + navigate(`/organization/employees/${id}`)} + /> + + ); +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 2bfa5b58..2fca3cd6 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -20,6 +20,10 @@ function renderOrg() { path="/organization/org" element={
org chart
} /> + employees} + /> experts} @@ -152,6 +156,7 @@ describe("OrganizationPage", () => { expect(screen.getByTestId("org-surfaces")).toBeInTheDocument(); expect(screen.getByTestId("org-open-announcements")).toBeInTheDocument(); expect(screen.getByTestId("org-open-chart")).toBeInTheDocument(); + expect(screen.getByTestId("org-open-employees")).toBeInTheDocument(); expect(screen.queryByTestId("org-mini-browser")).toBeNull(); expect(screen.queryByTestId("org-browser-frame")).toBeNull(); expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); @@ -214,6 +219,13 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host employees directory without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-employees")); + expect(await screen.findByTestId("org-employees-page")).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 17b7111c..6994a6a2 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -434,6 +434,22 @@ export default function OrganizationPage() { {t("organization.openOrgChart")} +
+ +

+ {t("organization.employeesTitle")} +

+

+ {t("organization.employeesBody")} +

+ +
{firstRun && ( diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index fae8e2c5..e8a98cd0 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,10 +1,10 @@ -import { Building2, Megaphone, Network } from "lucide-react"; +import { Building2, Megaphone, Network, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import type { PathTabsConfig } from "../../layouts/PageShell"; export function useOrgPathTabs( - active: "workbench" | "announcements" | "org", + active: "workbench" | "announcements" | "org" | "employees", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -26,6 +26,11 @@ export function useOrgPathTabs( label: t("organization.navOrgChart"), icon: , }, + { + value: "employees", + label: t("organization.navEmployees"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -36,6 +41,10 @@ export function useOrgPathTabs( navigate("/organization/org"); return; } + if (value === "employees") { + navigate("/organization/employees"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 1c095281..a1f315b3 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -69,6 +69,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization")).toBeNull(); expect(pathPermissionKeys("/organization/announcements")).toBeNull(); expect(pathPermissionKeys("/organization/org")).toBeNull(); + expect(pathPermissionKeys("/organization/employees")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -150,6 +151,10 @@ describe("unknown dashboard paths", () => { "organization", ); expect(resolveSelectedKey("/organization/org")).toBe("organization"); + expect(resolveSelectedKey("/organization/employees")).toBe("organization"); + expect(resolveSelectedKey("/organization/employees/4")).toBe( + "organization", + ); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index ddbe7389..2831f20c 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -22,6 +22,12 @@ const OrganizationAnnouncementsPage = lazy( () => import("../pages/Organization/announcements"), ); const OrganizationChartPage = lazy(() => import("../pages/Organization/org")); +const OrganizationEmployeesPage = lazy( + () => import("../pages/Organization/employees"), +); +const OrganizationEmployeeDetailPage = lazy( + () => import("../pages/Organization/employees/detail"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -77,6 +83,7 @@ export const pathToKey: Record = { "/organization": "organization", "/organization/announcements": "organization", "/organization/org": "organization", + "/organization/employees": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -215,6 +222,14 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/org", element: , }, + { + path: "/organization/employees", + element: , + }, + { + path: "/organization/employees/:id", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 4039d45e..1780e1fb 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` (shared `dashboard/src/org-ui`). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index 3cf8175d..ee1bcd71 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构切片已落地(宿主 org tree CRUD + `/organization/org` + `org-ui` OrgChartPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构与员工目录切片已落地(宿主 org tree / 员工 CRUD + `/organization/org` + `/organization/employees` + `org-ui` OrgChartPage / EmployeesPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构已迁;其余未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构与员工目录已迁;其余未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -57,7 +57,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | workspace | OpenApp `/app` | sidecar dashboard/inbox | Phase 3:Dashboard `/organization` 工作台扩展,复用 #55 原生页 | | **announcements** | OpenApp `/announcements` | sidecar `/api/announcements` | **Phase 2 切片** | | organization | OpenApp `/org` | sidecar `/api/org` | **Phase 3 切片已落地**:`/organization/org` + `/api/org-module/org` | -| employees | OpenApp `/employees` | sidecar `/api/employees` + 宿主 lifecycle `/api/org-module/employees` | Phase 3:目录 UI 迁入;lifecycle / spawn **已在宿主,勿重复造** | +| employees | OpenApp `/employees` | sidecar `/api/employees` + 宿主 lifecycle `/api/org-module/employees` | **Phase 3 切片已落地**:`/organization/employees` 目录 CRUD 复用 `org_chart.sqlite`;lifecycle / spawn **已在宿主,勿重复造** | | skills | OpenApp `/skills` | sidecar `/api/skills` + 宿主 `skill_bridge` | Phase 3:目录 UI 迁入;generate/publish **已在宿主** | | **chat** | OpenApp `/chat` | sidecar `/api/chats` | **不迁。** 对话走 FreeOS/Octop | | agents | OpenApp `/agents`(Agent Studio) | 源码有 `routes/agent-studio.ts`,**`server.ts` 当前未 `app.use`** | Phase 3 前必须先核实挂载;未挂载则只迁 UI、API 在宿主补齐 | @@ -126,7 +126,8 @@ uv run freeos org export-standalone --out dist/openxyos-web |---|---|---| | 公告 | `GET/POST/PUT/DELETE /api/announcements` | **先迁。** 宿主实现(建议 `/api/org-module/announcements` 或宿主挂 `/api/announcements`),`org-ui` client factory 指向宿主。独立导出站可同路径或经适配器改 base URL | | 组织树 | sidecar `/api/org` | **已迁。** 宿主 `/api/org-module/org`(tree / departments / employees 目录 CRUD),`{FREEOS_HOME}/org/org_chart.sqlite`。未迁:versions、reporting-lines、skills、import、头像上传 | -| 员工目录 / 任务 / 知识 / 反思 / 设置 | sidecar `/api/employees` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | +| 员工目录 | sidecar `/api/employees` | **已迁目录 CRUD。** 宿主 `/api/org-module/org/employees*`,与 org chart **同一** `{FREEOS_HOME}/org/org_chart.sqlite`。lifecycle 仍是 `GET /api/org-module/employees`(同事 registry)。未迁:人才市场、备选入职、资产离职清算、绩效、汇报线、技能绑定、头像上传 | +| 任务 / 知识 / 反思 / 设置 | sidecar `/api/tasks` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | | 未迁资源 | sidecar 或 `/api/org-module/sidecar/api/` 代理 | 可选 sidecar 仍可用;默认安装不启动它 | | Chat | sidecar `/api/chats` | **永久不迁** | @@ -191,7 +192,7 @@ org-ui → IdentityBridge.getSession() --- -## Phase 3 进度:组织架构(本切片) +## Phase 3 进度:组织架构 按 Phase 2 同一缝落地,**只迁 org chart**,不批量搬 Open-12。 @@ -207,7 +208,6 @@ org-ui → IdentityBridge.getSession() | 推迟项 | 原因 | |---|---| | Chat / 从架构图发起会话 | 对话运行时留在 FreeOS/Octop | -| 员工目录页 `/employees`、lifecycle spawn | 目录 UI 另切片;lifecycle 已在宿主,勿重复造 | | 汇报线、职级、技能绑定、架构版本 | 商业 OrgChart 扩展,不是本垂直切片的最低可用集 | | 导入文件 / 导出 PNG·SVG·PDF | 依赖 sidecar 上传与 html-to-image/jsPDF | | 头像上传与预设图 | 需要独立文件存储 | @@ -216,6 +216,39 @@ org-ui → IdentityBridge.getSession() --- +## Phase 3 进度:员工目录(本切片) + +按同一缝落地,**只迁员工目录**。目录行与组织架构共用 `org_chart.sqlite`,不另开第二套员工库。 + +### 数据模型(单一 SoT) + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 目录员工(人/AI 名册) | `{FREEOS_HOME}/org/org_chart.sqlite` `employees` | `/api/org-module/org/employees*` | 部门编制;OrgChartPage 与 EmployeesPage 读写同一行 | +| 数字同事 lifecycle | `{FREEOS_HOME}/tenants//employees/registry.json` | `GET /api/org-module/employees` · `/transition` · `/spawn` | 编译/晋升/挂聊天专家;**不是**目录 CRUD | + +openXYOS sidecar `/api/employees`(人才市场、备选、资产离职清算)仍是可选兼容面,默认路径不读它。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/employees`(`EmployeesPage` + `EmployeeDetailPage`),数据经 `createOrgApiClient().employees`。 +2. Dashboard 子路由:`/organization/employees`、`/organization/employees/:id`(工作台 path tabs + 入口卡)。 +3. 宿主 API:list / detail / stats / create / update / deactivate,管理员写;嵌入模式打宿主 JWT,不打 `:3780`。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `EmployeesPage` / `EmployeeDetailPage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从员工页发起会话 | 对话运行时留在 FreeOS/Octop | +| lifecycle spawn / transition UI | 已在宿主;工作台同事列表与 Experts 已覆盖 | +| 人才市场、备选入职、reserve ↔ internal | sidecar 另表;本切片不做第二套 talent DB | +| 资产离职清算、绩效、汇报线、技能绑定、头像上传 | 依赖未迁资源或文件存储 | +| 其余 Open-12(技能、智能体、任务、知识、反思、治理 UI、设置) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + ## 明确非目标 | 非目标 | 说明 | diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index 3f2a3ecd..a559f267 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -3,7 +3,7 @@ `freeos org export-standalone --out dist/openxyos-web` writes a scaffold that **imports the same org-ui pages** Dashboard mounts under `/organization/...`. -Phase 3 includes **Announcements** and **Org chart**: +Phase 3 includes **Announcements**, **Org chart**, and **Employees**: | Delivery | Route | Component | |---|---|---| @@ -11,8 +11,12 @@ Phase 3 includes **Announcements** and **Org chart**: | Standalone (this export) | `/announcements` | same import | | Dashboard | `/organization/org` | `dashboard/src/org-ui` → `OrgChartPage` | | Standalone (this export) | `/org` | same import | +| Dashboard | `/organization/employees` | `dashboard/src/org-ui` → `EmployeesPage` | +| Dashboard | `/organization/employees/:id` | `dashboard/src/org-ui` → `EmployeeDetailPage` | +| Standalone (this export) | `/employees` | same import | -Do not copy the pages into a second tree. +Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org +chart. Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index 0d36327b..46644560 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -162,7 +162,7 @@ }, { "name": "org-module", - "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements and org chart, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, and employees directory, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_chart.py b/src/octop/api/routers/org_chart.py index 329316f0..fff4d125 100644 --- a/src/octop/api/routers/org_chart.py +++ b/src/octop/api/routers/org_chart.py @@ -5,7 +5,7 @@ import asyncio from typing import Any -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -181,6 +181,55 @@ async def delete_department( return _ok() +@router.get("/org/employees", summary="List directory employees") +async def list_employees( + type: str = Query(default=""), + department_id: int | None = Query(default=None), + status: str = Query(default="active"), + search: str = Query(default=""), + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + rows = await asyncio.to_thread( + store.list_employees, + tenant_id=_tenant_id(server), + status=status or "active", + employee_type=type or None, + department_id=department_id, + search=search, + ) + return _ok(rows) + + +@router.get("/org/employees/stats", summary="Directory employee stats") +async def employee_stats( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + store = _store(server) + data = await asyncio.to_thread(store.employee_stats, tenant_id=_tenant_id(server)) + return _ok(data) + + +@router.get( + "/org/employees/{employee_id}", + summary="Directory employee detail", + response_model=None, +) +async def get_employee( + employee_id: int, + request: Request, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any] | JSONResponse: + store = _store(server) + row = await asyncio.to_thread(store.get_employee, employee_id, tenant_id=_tenant_id(server)) + if row is None: + return _fail(request, "org.chart.employee_not_found", 404) + return _ok(row) + + @router.post("/org/employees", summary="Create a directory employee", response_model=None) async def create_employee( body: EmployeeWriteBody, diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 39749802..559d083f 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -382,10 +382,11 @@ def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: help="Output directory for the standalone web scaffold.", ) def export_standalone(out_dir: Path) -> None: - """Scaffold a standalone Organization web from shared org-ui (Phase 3: Announcements + Org chart). + """Scaffold a standalone Organization web from shared org-ui (Phase 3). - Writes the shared module list and an App.tsx that imports AnnouncementPage - and OrgChartPage. Full Vite + Node packaging is Phase 5 (TODO in the generated README). + Writes the shared module list and an App.tsx that imports AnnouncementPage, + OrgChartPage, and EmployeesPage. Full Vite + Node packaging is Phase 5 + (TODO in the generated README). """ from octop.modules.org_os.export_standalone import write_standalone_scaffold diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 226ae0ce..30a2f2f9 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -5,7 +5,11 @@ from octop.modules.org_os.catalog import catalog_keys # Pages extracted into dashboard/src/org-ui and listed by export-standalone. -SHARED_ORG_UI_MODULES: tuple[str, ...] = ("announcements", "organization") +SHARED_ORG_UI_MODULES: tuple[str, ...] = ( + "announcements", + "organization", + "employees", +) def shared_org_ui_modules() -> list[str]: diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 3eb6f99e..29933cfa 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -1,8 +1,9 @@ """Minimal ``freeos org export-standalone`` scaffold (Phase 3). Full Vite + Node server packaging is Phase 5. This writes a snapshot that -lists the shared org-ui modules and points at the same AnnouncementPage -and OrgChartPage sources Dashboard mounts under ``/organization/...``. +lists the shared org-ui modules and points at the same AnnouncementPage, +OrgChartPage, and EmployeesPage sources Dashboard mounts under +``/organization/...``. """ from __future__ import annotations @@ -25,15 +26,19 @@ |---|---|---|---| | announcements | `/organization/announcements` | `/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | | organization | `/organization/org` | `/org` | `dashboard/src/org-ui` → `OrgChartPage` | +| employees | `/organization/employees` | `/employees` | `dashboard/src/org-ui` → `EmployeesPage` / `EmployeeDetailPage` | -Do **not** copy `AnnouncementPage.tsx` or `OrgChartPage.tsx` into a second tree. Edit `dashboard/src/org-ui`. +Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. + +Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org +chart. Host lifecycle colleagues stay on `GET /api/org-module/employees`. ## Phase 3 vs Phase 5 Phase 3 (this scaffold): - Shared module list (`src/modules.json`) -- Thin `src/App.tsx` that imports `AnnouncementPage` and `OrgChartPage` +- Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, and `EmployeesPage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -53,17 +58,24 @@ """ _APP_TSX = """\ -import { AnnouncementPage, OrgChartPage, SHARED_ORG_UI_MODULES } from "org-ui"; +import { + AnnouncementPage, + EmployeeDetailPage, + EmployeesPage, + OrgChartPage, + SHARED_ORG_UI_MODULES, +} from "org-ui"; import type { OrgAnnouncementsClient, OrgChartClient, + OrgEmployeesClient, OrgSession, } from "org-ui"; /** * Standalone shell stub. Phase 5 wires a real IdentityBridge + local JWT. * Page components are the same ones Dashboard mounts at - * /organization/announcements and /organization/org. + * /organization/announcements, /organization/org, and /organization/employees. */ const session: OrgSession = { userId: 0, @@ -75,6 +87,7 @@ export default function App(props: { client: OrgAnnouncementsClient; orgClient: OrgChartClient; + employeesClient: OrgEmployeesClient; locale?: "zh" | "en"; }) { return ( @@ -91,6 +104,19 @@ locale={props.locale ?? "en"} modules={SHARED_ORG_UI_MODULES} /> + + ); } @@ -102,7 +128,8 @@ "version": "0.0.0", "description": ( "Standalone Organization web export skeleton. " - "Pages come from dashboard/src/org-ui (Phase 3: Announcements + Org chart). " + "Pages come from dashboard/src/org-ui " + "(Phase 3: Announcements + Org chart + Employees). " "Full Node packaging is Phase 5." ), "type": "module", @@ -140,6 +167,14 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "standalone_route": "/org", "api": "/api/org-module/org", }, + "employees": { + "component": "EmployeesPage", + "detail_component": "EmployeeDetailPage", + "embedded_route": "/organization/employees", + "standalone_route": "/employees", + "api": "/api/org-module/org/employees", + "store": "{FREEOS_HOME}/org/org_chart.sqlite", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/src/octop/modules/org_os/org_chart/store.py b/src/octop/modules/org_os/org_chart/store.py index 4b53fe8b..2943eb99 100644 --- a/src/octop/modules/org_os/org_chart/store.py +++ b/src/octop/modules/org_os/org_chart/store.py @@ -292,27 +292,86 @@ def create_employee( def get_employee(self, employee_id: int, *, tenant_id: str) -> dict[str, Any] | None: with self._connect() as conn: row = conn.execute( - "SELECT * FROM employees WHERE id = ? AND tenant_id = ?", + """ + SELECT e.*, d.name AS department_name + FROM employees e + LEFT JOIN departments d ON d.id = e.department_id + WHERE e.id = ? AND e.tenant_id = ? + """, (employee_id, tenant_id), ).fetchone() if row is None: return None return self._decorate_employee(dict(row)) - def list_employees(self, *, tenant_id: str, status: str = "active") -> list[dict[str, Any]]: - where = ["tenant_id = ?"] + def list_employees( + self, + *, + tenant_id: str, + status: str = "active", + employee_type: str | None = None, + department_id: int | None = None, + search: str = "", + ) -> list[dict[str, Any]]: + where = ["e.tenant_id = ?"] params: list[Any] = [tenant_id] if status and status != "all": - where.append("status = ?") + where.append("e.status = ?") params.append(normalize_status(status)) + if employee_type: + where.append("e.employee_type = ?") + params.append(normalize_employee_type(employee_type)) + if department_id is not None: + where.append("e.department_id = ?") + params.append(int(department_id)) + needle = (search or "").strip() + if needle: + like = f"%{needle}%" + where.append("(e.name LIKE ? OR e.role LIKE ? OR e.skills LIKE ?)") + params.extend([like, like, like]) clause = " AND ".join(where) with self._connect() as conn: rows = conn.execute( - f"SELECT * FROM employees WHERE {clause} ORDER BY id", + f""" + SELECT e.*, d.name AS department_name + FROM employees e + LEFT JOIN departments d ON d.id = e.department_id + WHERE {clause} + ORDER BY e.id + """, params, ).fetchall() return [self._decorate_employee(dict(row)) for row in rows] + def employee_stats(self, *, tenant_id: str) -> dict[str, Any]: + employees = self.list_employees(tenant_id=tenant_id, status="active") + by_department: dict[str, int] = {} + by_role: dict[str, int] = {} + ai = 0 + human = 0 + for emp in employees: + if str(emp.get("employee_type") or "") == "ai": + ai += 1 + else: + human += 1 + dept = str(emp.get("department_name") or "") + by_department[dept] = by_department.get(dept, 0) + 1 + role = str(emp.get("role") or "").strip() + if role: + by_role[role] = by_role.get(role, 0) + 1 + return { + "total": len(employees), + "ai": ai, + "human": human, + "byDepartment": [ + {"department": name, "count": count} for name, count in by_department.items() + ], + "byRole": [ + {"role": name, "count": count} + for name, count in sorted(by_role.items(), key=lambda item: (-item[1], item[0])) + ][:10], + } + def update_employee( self, employee_id: int, diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index d7b8000d..88e41540 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -1,4 +1,4 @@ -"""``freeos org export-standalone`` writes the shared Announcements + Org chart seam.""" +"""``freeos org export-standalone`` writes the shared Announcements + Org + Employees seam.""" from __future__ import annotations @@ -10,24 +10,34 @@ from octop.cli.main import cli -def test_export_standalone_lists_announcements_and_org_chart(tmp_path: Path) -> None: +def test_export_standalone_lists_announcements_org_and_employees(tmp_path: Path) -> None: out = tmp_path / "openxyos-web" runner = CliRunner() result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out)]) assert result.exit_code == 0, result.output payload = json.loads(result.output) - assert payload["modules"] == ["announcements", "organization"] + assert payload["modules"] == ["announcements", "organization", "employees"] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) - assert modules["shared_org_ui_modules"] == ["announcements", "organization"] + assert modules["shared_org_ui_modules"] == [ + "announcements", + "organization", + "employees", + ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" assert modules["pages"]["organization"]["component"] == "OrgChartPage" assert modules["pages"]["organization"]["embedded_route"] == "/organization/org" + assert modules["pages"]["employees"]["component"] == "EmployeesPage" + assert modules["pages"]["employees"]["embedded_route"] == "/organization/employees" + assert modules["pages"]["employees"]["api"] == "/api/org-module/org/employees" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app + assert "EmployeesPage" in app + assert "EmployeeDetailPage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme assert "AnnouncementPage" in readme assert "OrgChartPage" in readme + assert "EmployeesPage" in readme diff --git a/tests/unit/test_org_chart.py b/tests/unit/test_org_chart.py index c0de681d..3dc38b17 100644 --- a/tests/unit/test_org_chart.py +++ b/tests/unit/test_org_chart.py @@ -42,6 +42,7 @@ def _client(tmp_path: Path, user: User) -> TestClient: def test_shared_organization_module_is_in_catalog() -> None: assert "organization" in SHARED_ORG_UI_MODULES assert "announcements" in SHARED_ORG_UI_MODULES + assert "employees" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog() @@ -68,6 +69,12 @@ def test_store_tree_create_and_isolate(tmp_path: Path) -> None: assert tree[0]["children"][0]["employees"][0]["name"] == "Ada" assert tree[0]["children"][0]["employees"][0]["is_online"] is False assert store.tree(tenant_id="other")[0]["name"] == "Other HQ" + listed = store.list_employees(tenant_id="default", search="Ada") + assert listed[0]["department_name"] == "Eng" + stats = store.employee_stats(tenant_id="default") + assert stats["total"] == 1 + assert stats["human"] == 1 + assert store.get_employee(listed[0]["id"], tenant_id="default")["name"] == "Ada" def test_store_delete_guards_and_deactivate(tmp_path: Path) -> None: @@ -114,6 +121,19 @@ def test_api_tree_create_update_delete(tmp_path: Path) -> None: assert emp.status_code == 200 emp_id = emp.json()["data"]["id"] + listed = admin.get("/api/org-module/org/employees") + assert listed.status_code == 200 + assert listed.json()["data"][0]["name"] == "Ada" + assert listed.json()["data"][0]["department_name"] == "Eng" + detail = admin.get(f"/api/org-module/org/employees/{emp_id}") + assert detail.json()["data"]["role"] == "Lead" + stats = admin.get("/api/org-module/org/employees/stats") + assert stats.json()["data"]["total"] == 1 + searched = admin.get("/api/org-module/org/employees?search=Ada&type=human") + assert len(searched.json()["data"]) == 1 + missing = admin.get("/api/org-module/org/employees/999999") + assert missing.status_code == 404 + tree = admin.get("/api/org-module/org/tree") assert tree.status_code == 200 roots = tree.json()["data"] @@ -147,6 +167,8 @@ def test_api_member_cannot_write(tmp_path: Path) -> None: tree = member.get("/api/org-module/org/tree") assert tree.status_code == 200 assert tree.json()["data"][0]["name"] == "HQ" + directory = member.get("/api/org-module/org/employees") + assert directory.status_code == 200 denied = member.post("/api/org-module/org/departments", json={"name": "Nope"}) assert denied.status_code == 403 assert denied.json()["success"] is False @@ -166,4 +188,9 @@ def test_org_chart_routes_registered() -> None: assert "/org/departments" in paths assert "/org/departments/{department_id}" in paths assert "/org/employees" in paths + assert "/org/employees/stats" in paths assert "/org/employees/{employee_id}" in paths + # Lifecycle colleagues stay on /employees; directory CRUD is /org/employees. + module_paths = {getattr(route, "path", "") for route in router.routes} + assert "/employees" in module_paths + assert "/employees/spawn" in module_paths