feat: Sprint 2 — Stepper/InlineAlert组件 + 首页TaskCenter任务中心 + /workspace/next-actions API + Money.tsx BatchDetail集成工作流步骤条和质量门禁
This commit is contained in:
@@ -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) => {
|
router.get('/turnover-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<AlertType, { icon: typeof Info; bg: string; text: string; border: string; iconColor: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className={clsx('flex items-start gap-2 rounded-md border px-3 py-2 text-sm', c.bg, c.border, c.text, className)}>
|
||||||
|
<Icon className={clsx('w-4 h-4 mt-0.5 shrink-0', c.iconColor)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{title && <div className="font-medium">{title}</div>}
|
||||||
|
{children && <div className="text-xs mt-0.5 opacity-90">{children}</div>}
|
||||||
|
</div>
|
||||||
|
{closable && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setClosed(true); onClose?.() }}
|
||||||
|
className="shrink-0 opacity-60 hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={clsx('flex flex-col', className)}>
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const isLast = i === steps.length - 1
|
||||||
|
const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current')
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex gap-3">
|
||||||
|
{/* 左侧指示器 + 连接线 */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
||||||
|
disabled={!isClickable}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-colors shrink-0',
|
||||||
|
step.status === 'complete' && 'bg-success text-white',
|
||||||
|
step.status === 'current' && 'bg-brand-600 text-white ring-4 ring-brand-600/20',
|
||||||
|
step.status === 'pending' && 'bg-surface-muted text-ink-400',
|
||||||
|
step.status === 'error' && 'bg-danger text-white',
|
||||||
|
isClickable && 'cursor-pointer hover:opacity-80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.status === 'complete' ? <Check className="w-4 h-4" /> : i + 1}
|
||||||
|
</button>
|
||||||
|
{!isLast && (
|
||||||
|
<div className={clsx('w-0.5 flex-1 min-h-[24px] my-1', step.status === 'complete' ? 'bg-success' : 'bg-border-subtle')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 右侧内容 */}
|
||||||
|
<div className={clsx('pb-4', isLast && 'pb-0')}>
|
||||||
|
<div
|
||||||
|
onClick={isClickable ? () => 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}
|
||||||
|
</div>
|
||||||
|
{step.description && (
|
||||||
|
<div className="text-xs text-ink-500 mt-0.5">{step.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 横向布局
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex items-center', className)}>
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const isLast = i === steps.length - 1
|
||||||
|
const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current')
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center flex-1 last:flex-none">
|
||||||
|
{/* 圆点 + 标题 */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
||||||
|
disabled={!isClickable}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium transition-colors shrink-0',
|
||||||
|
step.status === 'complete' && 'bg-success text-white',
|
||||||
|
step.status === 'current' && 'bg-brand-600 text-white ring-4 ring-brand-600/20',
|
||||||
|
step.status === 'pending' && 'bg-surface-muted text-ink-400',
|
||||||
|
step.status === 'error' && 'bg-danger text-white',
|
||||||
|
isClickable && 'cursor-pointer hover:opacity-80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.status === 'complete' ? <Check className="w-3.5 h-3.5" /> : i + 1}
|
||||||
|
</button>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div
|
||||||
|
onClick={isClickable ? () => 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}
|
||||||
|
</div>
|
||||||
|
{step.description && (
|
||||||
|
<div className="text-xs text-ink-500 whitespace-nowrap">{step.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 连接线 */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className={clsx('h-0.5 flex-1 mx-2', step.status === 'complete' ? 'bg-success' : 'bg-border-subtle')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import Pagination from '../components/ui/Pagination'
|
|||||||
import type { DashboardData } from '../types'
|
import type { DashboardData } from '../types'
|
||||||
import TurnoverStats from './dashboard/TurnoverStats'
|
import TurnoverStats from './dashboard/TurnoverStats'
|
||||||
import PerformanceStats from './dashboard/PerformanceStats'
|
import PerformanceStats from './dashboard/PerformanceStats'
|
||||||
|
import { TaskCenter } from './dashboard/TaskCenter'
|
||||||
|
|
||||||
function fmt(n: number) {
|
function fmt(n: number) {
|
||||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
@@ -268,6 +269,9 @@ export default function Dashboard() {
|
|||||||
{/* 概览 Tab */}
|
{/* 概览 Tab */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* 任务中心 */}
|
||||||
|
<TaskCenter />
|
||||||
|
|
||||||
{/* 合规健康度评分 + AI 建议卡片流 */}
|
{/* 合规健康度评分 + AI 建议卡片流 */}
|
||||||
{complianceScore && (
|
{complianceScore && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
import * as XLSX from 'xlsx'
|
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 { 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 api from '../lib/api'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -934,6 +936,39 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 发薪工作流步骤条 */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<Stepper
|
||||||
|
steps={[
|
||||||
|
{ key: 'edit', title: '编辑薪资', description: '填写/导入工资数据', status: isArchived ? 'complete' : 'current' },
|
||||||
|
{ key: 'review', title: '核对汇总', description: '检查应发/个税/实发', status: isArchived ? 'complete' : 'pending' },
|
||||||
|
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
|
||||||
|
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
|
||||||
|
] as Step[]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 质量门禁 — 草稿状态下检查异常 */}
|
||||||
|
{!isArchived && batch.entries.length > 0 && (
|
||||||
|
<>
|
||||||
|
{batch.entries.some((e: any) => e.totalPay > 0 && e.netPay <= 0) && (
|
||||||
|
<InlineAlert type="error" title="存在实发为负的员工">
|
||||||
|
请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
|
||||||
|
<InlineAlert type="warning" title="存在全零记录">
|
||||||
|
部分员工所有金额为 0,请确认是否需要填写或移除这些人员。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
{batch.entries.some((e: any) => e.riskWarnings && e.riskWarnings.length > 0) && (
|
||||||
|
<InlineAlert type="warning" title="存在风险预警">
|
||||||
|
部分员工有薪资风险提示(如社保基数偏低/偏高),请在「风险」列查看详情。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 批次汇总 */}
|
{/* 批次汇总 */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<Card className="flex items-center gap-3">
|
<Card className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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<string, typeof AlertCircle> = {
|
||||||
|
'待办事项': AlertCircle,
|
||||||
|
'发薪批次': Layers,
|
||||||
|
'合同到期': FileText,
|
||||||
|
'特殊状态': Heart,
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ListTodo className="w-4 h-4 text-brand-600" />
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">任务中心</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-12 rounded-md bg-surface-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.totalCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ListTodo className="w-4 h-4 text-brand-600" />
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">任务中心</h3>
|
||||||
|
</div>
|
||||||
|
<InlineAlert type="success" title="暂无待办">
|
||||||
|
当前没有需要处理的事项,一切正常。
|
||||||
|
</InlineAlert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="w-4 h-4 text-brand-600" />
|
||||||
|
<h3 className="text-sm font-medium text-ink-700">任务中心</h3>
|
||||||
|
<span className="text-xs text-ink-400">{data.totalCount} 项待处理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.actions.map((group) => {
|
||||||
|
const Icon = categoryIcons[group.category] || AlertCircle
|
||||||
|
return (
|
||||||
|
<div key={group.category}>
|
||||||
|
{/* 分类标题 */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${priorityColors[group.priority]}`} />
|
||||||
|
<span className="text-xs font-medium text-ink-600">{group.category}</span>
|
||||||
|
<span className="text-xs text-ink-400">({group.items.length})</span>
|
||||||
|
</div>
|
||||||
|
{/* 行动项列表 */}
|
||||||
|
<div className="space-y-1 ml-5">
|
||||||
|
{group.items.slice(0, 5).map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={item.link}
|
||||||
|
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md hover:bg-surface-muted transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-ink-900 truncate">{item.title}</div>
|
||||||
|
{item.subtitle && (
|
||||||
|
<div className="text-xs text-ink-400 truncate">{item.subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.dueDate && (
|
||||||
|
<span className="text-xs text-ink-400 shrink-0">{item.dueDate}</span>
|
||||||
|
)}
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 text-ink-300 group-hover:text-brand-600 shrink-0 transition-colors" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{group.items.length > 5 && (
|
||||||
|
<Link
|
||||||
|
to={group.category === '发薪批次' ? '/money' : group.category === '合同到期' ? '/roster' : '/'}
|
||||||
|
className="block text-xs text-brand-600 hover:underline px-2 py-1"
|
||||||
|
>
|
||||||
|
查看全部 {group.items.length} 项 →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user