diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts index d835d79..5550a2b 100644 --- a/backend/src/routes/dashboard.routes.ts +++ b/backend/src/routes/dashboard.routes.ts @@ -239,6 +239,122 @@ router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Res } }) +// 工作台下一步行动 — 聚合待办任务、草稿批次、到期合同、特殊状态 +router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const now = new Date() + const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + + // 1. 待办风险项 + const riskItems = await prisma.riskItem.findMany({ + where: { orgId, status: 'PENDING' }, + orderBy: { createdAt: 'desc' }, + take: 10, + select: { id: true, title: true, type: true, level: true, deadline: true, actionUrl: true, employeeId: true }, + }) + + // 2. 草稿发薪批次 + const draftBatches = await prisma.payrollBatch.findMany({ + where: { orgId, status: 'DRAFT' }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true }, + }) + + // 3. 即将到期合同(30天内) + const expiringContracts = await prisma.laborContract.findMany({ + where: { + orgId, + endDate: { gte: now, lte: in30Days }, + employee: { status: 'ACTIVE' }, + }, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { endDate: 'asc' }, + take: 10, + }) + + // 4. 特殊状态员工 + const specialStatusEmployees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + OR: [ + { isPregnant: true }, + { isInMedicalPeriod: true }, + { isWorkInjured: true }, + ], + }, + select: { id: true, name: true, department: true, isPregnant: true, isInMedicalPeriod: true, isWorkInjured: true }, + take: 10, + }) + + // 按优先级分组 + const actions: Array<{ category: string; priority: 'high' | 'medium' | 'low'; items: any[] }> = [ + { + category: '待办事项', + priority: 'high', + items: riskItems.map(r => ({ + id: r.id, + title: r.title, + type: r.type, + level: r.level, + dueDate: r.deadline?.toISOString().slice(0, 10), + link: r.actionUrl || '/', + })), + }, + { + category: '发薪批次', + priority: 'high', + items: draftBatches.map(b => ({ + id: b.id, + title: `${b.name}(${b.month})`, + subtitle: `${b.employeeCount}人 · 应发 ¥${(b.totalPay || 0).toLocaleString()}`, + link: '/money', + })), + }, + { + category: '合同到期', + priority: 'medium', + items: expiringContracts.map(c => ({ + id: c.id, + title: `${c.employee?.name} 的合同将于 ${c.endDate?.toISOString().slice(0, 10)} 到期`, + subtitle: c.employee?.department || '', + link: '/roster', + })), + }, + { + category: '特殊状态', + priority: 'medium', + items: specialStatusEmployees.map(e => ({ + id: e.id, + title: e.name, + subtitle: [ + e.isPregnant ? '孕期' : '', + e.isInMedicalPeriod ? '医疗期' : '', + e.isWorkInjured ? '工伤' : '', + ].filter(Boolean).join('、'), + link: '/special-status', + })), + }, + ] + + // 过滤空分类 + const filteredActions = actions.filter(a => a.items.length > 0) + const totalCount = filteredActions.reduce((s, a) => s + a.items.length, 0) + + res.json({ + success: true, + data: { + actions: filteredActions, + totalCount, + }, + }) + } catch (err) { + next(err) + } +}) + // 入离职统计看板 — 按月聚合入职和离职人数 router.get('/turnover-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/frontend/src/components/ui/InlineAlert.tsx b/frontend/src/components/ui/InlineAlert.tsx new file mode 100644 index 0000000..8583ae7 --- /dev/null +++ b/frontend/src/components/ui/InlineAlert.tsx @@ -0,0 +1,55 @@ +/** + * InlineAlert 内联告警组件 — 用于表单内和工作流中展示提示、警告、错误 + * 支持多种语义类型和可关闭模式 + */ +import { ReactNode, useState } from 'react' +import clsx from 'clsx' +import { Info, AlertTriangle, XCircle, CheckCircle, X } from 'lucide-react' + +type AlertType = 'info' | 'warning' | 'error' | 'success' + +interface InlineAlertProps { + type: AlertType + title?: string + children?: ReactNode + closable?: boolean + onClose?: () => void + className?: string +} + +const config: Record = { + info: { icon: Info, bg: 'bg-info/5', text: 'text-info', border: 'border-info/20', iconColor: 'text-info' }, + warning: { icon: AlertTriangle, bg: 'bg-warning/5', text: 'text-warning', border: 'border-warning/20', iconColor: 'text-warning' }, + error: { icon: XCircle, bg: 'bg-danger/5', text: 'text-danger', border: 'border-danger/20', iconColor: 'text-danger' }, + success: { icon: CheckCircle, bg: 'bg-success/5', text: 'text-success', border: 'border-success/20', iconColor: 'text-success' }, +} + +/** + * 内联告警 — 工作流中的质量门禁提示 + */ +export function InlineAlert({ type, title, children, closable, onClose, className }: InlineAlertProps) { + const [closed, setClosed] = useState(false) + if (closed) return null + + const c = config[type] + const Icon = c.icon + + return ( +
+ +
+ {title &&
{title}
} + {children &&
{children}
} +
+ {closable && ( + + )} +
+ ) +} diff --git a/frontend/src/components/ui/Stepper.tsx b/frontend/src/components/ui/Stepper.tsx new file mode 100644 index 0000000..48af6ae --- /dev/null +++ b/frontend/src/components/ui/Stepper.tsx @@ -0,0 +1,135 @@ +/** + * Stepper 步骤条组件 — 用于多步骤工作流引导 + * 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态 + */ +import { ReactNode } from 'react' +import clsx from 'clsx' +import { Check } from 'lucide-react' + +export interface Step { + key: string + title: string + description?: string + status: 'complete' | 'current' | 'pending' | 'error' +} + +interface StepperProps { + steps: Step[] + orientation?: 'horizontal' | 'vertical' + onStepClick?: (key: string) => void + className?: string +} + +/** + * Stepper 步骤条 — 展示工作流进度和步骤导航 + */ +export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) { + const currentIndex = steps.findIndex(s => s.status === 'current') + + if (orientation === 'vertical') { + return ( +
+ {steps.map((step, i) => { + const isLast = i === steps.length - 1 + const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current') + return ( +
+ {/* 左侧指示器 + 连接线 */} +
+ + {!isLast && ( +
+ )} +
+ {/* 右侧内容 */} +
+
onStepClick?.(step.key) : undefined} + className={clsx( + 'text-sm font-medium', + step.status === 'current' && 'text-ink-900', + step.status === 'complete' && 'text-ink-700', + step.status === 'pending' && 'text-ink-400', + step.status === 'error' && 'text-danger', + isClickable && 'cursor-pointer hover:text-ink-900', + )} + > + {step.title} +
+ {step.description && ( +
{step.description}
+ )} +
+
+ ) + })} +
+ ) + } + + // 横向布局 + return ( +
+ {steps.map((step, i) => { + const isLast = i === steps.length - 1 + const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current') + return ( +
+ {/* 圆点 + 标题 */} +
+ +
+
onStepClick?.(step.key) : undefined} + className={clsx( + 'text-sm font-medium whitespace-nowrap', + step.status === 'current' && 'text-ink-900', + step.status === 'complete' && 'text-ink-700', + step.status === 'pending' && 'text-ink-400', + step.status === 'error' && 'text-danger', + isClickable && 'cursor-pointer hover:text-ink-900', + )} + > + {step.title} +
+ {step.description && ( +
{step.description}
+ )} +
+
+ {/* 连接线 */} + {!isLast && ( +
+ )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 30caa25..9d212cb 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,6 +13,7 @@ import Pagination from '../components/ui/Pagination' import type { DashboardData } from '../types' import TurnoverStats from './dashboard/TurnoverStats' import PerformanceStats from './dashboard/PerformanceStats' +import { TaskCenter } from './dashboard/TaskCenter' function fmt(n: number) { return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` @@ -268,6 +269,9 @@ export default function Dashboard() { {/* 概览 Tab */} {activeTab === 'overview' && (
+ {/* 任务中心 */} + + {/* 合规健康度评分 + AI 建议卡片流 */} {complianceScore && (
diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index c40e665..8982397 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -4,6 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' import * as XLSX from 'xlsx' import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react' +import { Stepper, type Step } from '../components/ui/Stepper' +import { InlineAlert } from '../components/ui/InlineAlert' import api from '../lib/api' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' @@ -934,6 +936,39 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void )}
+ {/* 发薪工作流步骤条 */} + + + + + {/* 质量门禁 — 草稿状态下检查异常 */} + {!isArchived && batch.entries.length > 0 && ( + <> + {batch.entries.some((e: any) => e.totalPay > 0 && e.netPay <= 0) && ( + + 请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。 + + )} + {batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && ( + + 部分员工所有金额为 0,请确认是否需要填写或移除这些人员。 + + )} + {batch.entries.some((e: any) => e.riskWarnings && e.riskWarnings.length > 0) && ( + + 部分员工有薪资风险提示(如社保基数偏低/偏高),请在「风险」列查看详情。 + + )} + + )} + {/* 批次汇总 */}
diff --git a/frontend/src/pages/dashboard/TaskCenter.tsx b/frontend/src/pages/dashboard/TaskCenter.tsx new file mode 100644 index 0000000..38c0123 --- /dev/null +++ b/frontend/src/pages/dashboard/TaskCenter.tsx @@ -0,0 +1,139 @@ +/** + * TaskCenter 任务中心组件 — 展示待办队列、人员动态和下一步行动 + * 从 /dashboard/workspace/next-actions 获取数据,按优先级分组展示 + */ +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo } from 'lucide-react' +import api from '../../lib/api' +import { InlineAlert } from '../../components/ui/InlineAlert' + +interface NextAction { + id: string + title: string + subtitle?: string + type?: string + level?: string + dueDate?: string + link: string +} + +interface ActionGroup { + category: string + priority: 'high' | 'medium' | 'low' + items: NextAction[] +} + +const categoryIcons: Record = { + '待办事项': AlertCircle, + '发薪批次': Layers, + '合同到期': FileText, + '特殊状态': Heart, +} + +const priorityColors: Record = { + high: 'text-danger', + medium: 'text-warning', + low: 'text-info', +} + +/** + * 任务中心 — 首页核心组件,聚合展示需要关注的行动项 + */ +export function TaskCenter() { + const { data, isLoading } = useQuery<{ actions: ActionGroup[]; totalCount: number }>({ + queryKey: ['next-actions'], + queryFn: async () => { + const res = await api.get('/dashboard/workspace/next-actions') as any + return res.data + }, + staleTime: 60_000, + }) + + if (isLoading) { + return ( +
+
+ +

任务中心

+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ) + } + + if (!data || data.totalCount === 0) { + return ( +
+
+ +

任务中心

+
+ + 当前没有需要处理的事项,一切正常。 + +
+ ) + } + + return ( +
+
+
+ +

任务中心

+ {data.totalCount} 项待处理 +
+
+ +
+ {data.actions.map((group) => { + const Icon = categoryIcons[group.category] || AlertCircle + return ( +
+ {/* 分类标题 */} +
+ + {group.category} + ({group.items.length}) +
+ {/* 行动项列表 */} +
+ {group.items.slice(0, 5).map((item) => ( + +
+
{item.title}
+ {item.subtitle && ( +
{item.subtitle}
+ )} +
+ {item.dueDate && ( + {item.dueDate} + )} + + + ))} + {group.items.length > 5 && ( + + 查看全部 {group.items.length} 项 → + + )} +
+
+ ) + })} +
+
+ ) +}