1377 lines
53 KiB
TypeScript
1377 lines
53 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||
import Link from 'next/link'
|
||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||
import { useSession } from 'next-auth/react'
|
||
import { pinyin } from 'pinyin-pro'
|
||
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
|
||
fullPath?: string | null
|
||
}
|
||
|
||
type Template = {
|
||
code: string
|
||
name: string
|
||
shortName?: string
|
||
description: string
|
||
surveyType: string
|
||
weight: number
|
||
weightLabel?: string
|
||
maxTotal?: number
|
||
achievementLabel?: string
|
||
achievementSuffix?: string
|
||
questions: Array<{ id: string; title: string; description?: string }>
|
||
}
|
||
|
||
const API_BASE = '/api/performance-survey'
|
||
|
||
function normalizePickerSearch(value: unknown) {
|
||
return String(value ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s/_\-||,,;;>]+/g, '')
|
||
}
|
||
|
||
function pickerSearchVariants(value: unknown) {
|
||
const text = String(value ?? '').trim()
|
||
if (!text) return []
|
||
const variants = [normalizePickerSearch(text)]
|
||
try {
|
||
variants.push(
|
||
normalizePickerSearch(pinyin(text, { toneType: 'none', type: 'array' }).join('')),
|
||
normalizePickerSearch(pinyin(text, { pattern: 'first', toneType: 'none', type: 'array' }).join('')),
|
||
)
|
||
} catch {
|
||
// 非中文或异常字符按原文匹配即可。
|
||
}
|
||
return Array.from(new Set(variants.filter(Boolean)))
|
||
}
|
||
|
||
function matchesPickerSearch(keyword: string, values: unknown[]) {
|
||
const normalizedKeyword = normalizePickerSearch(keyword)
|
||
if (!normalizedKeyword) return true
|
||
return values.some((value) => pickerSearchVariants(value).some((variant) => variant.includes(normalizedKeyword)))
|
||
}
|
||
|
||
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') {
|
||
const currentPath =
|
||
typeof window === 'undefined'
|
||
? '/performance-survey'
|
||
: `${window.location.pathname}${window.location.search}`
|
||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
||
}
|
||
}, [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 && <DashboardStats data={surveys.data} />}
|
||
{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()
|
||
}}
|
||
templateStats={surveys.data?.templateStats || []}
|
||
/>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function DashboardStats({ data }: { data: any }) {
|
||
const created = data.created || []
|
||
const templateStats = data.templateStats || []
|
||
const responseTotal = created.reduce((sum: number, item: any) => sum + Number(item.counts?.responses || 0), 0)
|
||
const openTotal = created.filter((item: any) => item.status === 'open').length
|
||
const items = [
|
||
{ label: '问卷事项', value: created.length },
|
||
{ label: '评价总数', value: responseTotal },
|
||
{ label: '模板数量', value: templateStats.length },
|
||
{ label: '开放问卷', value: openTotal },
|
||
]
|
||
|
||
return (
|
||
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
{items.map((item) => (
|
||
<div key={item.label} className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">{item.label}</div>
|
||
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">{item.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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, templateStats }: { onChanged: () => Promise<void>; templateStats: any[] }) {
|
||
const templates = useApi<Template[]>('/templates', [])
|
||
const departments = useApi<DepartmentOption[]>('/departments?purpose=evaluator', [])
|
||
const notifications = useApi<any[]>('/admin/notifications?limit=30', [])
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||
<CreateSurveyForm
|
||
templates={templates.data || []}
|
||
departments={departments.data || []}
|
||
onCreated={async () => {
|
||
setMessage('已创建考核调研')
|
||
await onChanged()
|
||
await notifications.refresh()
|
||
}}
|
||
/>
|
||
<TemplateStatsPanel items={templateStats} />
|
||
</div>
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(340px,0.75fr)_minmax(0,1.25fr)]">
|
||
<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>}
|
||
</div>
|
||
<NotificationList state={notifications} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TemplateStatsPanel({ items }: { items: any[] }) {
|
||
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 space-y-3">
|
||
{items.map((item) => {
|
||
const achievement = item.responseCount > 0 ? item.aggregate?.achievementScore : '暂无'
|
||
const suffix = item.responseCount > 0 ? item.achievementSuffix || '' : ''
|
||
return (
|
||
<div key={item.code} className="grid grid-cols-1 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:gap-4">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-semibold text-gray-900 dark:text-white">{item.shortName || item.name}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.responseCount || 0} 份评价</div>
|
||
</div>
|
||
<div className="sm:w-24 sm:text-right">
|
||
<div className="text-xl font-semibold text-gray-900 dark:text-white">{achievement}{suffix}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">{item.achievementLabel || '得分'}</div>
|
||
</div>
|
||
<div className="sm:w-24 sm:text-right">
|
||
<div className="text-xl font-semibold text-gray-900 dark:text-white">{item.responseCount > 0 ? item.aggregate?.weightedScore : '暂无'}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">权重得分 · {item.weightLabel || `${Math.round(Number(item.weight || 0) * 100)}%`}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{items.length === 0 && <div className="text-sm text-gray-500 dark:text-gray-400">暂无模板数据</div>}
|
||
</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('item_course')
|
||
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 [createdSurvey, setCreatedSurvey] = useState<any | null>(null)
|
||
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)
|
||
setCreatedSurvey(null)
|
||
try {
|
||
const pendingItemName = itemName.trim()
|
||
const targetItemsToSubmit = surveyKind === 'item_course' && pendingItemName
|
||
? [...targetItems, { name: pendingItemName }]
|
||
: targetItems
|
||
const submitTitle = title.trim() || targetItemsToSubmit[0]?.name || ''
|
||
const created = await apiRequest<any>('/surveys', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: submitTitle,
|
||
description,
|
||
surveyKind,
|
||
templateCode: selectedTemplate,
|
||
dueAt: dueAt || null,
|
||
openNow,
|
||
targetEmployeeIds: targetEmployees.map((employee) => employee.id),
|
||
targetItems: targetItemsToSubmit,
|
||
evaluatorEmployeeIds: evaluatorEmployees.map((employee) => employee.id),
|
||
evaluatorDepartmentNames: evaluatorDepartments,
|
||
}),
|
||
})
|
||
setTitle('')
|
||
setDescription('')
|
||
setTargetEmployees([])
|
||
setEvaluatorEmployees([])
|
||
setEvaluatorDepartments([])
|
||
setTargetItems([])
|
||
setItemName('')
|
||
setCreatedSurvey(created)
|
||
await onCreated()
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '创建失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const surveyLink = createdSurvey?.id
|
||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/performance-survey/survey/${createdSurvey.id}`
|
||
: ''
|
||
const qrLink = surveyLink
|
||
? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=12&data=${encodeURIComponent(surveyLink)}`
|
||
: ''
|
||
|
||
const copySurveyLink = async () => {
|
||
if (!surveyLink) return
|
||
await navigator.clipboard?.writeText(surveyLink)
|
||
}
|
||
|
||
const downloadQr = async () => {
|
||
if (!qrLink) return
|
||
const response = await fetch(qrLink)
|
||
const blob = await response.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${createdSurvey?.title || title || '考核调研'}-二维码.png`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
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} · {template.weightLabel || `${Math.round(template.weight * 100)}%`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<TextInput label="说明" value={description} onChange={setDescription} />
|
||
<TextInput label={surveyKind === 'item_course' ? '发生时间' : '截止时间'} value={dueAt} onChange={setDueAt} type="datetime-local" />
|
||
|
||
{surveyKind === 'person_period' ? (
|
||
<EmployeePicker title="被考核人" purpose="target" 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="评价人" purpose="evaluator" 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>
|
||
{createdSurvey && surveyLink && (
|
||
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4 dark:border-blue-900/50 dark:bg-blue-950/30">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||
{/* 二维码来自第三方生成服务,无法走本项目的 Next Image 域名白名单。 */}
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
{qrLink && <img src={qrLink} alt="问卷二维码" className="h-28 w-28 rounded-md bg-white p-1" />}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="font-medium text-gray-900 dark:text-white">问卷已生成</div>
|
||
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-300">{surveyLink}</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button type="button" onClick={copySurveyLink} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
|
||
复制链接
|
||
</button>
|
||
<button type="button" onClick={downloadQr} className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200">
|
||
保存二维码
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmployeePicker({
|
||
title,
|
||
purpose,
|
||
selected,
|
||
onChange,
|
||
}: {
|
||
title: string
|
||
purpose?: 'target' | 'evaluator'
|
||
selected: Employee[]
|
||
onChange: (employees: Employee[]) => void
|
||
}) {
|
||
const [keyword, setKeyword] = useState('')
|
||
const [items, setItems] = useState<Employee[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [open, setOpen] = useState(false)
|
||
const pickerRef = useRef<HTMLDivElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(async () => {
|
||
if (!keyword.trim()) {
|
||
setItems([])
|
||
setError(null)
|
||
setLoading(false)
|
||
setOpen(false)
|
||
return
|
||
}
|
||
setLoading(true)
|
||
setError(null)
|
||
setOpen(true)
|
||
try {
|
||
const params = new URLSearchParams({
|
||
search: keyword,
|
||
limit: '50',
|
||
...(purpose ? { purpose } : {}),
|
||
})
|
||
const data = await apiRequest<Employee[]>(`/employees?${params.toString()}`)
|
||
setItems(data)
|
||
} catch (err) {
|
||
setItems([])
|
||
setError(err instanceof Error ? err.message : '搜索失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, 250)
|
||
|
||
return () => window.clearTimeout(timer)
|
||
}, [keyword, purpose])
|
||
|
||
useEffect(() => {
|
||
const closeWhenOutside = (event: MouseEvent) => {
|
||
if (!pickerRef.current?.contains(event.target as Node)) {
|
||
setOpen(false)
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', closeWhenOutside)
|
||
return () => document.removeEventListener('mousedown', closeWhenOutside)
|
||
}, [])
|
||
|
||
const selectedIds = new Set(selected.map((employee) => employee.id))
|
||
const selectEmployee = (employee: Employee) => {
|
||
if (selectedIds.has(employee.id)) return
|
||
onChange([...selected, employee])
|
||
setError(null)
|
||
setOpen(true)
|
||
}
|
||
|
||
return (
|
||
<div ref={pickerRef}>
|
||
<label className="grid gap-1 text-sm">
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
||
<input
|
||
value={keyword}
|
||
onFocus={() => {
|
||
if (keyword.trim()) setOpen(true)
|
||
}}
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value)
|
||
if (event.target.value.trim()) setOpen(true)
|
||
}}
|
||
placeholder="搜索花名、姓名、工号、部门、职级"
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') setOpen(false)
|
||
}}
|
||
className="rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
|
||
/>
|
||
</label>
|
||
{open && 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>
|
||
) : error ? (
|
||
<div className="px-3 py-2 text-sm text-red-600">{error}</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={() => selectEmployee(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,
|
||
department.fullPath,
|
||
].filter(Boolean)
|
||
if (!matchesPickerSearch(normalizedKeyword, haystack)) 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.fullPath || department.departmentPath || (
|
||
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 })
|
||
}
|