1174 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import Header from '@/components/Header'
type ApiState<T> = {
loading: boolean
data: T | null
error: string | null
}
type Employee = {
id: string
displayName: string
flowerName?: string | null
realName?: string | null
dingEmail?: string | null
jobNumber?: string | null
departmentName?: string | null
positionTitle?: string | null
positionLevel?: string | null
levelType?: string | null
isLeadership?: boolean | null
managerName?: string | null
dingtalkUserId?: string | null
}
type DepartmentOption = {
id: string
name: string
parentId?: string | null
parentName?: string | null
employeeCount?: number
selectable?: boolean
value?: string | null
departmentName?: string | null
departmentPath?: string | null
}
type Template = {
code: string
name: string
description: string
surveyType: string
weight: number
questions: Array<{ id: string; title: string; description?: string }>
}
const API_BASE = '/api/performance-survey'
async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options?.headers || {}),
},
})
const contentType = response.headers.get('content-type') || ''
const payload = contentType.includes('application/json') ? await response.json() : await response.text()
if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) {
throw new Error(
payload && typeof payload === 'object'
? payload.error || payload.message || '请求失败'
: String(payload || '请求失败'),
)
}
return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) as T
}
function useApi<T>(path: string | null, deps: unknown[] = []): ApiState<T> & { refresh: () => Promise<void> } {
const [state, setState] = useState<ApiState<T>>({ loading: Boolean(path), data: null, error: null })
const load = async () => {
if (!path) return
setState((current) => ({ ...current, loading: true, error: null }))
try {
const data = await apiRequest<T>(path)
setState({ loading: false, data, error: null })
} catch (error) {
setState({ loading: false, data: null, error: error instanceof Error ? error.message : '加载失败' })
}
}
useEffect(() => {
void load()
}, deps)
return { ...state, refresh: load }
}
export default function PerformanceSurveyPage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const path = Array.isArray((params as any).path) ? ((params as any).path as string[]) : []
useEffect(() => {
if (status === 'unauthenticated') {
router.push(`/login?redirect=${encodeURIComponent('/performance-survey')}`)
}
}, [status, router])
useEffect(() => {
if (path[0] === 'teacher' || path[0] === 'admin') {
router.replace('/performance-survey')
}
}, [path, router])
if (status === 'loading') {
return <LoadingPage />
}
if (!session) {
return null
}
let content = <Dashboard />
if (path[0] === 'survey' && path[1]) {
content = <SurveySubmit surveyId={path[1]} />
}
if (path[0] === 'results' && path[1]) {
content = <SurveyResults surveyId={path[1]} />
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
{content}
</div>
)
}
function Dashboard() {
const [tab, setTab] = useState('pending')
const surveys = useApi<any>('/surveys', [])
const me = useApi<any>('/me', [])
const canManage = Boolean(me.data?.permissions?.isDepartmentAdmin)
const tabs = [
{ id: 'pending', label: '待我评价' },
{ id: 'created', label: '我发起的' },
{ id: 'results', label: '我的结果' },
...(canManage ? [{ id: 'manage', label: '管理' }] : []),
]
return (
<main className="mx-auto max-w-7xl px-4 py-6">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">考核调研</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>{me.data?.user?.name || '当前用户'}</span>
{me.data?.employee?.departmentName && <span>{me.data.employee.departmentName}</span>}
{me.data?.employee?.positionLevel && <span>{me.data.employee.positionLevel}</span>}
</div>
</div>
<div className="flex rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-800">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
tab === item.id
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{item.label}
</button>
))}
</div>
</div>
{surveys.loading && <Panel>加载中...</Panel>}
{surveys.error && <ErrorPanel message={surveys.error} />}
{surveys.data && tab === 'pending' && <PendingList items={surveys.data.pending || []} />}
{surveys.data && tab === 'created' && (
<CreatedList items={surveys.data.created || []} onRefresh={surveys.refresh} />
)}
{surveys.data && tab === 'results' && <MyResults items={surveys.data.results || []} />}
{canManage && tab === 'manage' && (
<ManagePanel
onChanged={async () => {
await surveys.refresh()
}}
/>
)}
</main>
)
}
function PendingList({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无待评价" />
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<div key={item.assignmentId} className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">{item.title}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{item.targetName}</p>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{item.dueAt ? `截止 ${formatDate(item.dueAt)}` : '无截止时间'}</span>
<Link
href={`/performance-survey/survey/${item.surveyId}?targetId=${item.targetId}`}
className="rounded-md bg-gray-900 px-3 py-1.5 text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
>
填写
</Link>
</div>
</div>
))}
</div>
)
}
function CreatedList({ items, onRefresh }: { items: any[]; onRefresh: () => Promise<void> }) {
const [busyId, setBusyId] = useState<string | null>(null)
const changeStatus = async (id: string, status: string) => {
setBusyId(id)
try {
await apiRequest(`/surveys/${id}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
await onRefresh()
} finally {
setBusyId(null)
}
}
if (items.length === 0) {
return <EmptyState title="还没有发起过考核调研" />
}
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/40">
<tr>
{['调研', '状态', '对象/评价/提交', '截止', '操作'].map((item) => (
<th key={item} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
{item}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{item.title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{item.departmentName || '未指定部门'}</div>
</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{item.counts?.targets || 0} / {item.counts?.assignments || 0} / {item.counts?.responses || 0}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{item.dueAt ? formatDate(item.dueAt) : '-'}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
{item.status === 'draft' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'open')}
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
发布
</button>
)}
{item.status !== 'closed' && (
<button
disabled={busyId === item.id}
onClick={() => changeStatus(item.id, 'closed')}
className="rounded-md border border-gray-300 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
关闭
</button>
)}
<Link
href={`/performance-survey/results/${item.id}`}
className="rounded-md bg-gray-900 px-2.5 py-1 text-sm text-white hover:bg-gray-700 dark:bg-white dark:text-gray-900"
>
结果
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function MyResults({ items }: { items: any[] }) {
if (items.length === 0) {
return <EmptyState title="暂无可查看结果" />
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<Link
key={`${item.surveyId}-${item.targetId}`}
href={`/performance-survey/results/${item.surveyId}?targetId=${item.targetId}`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-500"
>
<h2 className="font-semibold text-gray-900 dark:text-white">{item.title}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{item.targetName}</p>
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">{item.closedAt ? formatDate(item.closedAt) : ''}</p>
</Link>
))}
</div>
)
}
function ManagePanel({ onChanged }: { onChanged: () => Promise<void> }) {
const templates = useApi<Template[]>('/templates', [])
const departments = useApi<DepartmentOption[]>('/departments', [])
const notifications = useApi<any[]>('/admin/notifications?limit=30', [])
const [message, setMessage] = useState<string | null>(null)
return (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(340px,0.75fr)]">
<CreateSurveyForm
templates={templates.data || []}
departments={departments.data || []}
onCreated={async () => {
setMessage('已创建考核调研')
await onChanged()
await notifications.refresh()
}}
/>
<div className="space-y-5">
<AdminActions
onMessage={setMessage}
onNotificationsChanged={notifications.refresh}
/>
{message && <Panel><span className="text-sm text-green-700 dark:text-green-300">{message}</span></Panel>}
<NotificationList state={notifications} />
</div>
</div>
)
}
function CreateSurveyForm({
templates,
departments,
onCreated,
}: {
templates: Template[]
departments: DepartmentOption[]
onCreated: () => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [surveyKind, setSurveyKind] = useState('person_period')
const [templateCode, setTemplateCode] = useState('')
const [dueAt, setDueAt] = useState('')
const [openNow, setOpenNow] = useState(true)
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
const [evaluatorEmployees, setEvaluatorEmployees] = useState<Employee[]>([])
const [evaluatorDepartments, setEvaluatorDepartments] = useState<string[]>([])
const [itemName, setItemName] = useState('')
const [targetItems, setTargetItems] = useState<Array<{ name: string }>>([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const activeTemplates = templates.filter((template) => template.surveyType === surveyKind)
const selectedTemplate = templateCode || activeTemplates[0]?.code || templates[0]?.code || ''
const submit = async () => {
setSaving(true)
setError(null)
try {
await apiRequest('/surveys', {
method: 'POST',
body: JSON.stringify({
title,
description,
surveyKind,
templateCode: selectedTemplate,
dueAt: dueAt || null,
openNow,
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
targetItems,
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
evaluatorDepartmentNames: evaluatorDepartments,
}),
})
setTitle('')
setDescription('')
setTargetEmployees([])
setEvaluatorEmployees([])
setEvaluatorDepartments([])
setTargetItems([])
setItemName('')
await onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败')
} finally {
setSaving(false)
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">新建考核调研</h2>
<div className="mt-4 grid gap-4">
<TextInput label="标题" value={title} onChange={setTitle} />
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">类型</span>
<select
value={surveyKind}
onChange={(event) => {
setSurveyKind(event.target.value)
setTemplateCode('')
}}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
<option value="person_period">人员周期考核</option>
<option value="item_course">课程/事项/活动调研</option>
</select>
</label>
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">模板</span>
<select
value={selectedTemplate}
onChange={(event) => setTemplateCode(event.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
{(activeTemplates.length > 0 ? activeTemplates : templates).map((template) => (
<option key={template.code} value={template.code}>
{template.name}
</option>
))}
</select>
</label>
<TextInput label="说明" value={description} onChange={setDescription} />
<TextInput label="截止时间" value={dueAt} onChange={setDueAt} type="datetime-local" />
{surveyKind === 'person_period' ? (
<EmployeePicker title="被考核人" selected={targetEmployees} onChange={setTargetEmployees} />
) : (
<div>
<div className="flex gap-2">
<input
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder="课程、事项或活动名称"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
<button
type="button"
onClick={() => {
const name = itemName.trim()
if (!name) return
setTargetItems((current) => [...current, { name }])
setItemName('')
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:text-gray-200"
>
添加
</button>
</div>
<SelectedChips
items={targetItems.map((item) => ({ id: item.name, label: item.name }))}
onRemove={(id) => setTargetItems((current) => current.filter((item) => item.name !== id))}
/>
</div>
)}
<EmployeePicker title="评价人" selected={evaluatorEmployees} onChange={setEvaluatorEmployees} />
<DepartmentPicker
departments={departments}
selected={evaluatorDepartments}
onChange={setEvaluatorDepartments}
/>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" checked={openNow} onChange={(event) => setOpenNow(event.target.checked)} />
创建后立即发布
</label>
{error && <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{error}</div>}
<button
disabled={saving}
onClick={submit}
className="rounded-lg bg-gray-900 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900"
>
{saving ? '创建中...' : '创建考核调研'}
</button>
</div>
</div>
)
}
function EmployeePicker({
title,
selected,
onChange,
}: {
title: string
selected: Employee[]
onChange: (employees: Employee[]) => void
}) {
const [keyword, setKeyword] = useState('')
const [items, setItems] = useState<Employee[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const timer = window.setTimeout(async () => {
if (!keyword.trim()) {
setItems([])
return
}
setLoading(true)
try {
const data = await apiRequest<Employee[]>(`/employees?search=${encodeURIComponent(keyword)}&limit=20`)
setItems(data)
} finally {
setLoading(false)
}
}, 250)
return () => window.clearTimeout(timer)
}, [keyword])
const selectedIds = new Set(selected.map((employee) => employee.id))
return (
<div>
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{title}</span>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索花名、姓名、工号、部门、职级"
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
{keyword && (
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">搜索中...</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">无匹配人员</div>
) : (
items.map((employee) => (
<button
key={employee.id}
type="button"
disabled={selectedIds.has(employee.id)}
onClick={() => onChange([...selected, employee])}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 disabled:opacity-50 dark:hover:bg-gray-700"
>
<div className="font-medium text-gray-900 dark:text-white">{employee.displayName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{[
employee.departmentName,
employee.positionTitle,
employee.positionLevel || employee.levelType,
employee.isLeadership ? 'M层' : employee.levelType === 'P' ? 'P序列' : null,
employee.managerName ? `主管:${employee.managerName}` : null,
employee.jobNumber,
employee.dingEmail,
].filter(Boolean).join(' · ')}
</div>
</button>
))
)}
</div>
)}
<SelectedChips
items={selected.map((employee) => ({ id: employee.id, label: employee.displayName }))}
onRemove={(id) => onChange(selected.filter((employee) => employee.id !== id))}
/>
</div>
)
}
function DepartmentPicker({
departments,
selected,
onChange,
}: {
departments: DepartmentOption[]
selected: string[]
onChange: (items: string[]) => void
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [keyword, setKeyword] = useState('')
const { roots, childrenByParent, optionByValue, visibleIds } = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase()
const byId = new Map<string, DepartmentOption>()
const children = new Map<string, DepartmentOption[]>()
departments.forEach((department) => {
byId.set(department.id, department)
const parentKey = department.parentId || ''
const list = children.get(parentKey) || []
list.push(department)
children.set(parentKey, list)
})
children.forEach((list) => {
list.sort((a, b) => {
const countDiff = (b.employeeCount || 0) - (a.employeeCount || 0)
if (countDiff !== 0) return countDiff
return a.name.localeCompare(b.name, 'zh-CN')
})
})
const values = new Map<string, DepartmentOption>()
departments.forEach((department) => {
if (department.value) values.set(department.value, department)
})
const matchedIds = new Set<string>()
if (normalizedKeyword) {
departments.forEach((department) => {
const haystack = [
department.name,
department.parentName,
department.departmentName,
department.departmentPath,
].filter(Boolean).join(' ').toLowerCase()
if (!haystack.includes(normalizedKeyword)) return
let current: DepartmentOption | undefined = department
while (current) {
matchedIds.add(current.id)
current = current.parentId ? byId.get(current.parentId) : undefined
}
})
}
return {
roots: children.get('') || [],
childrenByParent: children,
optionByValue: values,
visibleIds: matchedIds,
}
}, [departments, keyword])
const selectedLabels = selected
.map((value) => optionByValue.get(value))
.filter((item): item is DepartmentOption => Boolean(item))
const toggle = (id: string) => {
setExpanded((current) => {
const next = new Set(current)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const toggleValue = (value: string, checked: boolean) => {
onChange(
checked
? Array.from(new Set([...selected, value]))
: selected.filter((item) => item !== value),
)
}
const renderNode = (department: DepartmentOption, depth = 0): ReactNode => {
const children = childrenByParent.get(department.id) || []
const hasChildren = children.length > 0
const isExpanded = expanded.has(department.id) || Boolean(keyword.trim())
const isVisible = !keyword.trim() || visibleIds.has(department.id)
if (!isVisible) return null
return (
<div key={department.id}>
<div
className="flex min-h-9 items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
style={{ paddingLeft: `${8 + depth * 18}px` }}
>
<button
type="button"
onClick={() => hasChildren && toggle(department.id)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border text-xs ${
hasChildren
? 'border-gray-300 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600'
: 'border-transparent text-transparent'
}`}
aria-label={hasChildren ? `${isExpanded ? '收起' : '展开'}${department.name}` : undefined}
disabled={!hasChildren}
>
{hasChildren ? (isExpanded ? '-' : '+') : ''}
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-gray-800 dark:text-gray-100">{department.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{department.employeeCount || 0} 人
</div>
</div>
{department.selectable !== false && department.value && (
<input
type="checkbox"
checked={selected.includes(department.value)}
onChange={(event) => toggleValue(department.value!, event.target.checked)}
aria-label={`选择${department.name}`}
/>
)}
</div>
{hasChildren && isExpanded && children.map((child) => renderNode(child, depth + 1))}
</div>
)
}
if (departments.length === 0) return null
return (
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">部门快选评价人</div>
{selected.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className="text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
清空
</button>
)}
</div>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索部门或子归属"
className="mb-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-200 p-2 dark:border-gray-700">
{roots.map((department) => renderNode(department))}
</div>
{selectedLabels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedLabels.map((department) => (
<button
key={department.value || department.id}
type="button"
onClick={() => department.value && toggleValue(department.value, false)}
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
>
{department.parentName && department.parentName !== '全部部门'
? `${department.parentName} / ${department.name}`
: department.name} ×
</button>
))}
</div>
)}
</div>
)
}
function AdminActions({
onMessage,
onNotificationsChanged,
}: {
onMessage: (message: string | null) => void
onNotificationsChanged: () => Promise<void>
}) {
const [busy, setBusy] = useState<string | null>(null)
const run = async (key: string, path: string, success: string) => {
setBusy(key)
onMessage(null)
try {
const data = await apiRequest<any>(path, { method: 'POST', body: JSON.stringify({}) })
onMessage(`${success}${data?.upserted != null ? `:${data.upserted} 人` : ''}`)
await onNotificationsChanged()
} catch (error) {
onMessage(error instanceof Error ? error.message : '操作失败')
} finally {
setBusy(null)
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="font-semibold text-gray-900 dark:text-white">管理操作</h2>
<div className="mt-3 grid gap-2">
<button
disabled={busy !== null}
onClick={() => run('sync', '/admin/sync-employees', '人员档案已同步')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{busy === 'sync' ? '同步中...' : '同步人员档案'}
</button>
<button
disabled={busy !== null}
onClick={() => run('notice', '/admin/notifications/maintenance', '通知维护已执行')}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{busy === 'notice' ? '执行中...' : '执行通知维护'}
</button>
<a
href="/api/performance-survey/admin/export"
className="rounded-lg border border-gray-300 px-3 py-2 text-center text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
导出结果
</a>
</div>
</div>
)
}
function NotificationList({ state }: { state: ApiState<any[]> & { refresh: () => Promise<void> } }) {
const [busyId, setBusyId] = useState<string | null>(null)
const retry = async (id: string) => {
setBusyId(id)
try {
await apiRequest(`/admin/notifications/${id}/retry`, { method: 'POST', body: JSON.stringify({}) })
await state.refresh()
} finally {
setBusyId(null)
}
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900 dark:text-white">钉钉通知</h2>
<button onClick={state.refresh} className="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-300">刷新</button>
</div>
<div className="mt-3 space-y-2">
{state.loading && <div className="text-sm text-gray-500">加载中...</div>}
{state.error && <div className="text-sm text-red-600">{state.error}</div>}
{state.data?.length === 0 && <div className="text-sm text-gray-500">暂无通知记录</div>}
{state.data?.map((item) => (
<div key={item.id} className="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{item.recipientName} · {item.eventType}</div>
{item.lastError && <div className="mt-1 text-xs text-red-600 dark:text-red-300">{item.lastError}</div>}
</div>
<StatusBadge status={item.status} />
</div>
{['retrying', 'exhausted', 'skipped'].includes(item.status) && (
<button
disabled={busyId === item.id}
onClick={() => retry(item.id)}
className="mt-2 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
补发
</button>
)}
</div>
))}
</div>
</div>
)
}
function SurveySubmit({ surveyId }: { surveyId: string }) {
const searchParams = useSearchParams()
const targetIdParam = searchParams.get('targetId')
const detail = useApi<any>(`/surveys/${surveyId}`, [surveyId])
const [targetId, setTargetId] = useState(targetIdParam || '')
const [scores, setScores] = useState<Record<string, number>>({})
const [comment, setComment] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const targets = useMemo(
() => (detail.data?.targets || []).filter((target: any) => target.currentAssignment),
[detail.data],
)
const activeTargetId = targetId || targets[0]?.id || ''
const template = detail.data?.template as Template | undefined
useEffect(() => {
if (!targetId && targets[0]?.id) setTargetId(targets[0].id)
}, [targets, targetId])
useEffect(() => {
if (template?.questions) {
setScores(
Object.fromEntries(
template.questions.map((question) => [question.id, 16]),
),
)
}
}, [template?.code])
const submit = async () => {
setSubmitting(true)
setMessage(null)
try {
await apiRequest(`/surveys/${surveyId}/responses`, {
method: 'POST',
body: JSON.stringify({ targetId: activeTargetId, scores, comment }),
})
setMessage('提交成功')
await detail.refresh()
} catch (error) {
setMessage(error instanceof Error ? error.message : '提交失败')
} finally {
setSubmitting(false)
}
}
if (detail.loading) return <MainPanel>加载中...</MainPanel>
if (detail.error) return <MainPanel><ErrorPanel message={detail.error} /></MainPanel>
if (!detail.data) return null
const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId)
const submitted = activeTarget?.currentAssignment?.status === 'submitted'
return (
<main className="mx-auto max-w-4xl px-4 py-6">
<div className="mb-4">
<Link href="/performance-survey" className="text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">返回考核调研</Link>
<h1 className="mt-3 text-2xl font-semibold text-gray-900 dark:text-white">{detail.data.title}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<StatusBadge status={detail.data.status} />
{detail.data.dueAt && <span>截止 {formatDate(detail.data.dueAt)}</span>}
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
{targets.length > 1 && (
<label className="mb-4 grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">评价对象</span>
<select
value={activeTargetId}
onChange={(event) => setTargetId(event.target.value)}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
{targets.map((target: any) => (
<option key={target.id} value={target.id}>{target.targetName}</option>
))}
</select>
</label>
)}
{activeTarget && (
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
{activeTarget.targetName}
</div>
)}
{submitted ? (
<EmptyState title="你已提交该评价" />
) : detail.data.status !== 'open' ? (
<EmptyState title="该调研未开放或已关闭" />
) : (
<div className="space-y-5">
{template?.questions?.map((question) => (
<div key={question.id}>
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="font-medium text-gray-900 dark:text-white">{question.title}</div>
{question.description && <div className="text-sm text-gray-500 dark:text-gray-400">{question.description}</div>}
</div>
<span className="w-10 text-right text-sm font-semibold text-gray-900 dark:text-white">{scores[question.id] ?? 16}</span>
</div>
<input
type="range"
min={0}
max={20}
value={scores[question.id] ?? 16}
onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))}
className="w-full"
/>
</div>
))}
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">补充评价</span>
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
{message && <div className="text-sm text-gray-600 dark:text-gray-300">{message}</div>}
<button
disabled={submitting || !activeTargetId}
onClick={submit}
className="rounded-lg bg-gray-900 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900"
>
{submitting ? '提交中...' : '提交评价'}
</button>
</div>
)}
</div>
</main>
)
}
function SurveyResults({ surveyId }: { surveyId: string }) {
const searchParams = useSearchParams()
const query = searchParams.get('targetId') ? `?targetId=${encodeURIComponent(searchParams.get('targetId') || '')}` : ''
const result = useApi<any>(`/results/${surveyId}${query}`, [surveyId, query])
if (result.loading) return <MainPanel>加载中...</MainPanel>
if (result.error) return <MainPanel><ErrorPanel message={result.error} /></MainPanel>
if (!result.data) return null
return (
<main className="mx-auto max-w-6xl px-4 py-6">
<div className="mb-4">
<Link href="/performance-survey" className="text-sm text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">返回考核调研</Link>
<h1 className="mt-3 text-2xl font-semibold text-gray-900 dark:text-white">{result.data.survey.title}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
<StatusBadge status={result.data.survey.status} />
{result.data.survey.closedAt && <span>关闭 {formatDate(result.data.survey.closedAt)}</span>}
</div>
</div>
<div className="grid gap-4">
{result.data.targets.map((target: any) => (
<div key={target.id} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{target.targetName}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
有效样本 {target.aggregate.effectiveCount}/{target.aggregate.responseCount}
{target.aggregate.droppedHighestLowest ? ' · 已去最高/最低' : ''}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<Metric label="达成分" value={target.aggregate.achievementScore} />
<Metric label="KPI分" value={target.aggregate.kpiScore} />
<Metric label="加权分" value={target.aggregate.weightedScore} />
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{target.responses.map((response: any) => (
<div key={response.id} className="rounded-lg bg-gray-50 p-3 dark:bg-gray-900/40">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-white">{response.respondentName}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{response.totalScore}</span>
</div>
{response.comment && <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{response.comment}</p>}
</div>
))}
{target.responses.length === 0 && <div className="text-sm text-gray-500">暂无评价</div>}
</div>
</div>
))}
</div>
</main>
)
}
function TextInput({
label,
value,
onChange,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
}) {
return (
<label className="grid gap-1 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
/>
</label>
)
}
function SelectedChips({ items, onRemove }: { items: Array<{ id: string; label: string }>; onRemove: (id: string) => void }) {
if (items.length === 0) return null
return (
<div className="mt-2 flex flex-wrap gap-2">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onRemove(item.id)}
className="rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200"
>
{item.label} ×
</button>
))}
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: '草稿',
open: '进行中',
closed: '已关闭',
pending: '待提交',
submitted: '已提交',
succeeded: '已发送',
retrying: '重试中',
exhausted: '失败',
skipped: '未发送',
processing: '处理中',
}
return (
<span className="inline-flex rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{map[status] || status}
</span>
)
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-20 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-900/40">
<div className="text-lg font-semibold text-gray-900 dark:text-white">{value}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
</div>
)
}
function Panel({ children }: { children: ReactNode }) {
return <div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">{children}</div>
}
function MainPanel({ children }: { children: ReactNode }) {
return <main className="mx-auto max-w-4xl px-4 py-6"><Panel>{children}</Panel></main>
}
function ErrorPanel({ message }: { message: string }) {
return <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-200">{message}</div>
}
function EmptyState({ title }: { title: string }) {
return (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-4 py-10 text-center text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
{title}
</div>
)
}
function LoadingPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
加载中...
</div>
)
}
function formatDate(value: string | Date) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { hour12: false })
}