R60 A1-A6:首页工作台优化
这个提交包含在:
@@ -1,5 +1,36 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type PortalRecentActivity = {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "reimbursement.submit"
|
||||||
|
| "reimbursement.approve"
|
||||||
|
| "reimbursement.reject"
|
||||||
|
| "leave.submit"
|
||||||
|
| "leave.approve"
|
||||||
|
| "leave.reject"
|
||||||
|
| "attendance.clock_in"
|
||||||
|
| "attendance.clock_out"
|
||||||
|
| "attendance.supplement"
|
||||||
|
| "asset.assign"
|
||||||
|
| "asset.return";
|
||||||
|
actorName: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalTeamStatusItem = {
|
||||||
|
employeeId: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
presenceStatus: string | null;
|
||||||
|
attendanceClockIn: string | null;
|
||||||
|
attendanceClockOut: string | null;
|
||||||
|
onLeave: boolean;
|
||||||
|
leaveType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PortalHomePayload = {
|
export type PortalHomePayload = {
|
||||||
workspace: {
|
workspace: {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -55,8 +86,14 @@ export type PortalHomePayload = {
|
|||||||
hints: string[];
|
hints: string[];
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
|
recentActivities: PortalRecentActivity[];
|
||||||
|
teamStatusBoard: PortalTeamStatusItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchPortalHome() {
|
export async function fetchPortalHome() {
|
||||||
return apiRequestData<PortalHomePayload>("/api/portal/home");
|
return apiRequestData<PortalHomePayload>("/api/portal/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPortalTeamStatus() {
|
||||||
|
return apiRequestData<PortalTeamStatusItem[]>("/api/portal/team-status");
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
fetchMyTodayAttendance,
|
fetchMyTodayAttendance,
|
||||||
} from "../api/attendance";
|
} from "../api/attendance";
|
||||||
import { fetchNotifications } from "../api/notifications";
|
import { fetchNotifications } from "../api/notifications";
|
||||||
|
import { fetchPortalTeamStatus } from "../api/portal";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
|
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
|
||||||
import { fetchWorkflowTasks } from "../api/workflow";
|
import { fetchWorkflowTasks } from "../api/workflow";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
@@ -53,6 +55,21 @@ function formatDateTime(value: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatShortTime(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function presenceStatusLabel(item: PresenceItem | null) {
|
function presenceStatusLabel(item: PresenceItem | null) {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return "未更新";
|
return "未更新";
|
||||||
@@ -123,6 +140,50 @@ function resolveNotificationTargetPath(
|
|||||||
return "/notifications";
|
return "/notifications";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowEntityLabel(entityType: string | null | undefined) {
|
||||||
|
if (entityType === "leave") return "请假";
|
||||||
|
if (entityType === "reimbursement") return "报销";
|
||||||
|
if (entityType === "purchase_request") return "采购";
|
||||||
|
if (entityType === "travel_request") return "差旅";
|
||||||
|
return entityType || "审批";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTeamStatusBadge(item: {
|
||||||
|
onLeave: boolean;
|
||||||
|
leaveType: string | null;
|
||||||
|
presenceStatus: string | null;
|
||||||
|
attendanceClockIn: string | null;
|
||||||
|
}) {
|
||||||
|
if (item.onLeave) {
|
||||||
|
return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" };
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "office") {
|
||||||
|
return { label: "在岗", className: "badge-success" };
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "out") {
|
||||||
|
return {
|
||||||
|
label: "外出",
|
||||||
|
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "travel") {
|
||||||
|
return {
|
||||||
|
label: "差旅",
|
||||||
|
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "remote") {
|
||||||
|
return { label: "居家", className: "badge-neutral" };
|
||||||
|
}
|
||||||
|
if (!item.attendanceClockIn) {
|
||||||
|
return { label: "未打卡", className: "badge-neutral" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "已打卡",
|
||||||
|
className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function QuickLinkCard({
|
function QuickLinkCard({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
@@ -183,11 +244,13 @@ export function MobilePortalPage() {
|
|||||||
|
|
||||||
const pendingCountQuery = useQuery({
|
const pendingCountQuery = useQuery({
|
||||||
enabled: canAccessApprovals,
|
enabled: canAccessApprovals,
|
||||||
queryKey: ["mobile", "workflow", "pending-count"],
|
queryKey: ["mobile", "workflow", "pending-items"],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
|
||||||
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
|
});
|
||||||
return response.total;
|
|
||||||
},
|
const teamStatusQuery = useQuery({
|
||||||
|
queryKey: ["mobile", "portal", "team-status"],
|
||||||
|
queryFn: () => fetchPortalTeamStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const notificationsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
@@ -249,6 +312,8 @@ export function MobilePortalPage() {
|
|||||||
const presenceItem = presenceQuery.data?.item ?? null;
|
const presenceItem = presenceQuery.data?.item ?? null;
|
||||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||||
|
const teamStatusItems = teamStatusQuery.data ?? [];
|
||||||
|
const pendingTasks = pendingCountQuery.data?.items ?? [];
|
||||||
const notifications = notificationsQuery.data?.items ?? [];
|
const notifications = notificationsQuery.data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -333,6 +398,48 @@ export function MobilePortalPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<section className="card-std space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">团队状态</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">查看全员今日在岗、请假和打卡状态。</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge-neutral">{teamStatusItems.length} 人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
|
||||||
|
{teamStatusQuery.isError ? (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(teamStatusQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
|
||||||
|
<p className="text-sm text-zinc-500">暂无团队状态。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{teamStatusItems.map((item) => {
|
||||||
|
const badge = resolveTeamStatusBadge(item);
|
||||||
|
return (
|
||||||
|
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-zinc-500">
|
||||||
|
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className={badge.className}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card-std space-y-4 p-4">
|
<section className="card-std space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -369,20 +476,47 @@ export function MobilePortalPage() {
|
|||||||
<section className="card-std space-y-3 p-4">
|
<section className="card-std space-y-3 p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-zinc-900">待审批</div>
|
<div className="text-sm font-semibold text-zinc-900">待办聚合</div>
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"}
|
{canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
|
||||||
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
|
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canAccessApprovals ? (
|
{canAccessApprovals ? (
|
||||||
|
<>
|
||||||
<Button asChild type="button" className="w-full">
|
<Button asChild type="button" className="w-full">
|
||||||
<Link to="/mobile/approvals">进入移动审批</Link>
|
<Link to="/mobile/approvals">进入移动审批</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
|
||||||
|
{pendingCountQuery.isError ? (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{resolveErrorMessage(pendingCountQuery.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
|
||||||
|
<p className="text-sm text-zinc-500">当前没有审批待办。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingTasks.map((task) => (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
to={buildMobileApprovalsPath(task.id)}
|
||||||
|
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
|
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
|
||||||
你当前没有审批权限,审批页入口已禁用。
|
你当前没有审批权限,审批页入口已禁用。
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
clockInAttendance,
|
||||||
|
clockOutAttendance,
|
||||||
|
fetchMyTodayAttendance,
|
||||||
|
type AttendanceTodayResponse,
|
||||||
|
} from "../api/attendance";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import {
|
||||||
|
fetchPortalHome,
|
||||||
|
type PortalHomePayload,
|
||||||
|
type PortalRecentActivity,
|
||||||
|
type PortalTeamStatusItem,
|
||||||
|
} from "../api/portal";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
import { MetricCard } from "../components/patterns/MetricCard";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
@@ -23,6 +36,32 @@ type SummaryItem = {
|
|||||||
tone?: "normal" | "warning";
|
tone?: "normal" | "warning";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TodoAggregateItem = {
|
||||||
|
title: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
severity: "normal" | "warning" | "high";
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuickActionConfig = {
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
to?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
||||||
|
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
||||||
|
const TODO_SEVERITY_RANK = {
|
||||||
|
high: 3,
|
||||||
|
warning: 2,
|
||||||
|
normal: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
|
function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
|
||||||
return Boolean(item);
|
return Boolean(item);
|
||||||
}
|
}
|
||||||
@@ -47,6 +86,251 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
|||||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(error: unknown) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAttendanceFeatureDisabled(error: unknown) {
|
||||||
|
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - timestamp;
|
||||||
|
if (diffMs < 60 * 1000) {
|
||||||
|
return "刚刚";
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMinutes = Math.floor(diffMs / (60 * 1000));
|
||||||
|
if (diffMinutes < 60) {
|
||||||
|
return `${diffMinutes} 分钟前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
if (diffHours < 24) {
|
||||||
|
return `${diffHours} 小时前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays} 天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortTime(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWorkMinutes(value: number | null | undefined) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const hours = Math.floor(value / 60);
|
||||||
|
const minutes = value % 60;
|
||||||
|
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTodoAggregateItems(payload: PortalHomePayload) {
|
||||||
|
const items: TodoAggregateItem[] = [];
|
||||||
|
if (payload.pendingTasks.count > 0) {
|
||||||
|
items.push({
|
||||||
|
title: `审批待办 ${payload.pendingTasks.count} 条`,
|
||||||
|
sourceLabel: "审批",
|
||||||
|
severity: "warning",
|
||||||
|
href: payload.pendingTasks.href,
|
||||||
|
icon: "fact_check",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((payload.todoSummary?.openCount ?? 0) > 0) {
|
||||||
|
items.push({
|
||||||
|
title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`,
|
||||||
|
sourceLabel: "TODO",
|
||||||
|
severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal",
|
||||||
|
href: payload.todoSummary?.href ?? "/todos",
|
||||||
|
icon: "check_circle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const card of payload.cards) {
|
||||||
|
if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
title: `${card.title}:${card.value}`,
|
||||||
|
sourceLabel: "工作台",
|
||||||
|
severity: card.severity,
|
||||||
|
href: card.href,
|
||||||
|
icon: card.severity === "high" ? "priority_high" : "warning",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = new Map<string, TodoAggregateItem>();
|
||||||
|
for (const item of items) {
|
||||||
|
deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(deduped.values())
|
||||||
|
.sort((left, right) => {
|
||||||
|
const severityCompare = TODO_SEVERITY_RANK[right.severity] - TODO_SEVERITY_RANK[left.severity];
|
||||||
|
if (severityCompare !== 0) {
|
||||||
|
return severityCompare;
|
||||||
|
}
|
||||||
|
return left.title.localeCompare(right.title, "zh-CN");
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
|
||||||
|
if (item.onLeave) {
|
||||||
|
return {
|
||||||
|
label: item.leaveType ? `请假 · ${item.leaveType}` : "请假",
|
||||||
|
className: "badge-warning",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "office") {
|
||||||
|
return { label: "在岗", className: "badge-success" };
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "out") {
|
||||||
|
return {
|
||||||
|
label: "外出",
|
||||||
|
className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "travel") {
|
||||||
|
return {
|
||||||
|
label: "差旅",
|
||||||
|
className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "remote") {
|
||||||
|
return { label: "居家", className: "badge-neutral" };
|
||||||
|
}
|
||||||
|
if (!item.attendanceClockIn) {
|
||||||
|
return { label: "未打卡", className: "badge-neutral" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "已打卡",
|
||||||
|
className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||||
|
if (item.onLeave) {
|
||||||
|
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
|
||||||
|
}
|
||||||
|
if (item.attendanceClockIn && item.attendanceClockOut) {
|
||||||
|
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
|
||||||
|
}
|
||||||
|
if (item.attendanceClockIn) {
|
||||||
|
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "travel") {
|
||||||
|
return "今日差旅";
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "out") {
|
||||||
|
return "当前外出";
|
||||||
|
}
|
||||||
|
if (item.presenceStatus === "remote") {
|
||||||
|
return "今日居家办公";
|
||||||
|
}
|
||||||
|
return "暂无打卡记录";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClockAction(
|
||||||
|
attendanceToday: AttendanceTodayResponse | null,
|
||||||
|
state: {
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
featureDisabled: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (state.pending) {
|
||||||
|
return {
|
||||||
|
label: "处理中...",
|
||||||
|
description: "正在提交考勤动作",
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (state.isLoading) {
|
||||||
|
return {
|
||||||
|
label: "打卡读取中",
|
||||||
|
description: "正在读取今日考勤状态",
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (state.isError && !state.featureDisabled) {
|
||||||
|
return {
|
||||||
|
label: "打卡不可用",
|
||||||
|
description: "当前无法读取今日考勤状态",
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!attendanceToday) {
|
||||||
|
return {
|
||||||
|
label: "今日考勤",
|
||||||
|
description: "暂无可用考勤数据",
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!attendanceToday.needClockIn) {
|
||||||
|
return {
|
||||||
|
label: "今日无需打卡",
|
||||||
|
description: "系统已自动排除缺卡统计",
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!attendanceToday.item?.clockInAt) {
|
||||||
|
return {
|
||||||
|
label: "上班打卡",
|
||||||
|
description: "记录今日到岗时间",
|
||||||
|
disabled: false,
|
||||||
|
action: "clock-in",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
if (!attendanceToday.item.clockOutAt) {
|
||||||
|
return {
|
||||||
|
label: "下班打卡",
|
||||||
|
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
|
||||||
|
disabled: false,
|
||||||
|
action: "clock-out",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "今日已完成",
|
||||||
|
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
|
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
|
||||||
@@ -197,12 +481,196 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickActionTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
to,
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
}: QuickActionConfig) {
|
||||||
|
const baseClassName = disabled
|
||||||
|
? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400"
|
||||||
|
: "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50";
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={disabled
|
||||||
|
? "flex size-10 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-400"
|
||||||
|
: "flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to && !disabled) {
|
||||||
|
return <Link to={to} className={baseClassName}>{content}</Link>;
|
||||||
|
}
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={baseClassName} disabled={disabled} onClick={onClick}>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className={baseClassName}>{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionsPanel({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: QuickActionConfig[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{items.length ? items.map((item) => (
|
||||||
|
<QuickActionTile
|
||||||
|
key={item.key}
|
||||||
|
icon={item.icon}
|
||||||
|
label={item.label}
|
||||||
|
description={item.description}
|
||||||
|
to={item.to}
|
||||||
|
onClick={item.onClick}
|
||||||
|
disabled={item.disabled}
|
||||||
|
/>
|
||||||
|
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||||
|
return (
|
||||||
|
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.length ? items.map((item) => (
|
||||||
|
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={item.severity === "high"
|
||||||
|
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
|
||||||
|
: item.severity === "warning"
|
||||||
|
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-amber-50 text-amber-600"
|
||||||
|
: "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-600"}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<span className="badge-neutral">{item.sourceLabel}</span>
|
||||||
|
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
|
||||||
|
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-primary">进入处理</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
|
||||||
|
return (
|
||||||
|
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.length ? items.map((item) => {
|
||||||
|
const badge = resolveTeamStatusBadge(item);
|
||||||
|
return (
|
||||||
|
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
|
||||||
|
</div>
|
||||||
|
<span className={badge.className}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
|
||||||
|
return (
|
||||||
|
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.length ? items.map((item) => (
|
||||||
|
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<EmployeeAvatar name={item.actorName} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PortalHomePage() {
|
export function PortalHomePage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||||
|
const attendanceTodayQuery = useQuery({
|
||||||
|
queryKey: ["portal-home", "attendance", "today"],
|
||||||
|
queryFn: () => fetchMyTodayAttendance(),
|
||||||
|
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||||
|
});
|
||||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||||
const isManagerView = isManagerRole(permissions);
|
const isManagerView = isManagerRole(permissions);
|
||||||
|
|
||||||
|
const attendanceClockInMutation = useMutation({
|
||||||
|
mutationFn: () => clockInAttendance(),
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||||
|
]);
|
||||||
|
toast.success(
|
||||||
|
response.item.isLate
|
||||||
|
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
|
||||||
|
: "上班打卡成功",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendanceClockOutMutation = useMutation({
|
||||||
|
mutationFn: () => clockOutAttendance(),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||||
|
]);
|
||||||
|
toast.success("下班打卡成功");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(resolveErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (portalQuery.isLoading) {
|
if (portalQuery.isLoading) {
|
||||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||||
}
|
}
|
||||||
@@ -219,6 +687,15 @@ export function PortalHomePage() {
|
|||||||
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
|
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
|
||||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||||
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
|
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
|
||||||
|
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||||
|
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||||
|
const clockAction = resolveClockAction(attendanceToday, {
|
||||||
|
isLoading: attendanceTodayQuery.isLoading,
|
||||||
|
isError: attendanceTodayQuery.isError,
|
||||||
|
featureDisabled: attendanceFeatureDisabled,
|
||||||
|
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
|
||||||
|
});
|
||||||
|
const todoAggregateItems = buildTodoAggregateItems(payload);
|
||||||
|
|
||||||
const employeeSummaryItems = ([
|
const employeeSummaryItems = ([
|
||||||
{
|
{
|
||||||
@@ -272,6 +749,49 @@ export function PortalHomePage() {
|
|||||||
: null,
|
: null,
|
||||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||||
|
|
||||||
|
const quickActions: QuickActionConfig[] = [];
|
||||||
|
if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "leave",
|
||||||
|
icon: "event_available",
|
||||||
|
label: "提交请假",
|
||||||
|
description: "直接进入请假申请页",
|
||||||
|
to: "/leave-requests?action=create",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "expense",
|
||||||
|
icon: "request_quote",
|
||||||
|
label: "提交报销",
|
||||||
|
description: "进入报销工作台创建单据",
|
||||||
|
to: "/reimbursements",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!attendanceFeatureDisabled) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "attendance",
|
||||||
|
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
|
||||||
|
label: clockAction.label,
|
||||||
|
description: clockAction.description,
|
||||||
|
disabled: clockAction.disabled,
|
||||||
|
onClick: clockAction.action === "clock-in"
|
||||||
|
? () => attendanceClockInMutation.mutate()
|
||||||
|
: clockAction.action === "clock-out"
|
||||||
|
? () => attendanceClockOutMutation.mutate()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (payload.weeklyReport || payload.weeklySummary) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "weekly",
|
||||||
|
icon: "article",
|
||||||
|
label: "提交周报",
|
||||||
|
description: "进入周报页继续填写或查看团队缺口",
|
||||||
|
to: "/weekly-reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
<WorkbenchHero
|
<WorkbenchHero
|
||||||
@@ -309,6 +829,16 @@ export function PortalHomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||||
|
<QuickActionsPanel items={quickActions} />
|
||||||
|
<TodoAggregatePanel items={todoAggregateItems} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
|
||||||
|
<TeamStatusBoardPanel items={payload.teamStatusBoard} />
|
||||||
|
<RecentActivitiesPanel items={payload.recentActivities} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{isManagerView ? (
|
{isManagerView ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ function envelope(data: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
||||||
return page.route("**/api/me", async (route) => {
|
return page.route("**/api/me", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -101,6 +100,21 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockAttendanceToday(page: import("@playwright/test").Page) {
|
||||||
|
return page.route("**/api/attendance/me/today", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope({
|
||||||
|
workDate: "2026-03-22",
|
||||||
|
needClockIn: true,
|
||||||
|
exemptionReason: null,
|
||||||
|
item: null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
|
function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
|
||||||
return page.route("**/api/portal/home", async (route) => {
|
return page.route("**/api/portal/home", async (route) => {
|
||||||
const data = mode === "manager"
|
const data = mode === "manager"
|
||||||
@@ -133,6 +147,14 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" },
|
projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" },
|
||||||
peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" },
|
peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" },
|
||||||
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 1, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
|
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 1, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
|
||||||
|
recentActivities: [
|
||||||
|
{ id: "activity-1", type: "reimbursement.submit", actorName: "张三", summary: "张三 提交了报销单", createdAt: "2026-03-22T09:00:00.000Z", href: "/reimbursements/reimbursement-1" },
|
||||||
|
{ id: "activity-2", type: "attendance.clock_in", actorName: "李四", summary: "李四 完成了上班打卡", createdAt: "2026-03-22T08:50:00.000Z", href: "/attendance" },
|
||||||
|
],
|
||||||
|
teamStatusBoard: [
|
||||||
|
{ employeeId: "employee-1", name: "张三", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
|
||||||
|
{ employeeId: "employee-2", name: "李四", avatarUrl: null, presenceStatus: "leave", attendanceClockIn: null, attendanceClockOut: null, onLeave: true, leaveType: "年假" },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||||
@@ -162,6 +184,13 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" },
|
projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" },
|
||||||
peopleLifecycle: null,
|
peopleLifecycle: null,
|
||||||
adoptionSummary: null,
|
adoptionSummary: null,
|
||||||
|
recentActivities: [
|
||||||
|
{ id: "activity-1", type: "leave.submit", actorName: "Employee", summary: "Employee 提交了请假单", createdAt: "2026-03-22T09:00:00.000Z", href: "/leave-requests" },
|
||||||
|
],
|
||||||
|
teamStatusBoard: [
|
||||||
|
{ employeeId: "employee-1", name: "Employee", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
|
||||||
|
{ employeeId: "employee-2", name: "同事", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -175,6 +204,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
|||||||
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||||
await mockNavigation(page, "employee");
|
await mockNavigation(page, "employee");
|
||||||
await mockPortalHome(page, "employee");
|
await mockPortalHome(page, "employee");
|
||||||
|
await mockAttendanceToday(page);
|
||||||
await mockMe(page, ["employee"], "Employee");
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
@@ -195,11 +225,16 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
|||||||
|
|
||||||
await expect(page.getByText("我的申请与资产")).toBeVisible();
|
await expect(page.getByText("我的申请与资产")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "近期动态流" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
||||||
await mockNavigation(page, "manager");
|
await mockNavigation(page, "manager");
|
||||||
await mockPortalHome(page, "manager");
|
await mockPortalHome(page, "manager");
|
||||||
|
await mockAttendanceToday(page);
|
||||||
await mockMe(page, ["admin"], "Admin");
|
await mockMe(page, ["admin"], "Admin");
|
||||||
|
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
@@ -226,6 +261,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
|||||||
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||||
await mockNavigation(page, "manager");
|
await mockNavigation(page, "manager");
|
||||||
await mockPortalHome(page, "manager");
|
await mockPortalHome(page, "manager");
|
||||||
|
await mockAttendanceToday(page);
|
||||||
await mockMe(page, ["administration"], "Administration");
|
await mockMe(page, ["administration"], "Administration");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
@@ -244,6 +280,7 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
await mockNavigation(page, "employee");
|
await mockNavigation(page, "employee");
|
||||||
await mockPortalHome(page, "employee");
|
await mockPortalHome(page, "employee");
|
||||||
|
await mockAttendanceToday(page);
|
||||||
await mockMe(page, ["employee"], "Employee");
|
await mockMe(page, ["employee"], "Employee");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
"A1","R60 执行合同建档与范围冻结","创建并校验 R60 首页工作台优化执行合同 CSV,冻结范围:1) 后端扩展 portal/home 返回 recentActivities 与 teamStatusBoard;2) 新增 GET /api/portal/team-status 轻量端点;3) PortalHomePage 增加快捷操作栏、团队全员状态、近期动态流三个区块;4) MobilePortalPage 增加团队状态卡片与待办聚合。不新增 Prisma 迁移,所有数据从现有表聚合。","issues/2026-04-12_r60-portal-workbench.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R60,不混入其他模块。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-12_r60-portal-workbench.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend→frontend→reviewer→qa 收口;3) 无 Prisma 迁移。","manual","DONE","DONE","DONE","issues/2026-04-12_r60-portal-workbench.csv","[]","回滚路径:删除本次 CSV 不影响业务。"
|
||||||
|
"A2","后端扩展 portal/home 与新增 team-status 端点","在 portal.js 扩展 GET /api/portal/home 返回两个新字段:1) recentActivities[]——从 auditLog 聚合最近 15 条业务动态(报销提交/审批、请假提交/审批、考勤异常、资产变动等),每条含 type/actor/summary/timestamp/href;2) teamStatusBoard[]——全员今日状态列表(id/name/avatarUrl/presenceStatus/attendanceStatus/leaveInfo)。新增 GET /api/portal/team-status 独立端点返回 teamStatusBoard,供移动端单独刷新。两个端点均需认证,team-status 端点不依赖 portalHome 特性开关。","1) GET /api/portal/home 响应新增 recentActivities(数组,每项含 id/type/actorName/summary/createdAt/href)和 teamStatusBoard(数组,每项含 employeeId/name/avatarUrl/presenceStatus/attendanceClockIn/attendanceClockOut/onLeave/leaveType);2) GET /api/portal/team-status 独立返回 teamStatusBoard,响应结构与 portal/home 中一致;3) recentActivities 从 auditLog 表按 created_at DESC 取最近 15 条,过滤 action 在 [reimbursement.submit, reimbursement.approve, reimbursement.reject, leave.submit, leave.approve, leave.reject, attendance.clock_in, attendance.clock_out, attendance.supplement, asset.assign, asset.return] 范围内;4) teamStatusBoard 聚合 employee(active)+ 今日 presenceHistory + 今日 attendanceRecord + 当日 leaveRequest 状态;5) 不新增 Prisma 迁移。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/portal.js && npm test -- server/tests/unit/routes/portal.test.js 2>/dev/null; echo done。业务:1) 调用 GET /api/portal/home,检查响应含 recentActivities 和 teamStatusBoard 字段;2) 调用 GET /api/portal/team-status,检查返回全员状态数组;3) recentActivities 按时间倒序、不超过 15 条;4) teamStatusBoard 包含所有 active 员工。","manual","DONE","DONE","DONE","server/src/routes/portal.js|server/tests/unit/routes/portal.test.js","[""A1""]","回滚路径:移除 portal.js 新增的 recentActivities/teamStatusBoard 聚合逻辑与 team-status 路由。无 Prisma 迁移,回退仅需代码回滚。auditLog action 值需与现有审计日志 action 字段实际值对齐,实现时先查 schema 确认。"
|
||||||
|
"A3","PortalHomePage 快捷操作栏与团队全员状态","重构 PortalHomePage 布局,在原有 Hero 区域下方新增两个区块:1) 快捷操作栏——一排图标按钮(提交请假、提交报销、打卡、提交周报),点击跳转对应页面或触发打卡操作;2) 团队全员状态面板——展示 teamStatusBoard 全员头像+姓名+今日状态(在岗/请假/外出/未打卡等),<10 人一屏展示无需分页,每人显示 avatar、name、presenceStatus badge、attendanceStatus、leaveInfo。使用 Tailwind grid 布局,响应式适配移动端。","1) 首页 Hero 下方出现快捷操作栏,含至少 4 个操作按钮(请假/报销/打卡/周报),各按钮点击跳转正确路径;2) 团队全员状态面板展示所有 active 员工,每人显示头像(或首字 fallback)、姓名、出勤状态 badge(在岗绿/请假橙/外出蓝/未打卡灰);3) 考勤打卡按钮调用 POST /api/attendance/clock-in 或 clock-out(根据今日状态判断),成功后刷新状态;4) 页面首屏加载时请求 portal/home 一次即获取所有数据,不额外请求 team-status。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 登录后访问 /portal,确认快捷操作栏可见且各按钮可点击跳转;2) 团队状态面板显示全员,badge 颜色符合状态;3) 打卡按钮调用正确 API 并刷新显示。","manual","DONE","DONE","DOING","client/src/pages/PortalHomePage.tsx|client/src/api/portal.ts","[""A2""]","回滚路径:恢复 PortalHomePage 原有布局,移除快捷操作栏与团队状态面板。打卡操作复用 R59 已有的 attendance API,不新增端点。"
|
||||||
|
"A4","PortalHomePage 待办聚合与近期动态流","在 PortalHomePage 继续新增两个区块:1) 今日待办聚合——将待审批任务、逾期 TODO、合同到期预警等散落在各模块的待办事项合并展示,每条含图标+标题+来源模块+操作按钮(跳转),按优先级排序,最多展示 10 条;2) 近期动态流——展示 recentActivities,每条显示操作人头像+操作描述+时间戳,可点击跳转详情页。两个区块放在团队状态面板下方,使用 card 容器。","1) 今日待办聚合区块可见,从 portal/home 现有的 pendingTasks/todoSummary/cards 中提取并合并展示,每条可点击跳转;2) 近期动态流区块可见,展示 recentActivities 列表,每条含操作人名、操作摘要、相对时间(如'2小时前')、可点击跳转 href;3) 动态流为空时显示'暂无近期动态'占位;4) 待办为空时显示'暂无待办事项,休息一下吧'占位。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 登录后 /portal 可见待办聚合和动态流两个区块;2) 待办条目点击可跳转到正确页面;3) 动态流显示最近业务操作,时间格式友好。","manual","DONE","DONE","DOING","client/src/pages/PortalHomePage.tsx","[""A3""]","回滚路径:移除待办聚合与动态流区块,保留 A3 的快捷操作和团队状态。相对时间格式化可用简单工具函数,不引入 dayjs/moment。"
|
||||||
|
"A5","MobilePortalPage 团队状态与待办聚合同步","在 MobilePortalPage 新增两个区块:1) 团队状态卡片——在考勤打卡区域下方展示全员今日状态,紧凑的横向滚动或 grid 布局,显示头像+姓名+状态 badge;2) 待办聚合——合并审批待办与 TODO 展示前 5 条,每条可点击跳转。团队状态使用 GET /api/portal/team-status 端点获取,支持下拉刷新。","1) MobilePortalPage 考勤区域下方出现团队状态卡片,展示全员头像+姓名+状态;2) 团队状态下方出现待办聚合,展示前 5 条待办,每条可点击;3) 团队状态卡片调用 GET /api/portal/team-status(非 portal/home),减少移动端数据传输;4) 下拉或点击刷新可更新团队状态。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 访问 /mobile,考勤区下方可见团队状态和待办聚合;2) 点击待办跳转正确页面;3) 团队状态数据来自 /api/portal/team-status 端点。","manual","DONE","DONE","DOING","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts","[""A2"",""A4""]","回滚路径:移除 MobilePortalPage 新增的团队状态和待办区块,恢复原布局。"
|
||||||
|
"A6","QA、状态回填与提交收口","执行 R60 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保首页工作台新功能无回归。","后端与前端校验全部通过;快捷操作、团队状态、待办聚合、动态流、移动端同步回归完成;issues/2026-04-12_r60-portal-workbench.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/portal.js && npm test -- server/tests/unit/routes/portal.test.js 2>/dev/null && cd ../client && npm run typecheck && npm run build。业务:1) /portal 首页可见快捷操作栏、团队状态、待办聚合、动态流四个新区块;2) 各操作按钮功能正常;3) /mobile 可见团队状态和待办聚合;4) 不影响原有 portal 卡片、公告、快捷入口等功能。","manual","DOING","DOING","DOING","issues/2026-04-12_r60-portal-workbench.csv|server/src/routes/portal.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx","[""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。"
|
||||||
|
@@ -8,6 +8,9 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
|
|||||||
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
||||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
|
|
||||||
|
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
||||||
|
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
||||||
|
|
||||||
async function buildTaskVisibilityFilters(tx, request) {
|
async function buildTaskVisibilityFilters(tx, request) {
|
||||||
const roleFilters = (request.employee.roleCodes ?? []).length
|
const roleFilters = (request.employee.roleCodes ?? []).length
|
||||||
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
? [{ assignee_role: { in: request.employee.roleCodes } }]
|
||||||
@@ -108,6 +111,277 @@ function buildQuickLinks({ request, featureEnabled }) {
|
|||||||
return items.slice(0, 8);
|
return items.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAvatarUrl(employeeId, avatarPath) {
|
||||||
|
return avatarPath ? `/api/avatars/${employeeId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowActivityType(entityType, action) {
|
||||||
|
if (entityType === "reimbursement" && action === "start") return "reimbursement.submit";
|
||||||
|
if (entityType === "reimbursement" && action === "approve") return "reimbursement.approve";
|
||||||
|
if (entityType === "reimbursement" && action === "reject") return "reimbursement.reject";
|
||||||
|
if (entityType === "leave" && action === "start") return "leave.submit";
|
||||||
|
if (entityType === "leave" && action === "approve") return "leave.approve";
|
||||||
|
if (entityType === "leave" && action === "reject") return "leave.reject";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActivityHref(type, entityId) {
|
||||||
|
if (type.startsWith("reimbursement.")) {
|
||||||
|
return entityId ? `/reimbursements/${entityId}` : "/reimbursements";
|
||||||
|
}
|
||||||
|
if (type.startsWith("leave.")) {
|
||||||
|
return "/leave-requests";
|
||||||
|
}
|
||||||
|
if (type.startsWith("attendance.")) {
|
||||||
|
return "/attendance";
|
||||||
|
}
|
||||||
|
if (type.startsWith("asset.")) {
|
||||||
|
return "/assets";
|
||||||
|
}
|
||||||
|
return "/portal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowActivitySummary(type, actorName) {
|
||||||
|
if (type === "reimbursement.submit") return `${actorName} 提交了报销单`;
|
||||||
|
if (type === "reimbursement.approve") return `${actorName} 通过了报销单`;
|
||||||
|
if (type === "reimbursement.reject") return `${actorName} 驳回了报销单`;
|
||||||
|
if (type === "leave.submit") return `${actorName} 提交了请假单`;
|
||||||
|
if (type === "leave.approve") return `${actorName} 通过了请假单`;
|
||||||
|
if (type === "leave.reject") return `${actorName} 驳回了请假单`;
|
||||||
|
return `${actorName} 更新了流程记录`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditActivitySummary(type, actorName, extra) {
|
||||||
|
const assetNo = extra?.assetNo ? ` ${extra.assetNo}` : "";
|
||||||
|
if (type === "asset.assign") return `${actorName} 分配了资产${assetNo}`;
|
||||||
|
if (type === "asset.return") return `${actorName} 完成了资产归还${assetNo}`;
|
||||||
|
if (type === "attendance.supplement") return `${actorName} 补录了考勤`;
|
||||||
|
return `${actorName} 更新了业务记录`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttendanceActivitySummary(type, actorName) {
|
||||||
|
if (type === "attendance.clock_in") return `${actorName} 完成了上班打卡`;
|
||||||
|
if (type === "attendance.clock_out") return `${actorName} 完成了下班打卡`;
|
||||||
|
return `${actorName} 更新了考勤`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortActivitiesByCreatedAtDesc(left, right) {
|
||||||
|
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRecentActivities(prisma) {
|
||||||
|
const [workflowRows, auditRows, attendanceRows] = await Promise.all([
|
||||||
|
prisma.workflowHistory.findMany({
|
||||||
|
where: {
|
||||||
|
action: { in: ["start", "approve", "reject"] },
|
||||||
|
workflow_instance: {
|
||||||
|
is: {
|
||||||
|
entity_type: { in: ["reimbursement", "leave"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
action: true,
|
||||||
|
created_at: true,
|
||||||
|
actor: { select: { name: true } },
|
||||||
|
workflow_instance: {
|
||||||
|
select: {
|
||||||
|
entity_type: true,
|
||||||
|
entity_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where: {
|
||||||
|
action: { in: ["ASSET_ASSIGNED", "ASSET_RETURNED", "ATTENDANCE_ADMIN_BACKFILLED"] },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
action: true,
|
||||||
|
target_id: true,
|
||||||
|
extra: true,
|
||||||
|
created_at: true,
|
||||||
|
actor: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.attendanceRecord.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ clock_in_at: { not: null } },
|
||||||
|
{ clock_out_at: { not: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
clock_in_at: true,
|
||||||
|
clock_out_at: true,
|
||||||
|
employee_id: true,
|
||||||
|
employee: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workflowActivities = workflowRows
|
||||||
|
.map((row) => {
|
||||||
|
const type = normalizeWorkflowActivityType(
|
||||||
|
row.workflow_instance?.entity_type ?? null,
|
||||||
|
row.action,
|
||||||
|
);
|
||||||
|
if (!type || !row.created_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorName = row.actor?.name ?? "系统";
|
||||||
|
return {
|
||||||
|
id: `workflow-${row.id}`,
|
||||||
|
type,
|
||||||
|
actorName,
|
||||||
|
summary: buildWorkflowActivitySummary(type, actorName),
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
href: buildActivityHref(type, row.workflow_instance?.entity_id ?? null),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const auditActionMap = {
|
||||||
|
ASSET_ASSIGNED: "asset.assign",
|
||||||
|
ASSET_RETURNED: "asset.return",
|
||||||
|
ATTENDANCE_ADMIN_BACKFILLED: "attendance.supplement",
|
||||||
|
};
|
||||||
|
const auditActivities = auditRows
|
||||||
|
.map((row) => {
|
||||||
|
const type = auditActionMap[row.action];
|
||||||
|
if (!type || !row.created_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorName = row.actor?.name ?? "系统";
|
||||||
|
const extra = row.extra && typeof row.extra === "object" ? row.extra : {};
|
||||||
|
return {
|
||||||
|
id: `audit-${row.id}`,
|
||||||
|
type,
|
||||||
|
actorName,
|
||||||
|
summary: buildAuditActivitySummary(type, actorName, extra),
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
href: buildActivityHref(type, row.target_id ?? null),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const attendanceActivities = attendanceRows.flatMap((row) => {
|
||||||
|
const actorName = row.employee?.name ?? row.employee_id;
|
||||||
|
const items = [];
|
||||||
|
if (row.clock_in_at) {
|
||||||
|
items.push({
|
||||||
|
id: `attendance-clock-in-${row.id}`,
|
||||||
|
type: "attendance.clock_in",
|
||||||
|
actorName,
|
||||||
|
summary: buildAttendanceActivitySummary("attendance.clock_in", actorName),
|
||||||
|
createdAt: row.clock_in_at.toISOString(),
|
||||||
|
href: buildActivityHref("attendance.clock_in"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row.clock_out_at) {
|
||||||
|
items.push({
|
||||||
|
id: `attendance-clock-out-${row.id}`,
|
||||||
|
type: "attendance.clock_out",
|
||||||
|
actorName,
|
||||||
|
summary: buildAttendanceActivitySummary("attendance.clock_out", actorName),
|
||||||
|
createdAt: row.clock_out_at.toISOString(),
|
||||||
|
href: buildActivityHref("attendance.clock_out"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...workflowActivities, ...auditActivities, ...attendanceActivities]
|
||||||
|
.sort(sortActivitiesByCreatedAtDesc)
|
||||||
|
.slice(0, PORTAL_RECENT_ACTIVITY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildTeamStatusBoard(prisma, dateString) {
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: { status: "active" },
|
||||||
|
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
avatar_path: true,
|
||||||
|
legal_entity_id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employees.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds = employees.map((item) => item.id);
|
||||||
|
const [presenceList, attendanceRows, leaveRows] = await Promise.all([
|
||||||
|
Promise.all(employees.map((employee) => resolvePresenceForEmployee(prisma, employee.id, dateString))),
|
||||||
|
prisma.attendanceRecord.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: { in: employeeIds },
|
||||||
|
work_date: parseDateOnlyString(dateString),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
employee_id: true,
|
||||||
|
clock_in_at: true,
|
||||||
|
clock_out_at: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.leaveRequest.findMany({
|
||||||
|
where: {
|
||||||
|
owner_id: { in: employeeIds },
|
||||||
|
status: "approved",
|
||||||
|
start_date: { lte: parseDateOnlyString(dateString) },
|
||||||
|
end_date: { gte: parseDateOnlyString(dateString) },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
select: {
|
||||||
|
owner_id: true,
|
||||||
|
leave_type: {
|
||||||
|
select: { name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const attendanceByEmployeeId = new Map(
|
||||||
|
attendanceRows.map((row) => [row.employee_id, row]),
|
||||||
|
);
|
||||||
|
const leaveByEmployeeId = new Map();
|
||||||
|
for (const row of leaveRows) {
|
||||||
|
if (!leaveByEmployeeId.has(row.owner_id)) {
|
||||||
|
leaveByEmployeeId.set(row.owner_id, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return employees.map((employee, index) => {
|
||||||
|
const attendance = attendanceByEmployeeId.get(employee.id) ?? null;
|
||||||
|
const leave = leaveByEmployeeId.get(employee.id) ?? null;
|
||||||
|
const presence = presenceList[index] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId: employee.id,
|
||||||
|
name: employee.name,
|
||||||
|
avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path),
|
||||||
|
presenceStatus: presence?.status ?? null,
|
||||||
|
attendanceClockIn: attendance?.clock_in_at?.toISOString?.() ?? null,
|
||||||
|
attendanceClockOut: attendance?.clock_out_at?.toISOString?.() ?? null,
|
||||||
|
onLeave: Boolean(leave),
|
||||||
|
leaveType: leave?.leave_type?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount }) {
|
function buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount }) {
|
||||||
const hints = [];
|
const hints = [];
|
||||||
if (login7dCount < activeEmployeeCount) {
|
if (login7dCount < activeEmployeeCount) {
|
||||||
@@ -173,6 +447,8 @@ async function portalRoutes(fastify) {
|
|||||||
openProjectRiskCount,
|
openProjectRiskCount,
|
||||||
projectFollowUpDueTodayCount,
|
projectFollowUpDueTodayCount,
|
||||||
closurePendingProjectCount,
|
closurePendingProjectCount,
|
||||||
|
recentActivities,
|
||||||
|
teamStatusBoard,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
||||||
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||||
@@ -280,6 +556,8 @@ async function portalRoutes(fastify) {
|
|||||||
select: { project_id: true },
|
select: { project_id: true },
|
||||||
}).then((items) => items.length)
|
}).then((items) => items.length)
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
|
buildRecentActivities(fastify.prisma),
|
||||||
|
buildTeamStatusBoard(fastify.prisma, today),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const todayPresence = featureEnabled.presenceBoard
|
const todayPresence = featureEnabled.presenceBoard
|
||||||
@@ -470,8 +748,16 @@ async function portalRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
adoptionSummary,
|
adoptionSummary,
|
||||||
|
recentActivities,
|
||||||
|
teamStatusBoard,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/portal/team-status", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
const today = currentDateString(fastify.config.appTimezone);
|
||||||
|
const teamStatusBoard = await buildTeamStatusBoard(fastify.prisma, today);
|
||||||
|
return sendData(reply, request, teamStatusBoard);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = portalRoutes;
|
module.exports = portalRoutes;
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
|
||||||
|
const featureFlagState = {
|
||||||
|
portalHome: true,
|
||||||
|
announcements: false,
|
||||||
|
presenceBoard: false,
|
||||||
|
weeklyReport: false,
|
||||||
|
employeeLifecycle: false,
|
||||||
|
todoBoard: false,
|
||||||
|
paymentBatch: false,
|
||||||
|
projectDomain: false,
|
||||||
|
projectCommunicationLog: false,
|
||||||
|
projectClosureChecklist: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../../src/services/featureFlags", () => ({
|
||||||
|
FEATURE_FLAG_KEYS: {
|
||||||
|
portalHome: "portal_home_v1",
|
||||||
|
announcements: "announcements_v1",
|
||||||
|
presenceBoard: "presence_board_v1",
|
||||||
|
weeklyReport: "weekly_report_v1",
|
||||||
|
employeeLifecycle: "employee_lifecycle_v1",
|
||||||
|
todoBoard: "todo_board_v1",
|
||||||
|
paymentBatch: "payment_batch_v1",
|
||||||
|
projectDomain: "project_domain_v1",
|
||||||
|
projectCommunicationLog: "project_communication_log_v1",
|
||||||
|
projectClosureChecklist: "project_closure_checklist_v1",
|
||||||
|
},
|
||||||
|
evaluateFeatureFlag: vi.fn(async ({ flagKey }) => {
|
||||||
|
if (flagKey === "portal_home_v1") {
|
||||||
|
return { enabled: featureFlagState.portalHome, reason: featureFlagState.portalHome ? "enabled" : "force_off" };
|
||||||
|
}
|
||||||
|
if (flagKey === "announcements_v1") return { enabled: featureFlagState.announcements, reason: "mock" };
|
||||||
|
if (flagKey === "presence_board_v1") return { enabled: featureFlagState.presenceBoard, reason: "mock" };
|
||||||
|
if (flagKey === "weekly_report_v1") return { enabled: featureFlagState.weeklyReport, reason: "mock" };
|
||||||
|
if (flagKey === "employee_lifecycle_v1") return { enabled: featureFlagState.employeeLifecycle, reason: "mock" };
|
||||||
|
if (flagKey === "todo_board_v1") return { enabled: featureFlagState.todoBoard, reason: "mock" };
|
||||||
|
if (flagKey === "payment_batch_v1") return { enabled: featureFlagState.paymentBatch, reason: "mock" };
|
||||||
|
if (flagKey === "project_domain_v1") return { enabled: featureFlagState.projectDomain, reason: "mock" };
|
||||||
|
if (flagKey === "project_communication_log_v1") return { enabled: featureFlagState.projectCommunicationLog, reason: "mock" };
|
||||||
|
if (flagKey === "project_closure_checklist_v1") return { enabled: featureFlagState.projectClosureChecklist, reason: "mock" };
|
||||||
|
return { enabled: false, reason: "mock" };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../src/services/workspaceSettings", () => ({
|
||||||
|
getWorkspaceSetting: vi.fn(async () => ({
|
||||||
|
id: "workspace-1",
|
||||||
|
persisted: true,
|
||||||
|
profile: "small_team_internal",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resolvePresenceForEmployee = vi.fn();
|
||||||
|
const buildTeamPresenceSummary = vi.fn(async () => ({
|
||||||
|
office: 0,
|
||||||
|
remote: 0,
|
||||||
|
out: 0,
|
||||||
|
travel: 0,
|
||||||
|
leave: 0,
|
||||||
|
unspecified: 0,
|
||||||
|
total: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../src/services/presenceHistory", () => ({
|
||||||
|
resolvePresenceForEmployee,
|
||||||
|
buildTeamPresenceSummary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const portalRoutes = require("../../../src/routes/portal");
|
||||||
|
|
||||||
|
function buildAttendanceRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "attendance-1",
|
||||||
|
employee_id: "employee-1",
|
||||||
|
clock_in_at: new Date("2026-04-12T01:15:00.000Z"),
|
||||||
|
clock_out_at: null,
|
||||||
|
employee: { name: "张三" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "history-1",
|
||||||
|
action: "start",
|
||||||
|
created_at: new Date("2026-04-12T02:00:00.000Z"),
|
||||||
|
actor: { name: "李四" },
|
||||||
|
workflow_instance: {
|
||||||
|
entity_type: "reimbursement",
|
||||||
|
entity_id: "reimbursement-1",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "audit-1",
|
||||||
|
action: "ASSET_ASSIGNED",
|
||||||
|
target_id: "asset-1",
|
||||||
|
extra: { assetNo: "AS-001" },
|
||||||
|
created_at: new Date("2026-04-12T03:00:00.000Z"),
|
||||||
|
actor: { name: "王五" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmployeeRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "employee-1",
|
||||||
|
employee_no: "E001",
|
||||||
|
name: "张三",
|
||||||
|
avatar_path: "avatars/zs.png",
|
||||||
|
legal_entity_id: "legal-1",
|
||||||
|
status: "active",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeaveRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
owner_id: "employee-2",
|
||||||
|
start_date: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
end_date: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
reason: "休假",
|
||||||
|
status: "approved",
|
||||||
|
leave_type: { name: "年假" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresenceRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: "presence-1",
|
||||||
|
employee_id: "employee-1",
|
||||||
|
date: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
status: "office",
|
||||||
|
time_range: "full_day",
|
||||||
|
note: null,
|
||||||
|
source: "manual",
|
||||||
|
updated_at: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(options = {}) {
|
||||||
|
const workflowHistoryRows = options.workflowHistoryRows ?? [];
|
||||||
|
const auditLogRows = options.auditLogRows ?? [];
|
||||||
|
const attendanceRows = options.attendanceRows ?? [];
|
||||||
|
const employees = options.employees ?? [buildEmployeeRow()];
|
||||||
|
const leaveRows = options.leaveRows ?? [];
|
||||||
|
const employeePresenceRows = options.employeePresenceRows
|
||||||
|
?? Object.entries(options.presenceByEmployeeId ?? {})
|
||||||
|
.filter(([, value]) => value && value.status && value.status !== "leave" && value.status !== "travel")
|
||||||
|
.map(([employeeId, value], index) =>
|
||||||
|
buildPresenceRow({
|
||||||
|
id: `presence-${index + 1}`,
|
||||||
|
employee_id: employeeId,
|
||||||
|
status: value.status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const travelRows = options.travelRows ?? [];
|
||||||
|
const permissionCodes = options.permissionCodes ?? ["expense.view_own"];
|
||||||
|
const employeeId = options.employeeId ?? "employee-1";
|
||||||
|
|
||||||
|
resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => {
|
||||||
|
return options.presenceByEmployeeId?.[targetEmployeeId] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
workflowTask: {
|
||||||
|
count: vi.fn(async () => 0),
|
||||||
|
},
|
||||||
|
reimbursementForm: {
|
||||||
|
count: vi.fn(async () => 2),
|
||||||
|
},
|
||||||
|
leaveRequest: {
|
||||||
|
count: vi.fn(async () => 1),
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
if (!where?.owner_id?.in) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return leaveRows.filter((row) => where.owner_id.in.includes(row.owner_id));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assetCard: {
|
||||||
|
count: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
employee: {
|
||||||
|
count: vi.fn(async () => employees.length),
|
||||||
|
findMany: vi.fn(async () => employees),
|
||||||
|
},
|
||||||
|
employeePresence: {
|
||||||
|
findMany: vi.fn(async ({ where }) =>
|
||||||
|
employeePresenceRows.filter((row) => {
|
||||||
|
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.date?.gte && row.date < where.date.gte) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.date?.lte && row.date > where.date.lte) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
authSession: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
featureFlag: {
|
||||||
|
findUnique: vi.fn(async ({ where }) => {
|
||||||
|
if (where.flag_key !== "portal_home_v1") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "flag-portal-home",
|
||||||
|
flag_key: "portal_home_v1",
|
||||||
|
override_mode: "force_on",
|
||||||
|
rollout_type: "global",
|
||||||
|
department_ids: [],
|
||||||
|
note: "",
|
||||||
|
version: 1,
|
||||||
|
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
created_by: null,
|
||||||
|
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
updated_by: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
workspaceSetting: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
id: "workspace-1",
|
||||||
|
profile: "small_team_internal",
|
||||||
|
org_mode: "single_company",
|
||||||
|
primary_entry_mode: "password",
|
||||||
|
notification_strategy: "in_app",
|
||||||
|
device_priority: "desktop",
|
||||||
|
module_visibility_json: {},
|
||||||
|
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
updated_by: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
workflowHistory: {
|
||||||
|
findMany: vi.fn(async () => workflowHistoryRows),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
findMany: vi.fn(async () => auditLogRows),
|
||||||
|
},
|
||||||
|
attendanceRecord: {
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
if (where?.employee_id?.in) {
|
||||||
|
return attendanceRows.filter((row) => where.employee_id.in.includes(row.employee_id));
|
||||||
|
}
|
||||||
|
return attendanceRows;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
travelRequest: {
|
||||||
|
findMany: vi.fn(async ({ where }) => {
|
||||||
|
if (!where?.applicant_employee_id?.in) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return travelRows.filter((row) => where.applicant_employee_id.in.includes(row.applicant_employee_id));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = Fastify();
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("config", {
|
||||||
|
appTimezone: "Asia/Shanghai",
|
||||||
|
portalHomeEnabled: true,
|
||||||
|
announcementsEnabled: false,
|
||||||
|
presenceBoardEnabled: false,
|
||||||
|
weeklyReportEnabled: false,
|
||||||
|
employeeLifecycleEnabled: false,
|
||||||
|
todoBoardEnabled: false,
|
||||||
|
paymentBatchEnabled: false,
|
||||||
|
projectDomainEnabled: false,
|
||||||
|
projectCommunicationLogEnabled: false,
|
||||||
|
projectClosureChecklistEnabled: false,
|
||||||
|
});
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
if (!request.headers.authorization) {
|
||||||
|
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
request.employee = {
|
||||||
|
id: employeeId,
|
||||||
|
name: "测试用户",
|
||||||
|
department_id: "department-1",
|
||||||
|
permissionCodes,
|
||||||
|
roleCodes: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.decorate(
|
||||||
|
"hasPermission",
|
||||||
|
(employee, permissionCode) =>
|
||||||
|
(employee?.permissionCodes ?? []).includes("*") ||
|
||||||
|
(employee?.permissionCodes ?? []).includes(permissionCode),
|
||||||
|
);
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(500).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(portalRoutes, { prefix: "/api" });
|
||||||
|
return { app, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("portal routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
featureFlagState.portalHome = true;
|
||||||
|
featureFlagState.announcements = false;
|
||||||
|
featureFlagState.presenceBoard = false;
|
||||||
|
featureFlagState.weeklyReport = false;
|
||||||
|
featureFlagState.employeeLifecycle = false;
|
||||||
|
featureFlagState.todoBoard = false;
|
||||||
|
featureFlagState.paymentBatch = false;
|
||||||
|
featureFlagState.projectDomain = false;
|
||||||
|
featureFlagState.projectCommunicationLog = false;
|
||||||
|
featureFlagState.projectClosureChecklist = false;
|
||||||
|
resolvePresenceForEmployee.mockReset();
|
||||||
|
buildTeamPresenceSummary.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => {
|
||||||
|
const recentWorkflowRows = Array.from({ length: 8 }, (_, index) =>
|
||||||
|
buildWorkflowRow({
|
||||||
|
id: `history-${index + 1}`,
|
||||||
|
action: index % 2 === 0 ? "approve" : "start",
|
||||||
|
created_at: new Date(Date.UTC(2026, 3, 12, 10, index, 0)),
|
||||||
|
actor: { name: `流程用户${index + 1}` },
|
||||||
|
workflow_instance: {
|
||||||
|
entity_type: index % 3 === 0 ? "leave" : "reimbursement",
|
||||||
|
entity_id: `entity-${index + 1}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const recentAuditRows = Array.from({ length: 4 }, (_, index) =>
|
||||||
|
buildAuditRow({
|
||||||
|
id: `audit-${index + 1}`,
|
||||||
|
action: index === 0 ? "ATTENDANCE_ADMIN_BACKFILLED" : index % 2 === 0 ? "ASSET_ASSIGNED" : "ASSET_RETURNED",
|
||||||
|
created_at: new Date(Date.UTC(2026, 3, 12, 11, index, 0)),
|
||||||
|
actor: { name: `审计用户${index + 1}` },
|
||||||
|
extra: { assetNo: `AS-${index + 1}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const recentAttendanceRows = Array.from({ length: 4 }, (_, index) =>
|
||||||
|
buildAttendanceRow({
|
||||||
|
id: `attendance-${index + 1}`,
|
||||||
|
employee_id: `employee-${index + 1}`,
|
||||||
|
employee: { name: `打卡用户${index + 1}` },
|
||||||
|
clock_in_at: new Date(Date.UTC(2026, 3, 12, 12, index, 0)),
|
||||||
|
clock_out_at: new Date(Date.UTC(2026, 3, 12, 13, index, 0)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const employees = [
|
||||||
|
buildEmployeeRow({ id: "employee-1", name: "张三" }),
|
||||||
|
buildEmployeeRow({ id: "employee-2", name: "李四", avatar_path: null }),
|
||||||
|
];
|
||||||
|
const { app } = await buildApp({
|
||||||
|
workflowHistoryRows: recentWorkflowRows,
|
||||||
|
auditLogRows: recentAuditRows,
|
||||||
|
attendanceRows: recentAttendanceRows,
|
||||||
|
employees,
|
||||||
|
leaveRows: [buildLeaveRow({ owner_id: "employee-2", leave_type: { name: "年假" } })],
|
||||||
|
presenceByEmployeeId: {
|
||||||
|
"employee-1": { status: "office" },
|
||||||
|
"employee-2": { status: "leave" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/portal/home",
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const payload = response.json().data;
|
||||||
|
expect(payload.recentActivities).toHaveLength(15);
|
||||||
|
expect(payload.recentActivities[0]).toMatchObject({
|
||||||
|
type: "attendance.clock_out",
|
||||||
|
actorName: "打卡用户4",
|
||||||
|
href: "/attendance",
|
||||||
|
});
|
||||||
|
expect(payload.recentActivities.map((item) => item.createdAt)).toEqual(
|
||||||
|
[...payload.recentActivities.map((item) => item.createdAt)].sort((left, right) => new Date(right).getTime() - new Date(left).getTime()),
|
||||||
|
);
|
||||||
|
expect(payload.recentActivities.map((item) => item.type)).toContain("reimbursement.submit");
|
||||||
|
expect(payload.recentActivities.map((item) => item.type)).toContain("leave.approve");
|
||||||
|
expect(payload.recentActivities.map((item) => item.type)).toContain("asset.assign");
|
||||||
|
expect(payload.recentActivities.map((item) => item.type)).toContain("attendance.supplement");
|
||||||
|
expect(payload.teamStatusBoard).toEqual([
|
||||||
|
{
|
||||||
|
employeeId: "employee-1",
|
||||||
|
name: "张三",
|
||||||
|
avatarUrl: "/api/avatars/employee-1",
|
||||||
|
presenceStatus: "office",
|
||||||
|
attendanceClockIn: "2026-04-12T12:00:00.000Z",
|
||||||
|
attendanceClockOut: "2026-04-12T13:00:00.000Z",
|
||||||
|
onLeave: false,
|
||||||
|
leaveType: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
employeeId: "employee-2",
|
||||||
|
name: "李四",
|
||||||
|
avatarUrl: null,
|
||||||
|
presenceStatus: "leave",
|
||||||
|
attendanceClockIn: "2026-04-12T12:01:00.000Z",
|
||||||
|
attendanceClockOut: "2026-04-12T13:01:00.000Z",
|
||||||
|
onLeave: true,
|
||||||
|
leaveType: "年假",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows /portal/team-status even when portal_home_v1 is disabled", async () => {
|
||||||
|
featureFlagState.portalHome = false;
|
||||||
|
const { app } = await buildApp({
|
||||||
|
employees: [buildEmployeeRow({ id: "employee-1", name: "张三" })],
|
||||||
|
attendanceRows: [buildAttendanceRow({ employee_id: "employee-1" })],
|
||||||
|
presenceByEmployeeId: { "employee-1": { status: "office" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/portal/team-status",
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual([
|
||||||
|
{
|
||||||
|
employeeId: "employee-1",
|
||||||
|
name: "张三",
|
||||||
|
avatarUrl: "/api/avatars/employee-1",
|
||||||
|
presenceStatus: "office",
|
||||||
|
attendanceClockIn: "2026-04-12T01:15:00.000Z",
|
||||||
|
attendanceClockOut: null,
|
||||||
|
onLeave: false,
|
||||||
|
leaveType: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户