221 lines
14 KiB
TypeScript
221 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { Check, UsersRound } from 'lucide-react'
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { teamFunctionInputSchema, type FunctionBranch, type TeamFunction, type TeamFunctionInput, type TeamFunctionPayload } from '@/lib/team-function-contract'
|
||
import DepartmentShell from './DepartmentShell'
|
||
|
||
const toneStyles = {
|
||
coral: {
|
||
accent: '#e4473f',
|
||
accentSoft: '#fff1ef',
|
||
accentMuted: '#a62f2a',
|
||
line: '#f2b3ae',
|
||
node: '#fff9f8',
|
||
},
|
||
navy: {
|
||
accent: '#26364f',
|
||
accentSoft: '#eef2f7',
|
||
accentMuted: '#26364f',
|
||
line: '#a9b5c7',
|
||
node: '#fafcff',
|
||
},
|
||
blue: {
|
||
accent: '#2384e8',
|
||
accentSoft: '#edf6ff',
|
||
accentMuted: '#176fc1',
|
||
line: '#a9cef6',
|
||
node: '#fbfdff',
|
||
},
|
||
teal: {
|
||
accent: '#138d73',
|
||
accentSoft: '#edf9f5',
|
||
accentMuted: '#087052',
|
||
line: '#a4d6c8',
|
||
node: '#fbfffd',
|
||
},
|
||
} as const
|
||
|
||
type Tone = keyof typeof toneStyles
|
||
const tones: Tone[] = ['coral', 'navy', 'blue', 'teal']
|
||
const fieldClass = 'min-h-11 w-full rounded-lg border border-[#dfe5ed] bg-white px-3 py-2 text-sm text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc]'
|
||
const buttonClass = 'min-h-11 rounded-lg px-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50'
|
||
|
||
function leadLabel(team: TeamFunction) {
|
||
return team.lead?.name ?? (team.leadStatus === 'invalid' ? '关联已失效' : '未设置')
|
||
}
|
||
|
||
function Branch({ branch, tone }: { branch: FunctionBranch; tone: Tone }) {
|
||
const colors = toneStyles[tone]
|
||
|
||
return (
|
||
<section className="relative grid gap-3 md:grid-cols-[108px_minmax(0,1fr)] md:items-start md:gap-5">
|
||
<div className="relative flex min-h-10 items-center md:justify-end">
|
||
<span
|
||
className="inline-flex min-h-9 items-center rounded-lg border px-3 py-2 text-sm font-bold"
|
||
style={{ borderColor: colors.line, backgroundColor: colors.accentSoft, color: colors.accentMuted }}
|
||
>
|
||
{branch.label}
|
||
</span>
|
||
</div>
|
||
<div className="relative grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
{branch.items.map((item, index) => (
|
||
<div
|
||
key={index}
|
||
data-team-item={item}
|
||
className="flex min-h-10 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold leading-5"
|
||
style={{ borderColor: colors.line, backgroundColor: colors.node, color: '#26364f' }}
|
||
>
|
||
<Check className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} aria-hidden="true" />
|
||
<span>{item}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default function TeamFunctionsDashboard() {
|
||
const [payload, setPayload] = useState<TeamFunctionPayload | null>(null)
|
||
const [selectedId, setSelectedId] = useState('')
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [draft, setDraft] = useState<TeamFunctionInput | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const load = useCallback(async () => {
|
||
setLoading(true); setError('')
|
||
try {
|
||
const response = await fetch('/api/organization/team-functions', { cache: 'no-store' })
|
||
const data = await response.json()
|
||
if (!response.ok) throw new Error(data.error || '团队职能加载失败。')
|
||
setPayload(data)
|
||
setSelectedId(current => data.teams.some((team: TeamFunction) => team.organizationUnitId === current) ? current : data.teams[0]?.organizationUnitId ?? '')
|
||
} catch (failure) { setError(failure instanceof Error ? failure.message : '团队职能加载失败。') }
|
||
finally { setLoading(false) }
|
||
}, [])
|
||
useEffect(() => { void load() }, [load])
|
||
const selectedTeam = payload?.teams.find(team => team.organizationUnitId === selectedId)
|
||
const tone = tones[Math.max(0, payload?.teams.findIndex(team => team.organizationUnitId === selectedId) ?? 0) % tones.length]
|
||
const colors = toneStyles[tone]
|
||
const original = selectedTeam ? { organizationUnitId: selectedTeam.organizationUnitId, revision: selectedTeam.revision,
|
||
leadEmployeeProfileId: selectedTeam.lead?.employeeProfileId ?? null, description: selectedTeam.description, branches: selectedTeam.branches } : null
|
||
const dirty = !!draft && JSON.stringify(draft) !== JSON.stringify(original)
|
||
useEffect(() => {
|
||
if (!dirty) return
|
||
const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||
const guardLink = (event: MouseEvent) => {
|
||
const link = (event.target as Element)?.closest('a[href]')
|
||
if (link && !window.confirm('有未保存的修改,放弃并离开?')) { event.preventDefault(); event.stopPropagation() }
|
||
}
|
||
window.addEventListener('beforeunload', guard)
|
||
document.addEventListener('click', guardLink, true)
|
||
return () => { window.removeEventListener('beforeunload', guard); document.removeEventListener('click', guardLink, true) }
|
||
}, [dirty])
|
||
const save = async () => {
|
||
const parsed = teamFunctionInputSchema.safeParse(draft && { ...draft, branches: draft.branches.map(branch => ({ ...branch, items: branch.items.map(item => item.trim()).filter(Boolean) })) })
|
||
if (!parsed.success) { setError('分组名称和条目不能为空,请检查内容长度。'); return }
|
||
setBusy(true); setError(''); setNotice('')
|
||
try {
|
||
const response = await fetch('/api/organization/team-functions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsed.data) })
|
||
const result = await response.json()
|
||
if (!response.ok) throw new Error(result.error || '保存失败,请重试。')
|
||
setPayload(current => current && { ...current, teams: current.teams.map(team => {
|
||
if (team.organizationUnitId !== parsed.data.organizationUnitId) return team
|
||
const lead = team.candidates.find(person => person.employeeProfileId === parsed.data.leadEmployeeProfileId)
|
||
return { ...team, ...parsed.data, revision: result.revision, lead: lead ? { employeeProfileId: lead.employeeProfileId, name: lead.name } : null, leadStatus: lead ? 'active' : 'unset' }
|
||
}) })
|
||
setDraft(null); setNotice('已保存')
|
||
} catch (failure) { setError(failure instanceof Error ? failure.message : '保存失败,请重试。') }
|
||
finally { setBusy(false) }
|
||
}
|
||
|
||
return (
|
||
<DepartmentShell>
|
||
<main className="mx-auto max-w-[1600px] px-4 py-6 sm:px-6 lg:px-8">
|
||
<h1 className="sr-only">团队职能</h1>
|
||
{error && <p role="alert" className="mb-4 text-sm text-[#b42318]">{error}{!draft && <button type="button" className={`${buttonClass} ml-2 text-[#1677ff]`} onClick={() => void load()}>重试</button>}</p>}
|
||
{notice && <p role="status" className="mb-3 text-sm text-[#087052]">{notice}</p>}
|
||
{loading ? <p aria-busy="true" className="text-sm text-[#667085]">正在加载团队职能…</p> : !selectedTeam ? <p className="text-sm text-[#667085]">{error ? '未能加载团队。' : '品牌中心暂无部门,请核对花名册同步。'}</p> :
|
||
<section aria-labelledby="team-functions-map-title" className="overflow-hidden rounded-xl border border-[#dfe5ed] bg-white lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
|
||
<aside aria-label="团队职能二级部门导航" className="border-b border-[#e4e9f0] bg-[#f8fafc] lg:border-b-0 lg:border-r">
|
||
<h2 id="team-functions-map-title" className="sr-only">品牌中心职责地图</h2>
|
||
<nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择部门">
|
||
{payload!.teams.map((team, index) => {
|
||
const teamColors = toneStyles[tones[index % tones.length]]
|
||
const selected = selectedId === team.organizationUnitId
|
||
return (
|
||
<button
|
||
key={team.organizationUnitId}
|
||
type="button"
|
||
aria-current={selected ? 'page' : undefined}
|
||
disabled={busy}
|
||
onClick={() => {
|
||
if (selected || (dirty && !window.confirm('有未保存的修改,放弃并切换部门?'))) return
|
||
setSelectedId(team.organizationUnitId); setDraft(null); setError(''); setNotice('')
|
||
}}
|
||
className={`flex min-h-[58px] shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full ${selected ? 'bg-[#1677ff] text-white' : 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'}`}
|
||
>
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: selected ? 'rgba(255,255,255,0.16)' : teamColors.accentSoft, color: selected ? '#fff' : teamColors.accent }}>
|
||
<UsersRound className="h-4 w-4" aria-hidden="true" />
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-bold">{team.name}</span>
|
||
<span className={`mt-0.5 block truncate text-xs ${selected ? 'text-white/85' : 'text-[#667085]'}`}>负责人:{leadLabel(team)}</span>
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
</aside>
|
||
|
||
<section aria-label={`${selectedTeam.name}部门介绍`} className="min-w-0">
|
||
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e4e9f0] px-5 py-4 sm:px-6">
|
||
<div className="flex min-w-0 items-start gap-3">
|
||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: colors.accentSoft, color: colors.accent }}>
|
||
<UsersRound className="h-5 w-5" aria-hidden="true" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<h2 className="text-xl font-bold text-[#101828]">{selectedTeam.name}</h2>
|
||
<p className="mt-1 text-sm font-semibold" style={{ color: colors.accentMuted }}>负责人:{leadLabel(selectedTeam)}</p>
|
||
</div>
|
||
</div>
|
||
{payload!.canEdit && !draft && <button type="button" className={`${buttonClass} text-[#0050cb] hover:bg-[#edf3ff]`} onClick={() => { setDraft(original && { ...original, branches: original.branches.map(branch => ({ ...branch, items: [...branch.items] })) }); setNotice(''); setError('') }}>编辑</button>}
|
||
</header>
|
||
{draft ? <form className="space-y-4 px-5 py-4 sm:px-6" onSubmit={event => { event.preventDefault(); void save() }}>
|
||
<label className="block text-sm font-semibold text-[#344054]">负责人
|
||
<select className={`${fieldClass} mt-2`} value={draft.leadEmployeeProfileId ?? ''} disabled={busy} onChange={event => setDraft({ ...draft, leadEmployeeProfileId: event.target.value || null })}>
|
||
<option value="">未设置</option>
|
||
{selectedTeam.candidates.map(person => <option key={person.employeeProfileId} value={person.employeeProfileId}>{person.name}{person.jobNumber ? ` · ${person.jobNumber}` : ''}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="block text-sm font-semibold text-[#344054]">团队说明
|
||
<textarea className={`${fieldClass} mt-2 resize-y font-normal`} rows={3} maxLength={2000} disabled={busy} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} />
|
||
</label>
|
||
{draft.branches.map((branch, index) => <fieldset key={index} className="grid gap-3 border-t border-[#e4e9f0] pt-4 sm:grid-cols-[160px_minmax(0,1fr)_auto]">
|
||
<legend className="sr-only">职责分组 {index + 1}</legend>
|
||
<label className="text-sm font-semibold text-[#344054]">分组名称 {index + 1}<input className={`${fieldClass} mt-2 font-normal`} maxLength={40} required disabled={busy} value={branch.label} onChange={event => setDraft({ ...draft, branches: draft.branches.map((row, i) => i === index ? { ...row, label: event.target.value } : row) })} /></label>
|
||
<label className="text-sm font-semibold text-[#344054]">条目 {index + 1}<textarea className={`${fieldClass} mt-2 resize-y font-normal`} placeholder="每行一项" rows={Math.min(6, Math.max(3, branch.items.length))} disabled={busy} value={branch.items.join('\n')} onChange={event => setDraft({ ...draft, branches: draft.branches.map((row, i) => i === index ? { ...row, items: event.target.value.split('\n') } : row) })} /></label>
|
||
<button type="button" aria-label={`移除分组 ${index + 1}`} disabled={busy} className={`${buttonClass} self-start text-[#b42318] sm:mt-7`} onClick={() => setDraft({ ...draft, branches: draft.branches.filter((_, i) => i !== index) })}>移除</button>
|
||
</fieldset>)}
|
||
<button type="button" disabled={busy || draft.branches.length >= 10} className={`${buttonClass} text-[#0050cb]`} onClick={() => setDraft({ ...draft, branches: [...draft.branches, { label: '', items: [''] }] })}>添加分组</button>
|
||
<div className="flex items-center gap-2 border-t border-[#e4e9f0] pt-4">
|
||
<button type="submit" disabled={busy || !dirty} className={`${buttonClass} bg-[#0050cb] text-white hover:bg-[#003da3]`}>{busy ? '保存中…' : '保存'}</button>
|
||
<button type="button" disabled={busy} className={`${buttonClass} text-[#475467] hover:bg-[#f2f4f7]`} onClick={() => { if (dirty && !window.confirm('放弃未保存的修改?')) return; setDraft(null); setNotice(''); void load() }}>取消</button>
|
||
{dirty && <span className="text-xs text-[#667085]">未保存</span>}
|
||
</div>
|
||
</form> : <div className="px-5 py-4 sm:px-6">
|
||
{selectedTeam.description && <p className="max-w-3xl text-base leading-7 text-[#4f6077]">{selectedTeam.description}</p>}
|
||
{!selectedTeam.description && !selectedTeam.branches.length && <p className="text-sm text-[#667085]">尚未配置团队职能。</p>}
|
||
<div className="relative mt-4 space-y-4 md:before:absolute md:before:bottom-5 md:before:left-[116px] md:before:top-5 md:before:w-px md:before:bg-[#e9edf3]">
|
||
{selectedTeam.branches.map((branch, index) => <Branch key={index} branch={branch} tone={tone} />)}
|
||
</div>
|
||
</div>}
|
||
</section>
|
||
</section>}
|
||
|
||
</main>
|
||
</DepartmentShell>
|
||
)
|
||
}
|