feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAnnouncements } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
|
||||
export function AnnouncementsPage() {
|
||||
const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告" subtitle="全员查看公司通知、制度链接和 FAQ 入口。" />
|
||||
<div className="space-y-4">
|
||||
{(announcementsQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="card-std p-6">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.summary}</div>
|
||||
{item.linkUrl ? <a className="mt-3 inline-block text-sm text-primary" href={item.linkUrl} target="_blank" rel="noreferrer">查看链接</a> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnnouncementsPage;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
completeEmployeeChecklistTask,
|
||||
fetchEmployeeProfile,
|
||||
patchEmployeeProfile,
|
||||
regularizeEmployee,
|
||||
skipEmployeeChecklistTask,
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
} from "../api/employees";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
queryKey: ["employee-profile", employeeId],
|
||||
queryFn: () => fetchEmployeeProfile(employeeId),
|
||||
});
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const [form, setForm] = useState({
|
||||
workLocation: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
workLocation: profile.employee.workLocation ?? "",
|
||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
||||
notes: profile.employee.notes ?? "",
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => patchEmployeeProfile(employeeId, { ...form }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("档案已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const taskMutation = useMutation({
|
||||
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
||||
action === "complete"
|
||||
? completeEmployeeChecklistTask(employeeId, taskId)
|
||||
: skipEmployeeChecklistTask(employeeId, taskId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("清单已更新");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "更新清单失败")),
|
||||
});
|
||||
|
||||
const regularizeMutation = useMutation({
|
||||
mutationFn: () => regularizeEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已转正");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "转正失败")),
|
||||
});
|
||||
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: () => transferEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已记录调岗事件");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "调岗失败")),
|
||||
});
|
||||
|
||||
const offboardingMutation = useMutation({
|
||||
mutationFn: () => startEmployeeOffboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已发起离职");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
||||
});
|
||||
|
||||
if (profileQuery.isLoading || !profile) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`${profile.employee.name} 档案`}
|
||||
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => regularizeMutation.mutate()} disabled={regularizeMutation.isPending}>
|
||||
转正
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
||||
记录调岗
|
||||
</Button>
|
||||
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="card-std p-6 space-y-4 xl:col-span-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">账号状态</div>
|
||||
<div className="mt-2 flex gap-2"><StatusBadge status={profile.employee.status} /><StatusBadge status={profile.employee.employmentStatus} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">部门 / 上级</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">入职 / 试用期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">离职日期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.lastWorkingDate ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">办公地点</label>
|
||||
<Input value={form.workLocation} onChange={(event) => setForm((prev) => ({ ...prev, workLocation: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系人</label>
|
||||
<Input value={form.emergencyContactName} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactName: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系电话</label>
|
||||
<Input value={form.emergencyContactPhone} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">备注</label>
|
||||
<Textarea value={form.notes} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">生命周期事件</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.lifecycle.events.length ? profile.lifecycle.events.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.eventType}</div>
|
||||
<div className="mt-1 text-zinc-500">生效日期:{item.effectiveDate ?? "-"}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">暂无生命周期事件</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">名下资产</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.assignedAssets.length ? profile.assignedAssets.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.assetNo} · {item.name}</div>
|
||||
<div className="mt-1 text-zinc-500">位置:{item.location ?? "-"} · 状态:{item.status}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">当前无挂名资产</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">Checklist</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>场景</TableHead>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profile.lifecycle.checklistTasks.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.scene}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "complete" })}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "skip" })}>跳过</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeeProfilePage;
|
||||
@@ -40,6 +40,10 @@ function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "announcements_v1") return "公告中心";
|
||||
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
|
||||
if (flagKey === "cutover_console_v1") return "切换控制台";
|
||||
if (flagKey === "presence_board_v1") return "团队状态板";
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchEmployees,
|
||||
regularizeEmployee,
|
||||
startEmployeeOffboarding,
|
||||
startEmployeeOnboarding,
|
||||
} from "../api/employees";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const employeesQuery = useQuery({
|
||||
queryKey: ["employees", "people-page", q],
|
||||
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, q: q || undefined }),
|
||||
});
|
||||
|
||||
const onboardingMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已发起入职流程");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "发起入职失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const regularizeMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已完成转正");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "转正失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const offboardingMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已发起离职流程");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "发起离职失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const items = employeesQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="人员管理"
|
||||
subtitle="小团队用这里管入职、转正、离职与档案入口。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/users">先新建账号</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-std p-4">
|
||||
<Input placeholder="搜索姓名、工号、手机号、邮箱" value={q} onChange={(event) => setQ(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>账号状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to={`/people/${item.id}`}>查看档案</Link>
|
||||
</Button>
|
||||
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
||||
发起入职
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||
转正
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PeoplePage;
|
||||
@@ -5,21 +5,21 @@ import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { readRecentVisits } from "../lib/recentVisits";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
function domainLabel(domain: string) {
|
||||
if (domain === "reimbursement") return "报销";
|
||||
if (domain === "leave") return "请假";
|
||||
if (domain === "purchase_request") return "采购";
|
||||
if (domain === "travel_request") return "差旅";
|
||||
return domain;
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const portalQuery = useQuery({
|
||||
queryKey: ["portal-home"],
|
||||
queryFn: () => fetchPortalHome(),
|
||||
});
|
||||
const { permissions } = useAuth();
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const recentVisits = readRecentVisits();
|
||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
@@ -31,9 +31,8 @@ export function PortalHomePage() {
|
||||
: "Portal 加载失败";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle="统一入口与待办概览。" />
|
||||
<PageHeader title="Portal" subtitle="小团队统一入口与高频待办概览。" />
|
||||
<div className="card-std p-6 text-sm text-red-600">{message}</div>
|
||||
<div className="card-std p-6 text-sm text-zinc-600">如 `portal_home_v1` 尚未开启,可先使用 `仪表盘`。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +43,19 @@ export function PortalHomePage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle={`你好,${payload.greeting.name}。`} />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<div className="card-std border-amber-200 bg-amber-50 p-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">工作区尚未初始化</div>
|
||||
<div className="text-sm text-zinc-600">建议先完成小团队模式的导航与模块设置,再邀请全员使用。</div>
|
||||
</div>
|
||||
<Button asChild type="button">
|
||||
<Link to="/system/workspace">去初始化</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{payload.cards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
@@ -85,6 +96,12 @@ export function PortalHomePage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{payload.todayPresence ? (
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||
今日状态:<span className="font-medium text-zinc-900">{payload.todayPresence.label}</span>
|
||||
{payload.todayPresence.note ? <div className="mt-1 text-zinc-500">{payload.todayPresence.note}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
@@ -115,19 +132,41 @@ export function PortalHomePage() {
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">待办与风险</h3>
|
||||
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
|
||||
我的待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
|
||||
</Link>
|
||||
{payload.riskSummary ? (
|
||||
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
|
||||
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
|
||||
<h3 className="text-base font-semibold text-zinc-900">工作台摘要</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{payload.teamPresence ? (
|
||||
<Link to={payload.teamPresence.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
今日团队状态:在岗 {payload.teamPresence.office} / 总数 {payload.teamPresence.total},请假 {payload.teamPresence.leave},未更新 {payload.teamPresence.unspecified}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.weeklySummary ? (
|
||||
<Link to={payload.weeklySummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
本周周报:未提交 {payload.weeklySummary.missingCount},需协助 {payload.weeklySummary.needsHelpCount}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.todoSummary ? (
|
||||
<Link to={payload.todoSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
我的待办:未完成 {payload.todoSummary.openCount},高优先级 {payload.todoSummary.highPriorityCount}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.peopleLifecycle ? (
|
||||
<Link to={payload.peopleLifecycle.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
人员闭环:入职 {payload.peopleLifecycle.onboardingCount},离职 {payload.peopleLifecycle.offboardingCount},资产待归还 {payload.peopleLifecycle.assetReturnPendingCount}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
|
||||
审批待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">当前没有开放风险项</div>
|
||||
)}
|
||||
{payload.riskSummary ? (
|
||||
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
|
||||
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortalHomePage;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
|
||||
const ITEMS = [
|
||||
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
|
||||
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
||||
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
||||
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
||||
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
||||
{ to: "/system/integrations/erp", title: "ERP 集成", description: "查看备用集成能力与出入口。" },
|
||||
];
|
||||
|
||||
export function SystemSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{ITEMS.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="card-std p-6 hover:bg-zinc-50">
|
||||
<div className="text-base font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.description}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchMyPresence, fetchTeamPresence, upsertMyPresence } from "../api/presence";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function statusLabel(status: string | null) {
|
||||
if (status === "office") return "在岗";
|
||||
if (status === "remote") return "居家";
|
||||
if (status === "out") return "外出";
|
||||
if (status === "travel") return "出差";
|
||||
if (status === "leave") return "请假";
|
||||
return "未更新";
|
||||
}
|
||||
|
||||
export function TeamPresencePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState<"office" | "remote" | "out" | "travel">("office");
|
||||
const [filter, setFilter] = useState<"all" | "onsite" | "away">("all");
|
||||
|
||||
const myPresenceQuery = useQuery({ queryKey: ["presence", "me", date], queryFn: () => fetchMyPresence(date || undefined) });
|
||||
const teamPresenceQuery = useQuery({ queryKey: ["presence", "team", date], queryFn: () => fetchTeamPresence(date || undefined) });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => upsertMyPresence({ date: date || undefined, status, note: note || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["presence", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["presence", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("今日状态已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "更新失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const teamItems = useMemo(() => {
|
||||
const items = teamPresenceQuery.data?.items ?? [];
|
||||
if (filter === "all") return items;
|
||||
if (filter === "onsite") return items.filter((item) => item.presence?.status === "office");
|
||||
return items.filter((item) => item.presence?.status !== "office");
|
||||
}, [filter, teamPresenceQuery.data?.items]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="团队状态" subtitle="不做打卡,只管理今天谁在岗 / 居家 / 外出 / 出差 / 请假。" />
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">更新我的今日状态</h3>
|
||||
<Input type="date" value={date} onChange={(event) => setDate(event.target.value)} />
|
||||
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value as typeof status)}>
|
||||
<option value="office">在岗</option>
|
||||
<option value="remote">居家</option>
|
||||
<option value="out">外出</option>
|
||||
<option value="travel">出差</option>
|
||||
</select>
|
||||
<Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选备注,如下午外出办事" />
|
||||
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>保存状态</Button>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-600">
|
||||
当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 xl:col-span-2">
|
||||
<h3 className="text-base font-semibold text-zinc-900">今日汇总</h3>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="text-zinc-500">{key}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
|
||||
</div>
|
||||
)) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队列表</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant={filter === "all" ? "default" : "secondary"} type="button" onClick={() => setFilter("all")}>全部</Button>
|
||||
<Button size="sm" variant={filter === "onsite" ? "default" : "secondary"} type="button" onClick={() => setFilter("onsite")}>在岗</Button>
|
||||
<Button size="sm" variant={filter === "away" ? "default" : "secondary"} type="button" onClick={() => setFilter("away")}>不在岗</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>部门</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamItems.map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employee.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
||||
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
||||
<TableCell>{item.presence?.note ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamPresencePage;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function TodosPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"todo" | "doing" | "done" | "cancelled" | "">("");
|
||||
const todosQuery = useQuery({
|
||||
queryKey: ["todos", statusFilter],
|
||||
queryFn: () => fetchTodos({ status: statusFilter || undefined }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createTodo({ title, description: description || undefined }),
|
||||
onSuccess: async () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已创建");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "创建待办失败")),
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => completeTodo(id),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已完成");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "完成待办失败")),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => cancelTodo(id),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已取消");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "取消待办失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">todo</option>
|
||||
<option value="doing">doing</option>
|
||||
<option value="done">done</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>新建待办</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(todosQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.priority}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>取消</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TodosPage;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTodo } from "../api/todos";
|
||||
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport } from "../api/weeklyReports";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function WeeklyReportsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canViewTeam = hasAnyPermission(permissions, ["report.dashboard.view", "system.users"]);
|
||||
const [weekStart, setWeekStart] = useState("");
|
||||
const reportQuery = useQuery({ queryKey: ["weekly-report", "me", weekStart], queryFn: () => fetchMyWeeklyReport(weekStart || undefined) });
|
||||
const teamQuery = useQuery({ enabled: canViewTeam, queryKey: ["weekly-report", "team", weekStart], queryFn: () => fetchTeamWeeklyReports(weekStart || undefined) });
|
||||
|
||||
const report = reportQuery.data?.item;
|
||||
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
setDraft({
|
||||
highlightsText: report.highlightsText,
|
||||
blockersText: report.blockersText,
|
||||
nextWeekText: report.nextWeekText,
|
||||
needsHelp: report.needsHelp,
|
||||
});
|
||||
}, [report]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitMyWeeklyReport({ weekStart: weekStart || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已提交");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "提交失败")),
|
||||
});
|
||||
|
||||
const createTodoMutation = useMutation({
|
||||
mutationFn: () => createTodo({
|
||||
title: `${report?.weekStart ?? "本周"} 周计划`,
|
||||
description: draft.nextWeekText || "来自周报的下周计划",
|
||||
source: "weekly_report",
|
||||
priority: draft.needsHelp ? "high" : "normal",
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("已从周报生成待办");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-zinc-500">选择周起始日(默认自动按周一归档)</div>
|
||||
<input className="input-std" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canViewTeam ? (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队汇总</h3>
|
||||
<div className="text-sm text-zinc-500">
|
||||
未提交 {teamQuery.data?.summary.missingCount ?? 0} · 需协助 {teamQuery.data?.summary.needsHelpCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(teamQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>{item.employee.name}</TableCell>
|
||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeeklyReportsPage;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
portal: "Portal",
|
||||
team: "团队",
|
||||
leaveRequests: "请假",
|
||||
reimbursements: "报销",
|
||||
assets: "资产",
|
||||
weeklyReports: "周报",
|
||||
todos: "待办",
|
||||
announcements: "公告",
|
||||
workspace: "工作区设置",
|
||||
people: "人员管理",
|
||||
workflowDefinitions: "流程定义",
|
||||
systemSettings: "系统设置",
|
||||
featureFlags: "功能开关",
|
||||
auditLogs: "审计日志",
|
||||
purchaseRequests: "采购申请",
|
||||
travelRequests: "差旅申请",
|
||||
budgets: "预算管理",
|
||||
paymentBatches: "支付批次",
|
||||
apOperations: "AP 运营",
|
||||
cutover: "切换控制台",
|
||||
erpIntegrations: "ERP 集成",
|
||||
};
|
||||
|
||||
export function WorkspaceSetupPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceQuery = useQuery({ queryKey: ["workspace-settings"], queryFn: fetchWorkspaceSettings });
|
||||
const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateWorkspaceSettings,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-settings"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["system-navigation"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("工作区设置已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "保存失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const workspace = workspaceQuery.data;
|
||||
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
|
||||
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
|
||||
|
||||
if (workspaceQuery.isLoading || !workspace) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="工作区设置"
|
||||
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">产品模式</div>
|
||||
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.profile}</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">单公司、账号密码、站内通知、桌面优先。</div>
|
||||
</div>
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">初始化状态</div>
|
||||
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.persisted ? "已完成" : "待初始化"}</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">首次管理员进入此页后保存一次,即完成小团队工作区初始化。</div>
|
||||
</div>
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">推荐角色预设</div>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{rolePresets.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border border-zinc-200 p-3">
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
<div className="mt-1 text-zinc-600">{item.description}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">角色:{item.roleCodes.join(" / ")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">模块显隐</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">主导航只保留高频入口;备用模块保留代码与数据,但默认放在管理员备用区。</p>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模块</TableHead>
|
||||
<TableHead>当前显隐</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{moduleEntries.map(([key, mode]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
|
||||
<TableCell>{mode}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["main", "admin", "hidden"] as const).map((nextMode) => (
|
||||
<Button
|
||||
key={nextMode}
|
||||
size="sm"
|
||||
variant={mode === nextMode ? "default" : "secondary"}
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => {
|
||||
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: nextMode };
|
||||
mutation.mutate({ moduleVisibility: nextVisibility });
|
||||
}}
|
||||
>
|
||||
{nextMode}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSetupPage;
|
||||
@@ -32,6 +32,13 @@ export interface CreateUserDialogProps {
|
||||
roleOptions: Array<{ code: string; label: string }>;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
];
|
||||
|
||||
export function CreateUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -204,6 +211,13 @@ export function CreateUserDialog({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>角色</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLE_PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setCreateRoles(new Set(preset.roles))}>
|
||||
预设:{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{roleOptions.map((item) => (
|
||||
<label className="flex items-center gap-2" key={item.code}>
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface UserRoleDialogProps {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
];
|
||||
|
||||
export function UserRoleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -57,7 +64,16 @@ export function UserRoleDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLE_PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setRoleDraft(new Set(preset.roles))}>
|
||||
预设:{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{roleOptions.map((item) => (
|
||||
<label className="flex items-center gap-2" key={item.code}>
|
||||
<input
|
||||
@@ -79,6 +95,7 @@ export function UserRoleDialog({
|
||||
<span>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
在新工单中引用
屏蔽一个用户