Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

### 修复
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,16 @@
"navWorkbench": "工作台",
"navAnnouncements": "通知公告",
"navOrgChart": "组织架构",
"navEmployees": "员工目录",
"announcementsTitle": "通知公告",
"announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。",
"openAnnouncements": "打开通知公告",
"orgChartTitle": "组织架构图",
"orgChartBody": "宿主内的部门与人员树。此页跑在宿主内,不需要边车或第二端口。",
"openOrgChart": "打开组织架构"
"openOrgChart": "打开组织架构",
"employeesTitle": "员工目录",
"employeesBody": "宿主组织树上的人机员工名册。与组织架构共用同一份数据,不需要边车或第二端口。",
"openEmployees": "打开员工目录"
},
"systemSettings": {
"subtitle": "工作台、远程桌面与主机管理集中在这里。"
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/org-ui/api/createClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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();
});

Expand Down
75 changes: 74 additions & 1 deletion dashboard/src/org-ui/api/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,9 +124,35 @@ export interface OrgChartClient {
removeEmployee(id: number): Promise<void>;
}

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<OrgEmployee[]>;
get(id: number): Promise<OrgEmployee>;
stats(): Promise<EmployeeStats>;
listDepartments(): Promise<OrgDepartment[]>;
create(body: EmployeeWrite): Promise<OrgEmployee>;
update(id: number, body: EmployeeWrite): Promise<OrgEmployee>;
remove(id: number): Promise<void>;
}

export interface OrgApiClient {
announcements: OrgAnnouncementsClient;
org: OrgChartClient;
employees: OrgEmployeesClient;
}

async function unwrap<T>(payload: OrgEnvelope<T>): Promise<T> {
Expand Down Expand Up @@ -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<OrgEnvelope<OrgEmployee[]>>(
`${orgPrefix}/employees${suffix}`,
),
);
},
async get(id) {
return unwrap(
await fetchJson<OrgEnvelope<OrgEmployee>>(
`${orgPrefix}/employees/${id}`,
),
);
},
async stats() {
return unwrap(
await fetchJson<OrgEnvelope<EmployeeStats>>(
`${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 };
}
6 changes: 5 additions & 1 deletion dashboard/src/org-ui/contract.ts
Original file line number Diff line number Diff line change
@@ -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];
7 changes: 7 additions & 0 deletions dashboard/src/org-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions dashboard/src/org-ui/pages/employees/EmployeeDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<EmployeeDetailPage
client={client}
session={{
userId: 1,
displayName: "Ada",
role: "admin",
isAdmin: true,
}}
locale="en"
employeeId={4}
/>,
);
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(
<EmployeeDetailPage
client={client}
session={{
userId: 1,
displayName: "Ada",
role: "admin",
isAdmin: true,
}}
locale="en"
employeeId={4}
/>,
);
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 }),
);
});
});
});
Loading
Loading