gongxiangp2/components/department-dashboard/TeamFunctionsDashboard.tsx

221 lines
14 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 { 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>
)
}