gongxiangp2/components/department-dashboard/UserPermissionsDashboard.tsx

195 lines
11 KiB
TypeScript

'use client'
import Select from '@/components/ui/Select'
import { Fragment, useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { ChevronRight, Search, X } from 'lucide-react'
import { useSession } from 'next-auth/react'
import DepartmentShell from './DepartmentShell'
type GroupRef = { slug: string; name: string }
type DepartmentGroup = GroupRef & {
attachedPath: string
source: 'own' | 'inherit' | string
}
type Person = {
userId: string
username: string
displayName: string
jobNumber: string | null
departmentPath: string | null
departmentName: string | null
departmentGroups: DepartmentGroup[]
personalGroups: GroupRef[]
}
export default function UserPermissionsDashboard({ embedded = false }: { embedded?: boolean }) {
const { data: session, status } = useSession()
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
const [keyword, setKeyword] = useState('')
const [people, setPeople] = useState<Person[]>([])
const [groups, setGroups] = useState<GroupRef[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const search = useCallback(async (needle: string) => {
if (!needle.trim()) {
setPeople([])
return
}
setLoading(true)
setError(null)
try {
const response = await fetch(
`/api/organization/user-permissions?q=${encodeURIComponent(needle.trim())}`,
{ cache: 'no-store' },
)
const body = await response.json().catch(() => null)
if (!response.ok) throw new Error(body?.error || '搜索失败')
setPeople(body.people || [])
setGroups(body.groups || [])
} catch (searchError) {
setError(searchError instanceof Error ? searchError.message : '搜索失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (status !== 'authenticated' || !isSuperAdmin) return
const handle = window.setTimeout(() => {
void search(keyword)
}, 250)
return () => window.clearTimeout(handle)
}, [keyword, status, isSuperAdmin, search])
const mutate = async (body: Record<string, unknown>, success: string) => {
setSaving(true)
setError(null)
setNotice(null)
try {
const response = await fetch('/api/organization/user-permissions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.error || '保存失败')
setNotice(success)
await search(keyword)
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : '保存失败')
} finally {
setSaving(false)
}
}
const Frame = embedded ? Fragment : DepartmentShell
const Content = embedded ? 'div' : 'main'
return (
<Frame>
<Content className={embedded ? 'flex min-h-[320px] flex-col lg:h-full lg:min-h-0' : 'flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8'}>
<header className="shrink-0 pb-4">
{!embedded && <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-[#101828]">按人看权限</h1>
<Link href="/workspaces/governance/permissions" className="inline-flex min-h-11 items-center gap-1 rounded-lg px-2 text-sm font-medium text-[#4f6077] hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
权限组<ChevronRight className="h-4 w-4" />
</Link>
</div>}
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
<div className={`relative max-w-xl ${embedded ? '' : 'mt-3'}`}>
<Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" />
<input id="user-permission-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜花名 / 工号 / 账号" className="h-11 w-full rounded-lg border border-[#cfd8e3] bg-white pl-9 pr-3 text-sm text-[#101828] placeholder:text-[#66758b] focus:outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20" />
</div>
</header>
{status === 'loading' ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">加载中…</div>
) : !isSuperAdmin ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
只有超级管理员可以改人员权限。
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto pb-6">
{error && (
<div role="alert" className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
)}
{notice && (
<div role="status" className="mb-3 text-sm text-[#087052]">{notice}</div>
)}
{loading && <p className="mb-3 text-sm text-[#66758b]">搜索中…</p>}
{!keyword.trim() ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
搜索人员查看权限组
</div>
) : people.length === 0 && !loading ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
未找到已绑定人员
</div>
) : (
<div className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
<div aria-hidden="true" className="hidden grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] gap-4 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-3 text-xs font-semibold text-[#4f6077] xl:grid">
<span>人员</span><span>部门组</span><span>个人组</span><span>加组</span>
</div>
<ul aria-label="人员权限" className="divide-y divide-[#e2e8f0]">
{people.map((person) => {
const personalSlugs = new Set(person.personalGroups.map((row) => row.slug))
const extraGroups = groups.filter((group) => !personalSlugs.has(group.slug))
return (
<li key={person.userId} className="grid min-w-0 grid-cols-[minmax(0,1fr)_120px] gap-x-4 gap-y-2 px-4 py-3 sm:grid-cols-2 xl:grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] xl:items-center">
<div className="min-w-0">
<h2 className="break-words text-sm font-semibold text-[#101828]">{person.displayName}</h2>
<p title={person.departmentPath || undefined} className="mt-1 break-words text-xs text-[#66758b]">
@{person.username}{person.jobNumber ? ` · ${person.jobNumber}` : ''}{person.departmentName ? ` · ${person.departmentName}` : ''}
</p>
{person.departmentPath && <span className="sr-only">{person.departmentPath}</span>}
</div>
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
<span className="shrink-0 pt-1 text-xs text-[#66758b] xl:sr-only">部门组</span>
{person.departmentGroups.length === 0 ? <p className="text-sm leading-6 text-[#66758b]">未关联权限组</p> :
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{person.departmentGroups.map((group) => (
<li key={`${person.userId}-${group.slug}`} className="break-words text-sm leading-6 text-[#101828]">
{group.name}<span title={group.attachedPath} className="ml-2 text-xs text-[#66758b]">{group.source === 'own' ? '本部门' : `随 ${group.attachedPath.split('/').pop()}`}</span>
</li>
))}</ul>}
</div>
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
<span className="shrink-0 pt-3.5 text-xs text-[#66758b] xl:sr-only">个人组</span>
{person.personalGroups.length === 0 ? <p className="py-3 text-sm text-[#66758b]">—<span className="sr-only">未单独加组</span></p> :
<ul className="flex min-w-0 flex-wrap gap-2">{person.personalGroups.map((group) => (
<li key={group.slug} className="inline-flex min-w-0 items-center rounded-lg bg-[#f8fafc] pl-3 text-sm text-[#101828]">
<span className="min-w-0 break-words">{group.name}</span>
<button type="button" disabled={saving} aria-label={`将${person.displayName}移出${group.name}组`} title={`移出${group.name}组`} onClick={() => void mutate({ action: 'remove-member', userId: person.userId, slug: group.slug }, `已将 ${person.displayName} 移出「${group.name}」`)} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] hover:bg-[#fff5f4] hover:text-[#b42318] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"><X className="h-4 w-4" /></button>
</li>
))}</ul>}
</div>
<div className="col-start-2 row-start-1 min-w-0 sm:col-start-auto sm:row-start-auto">
{extraGroups.length > 0 && <Select aria-label={`为${person.displayName}加组`} disabled={saving} defaultValue="" onChange={(event) => {
const slug = event.target.value
if (!slug) return
const group = extraGroups.find((item) => item.slug === slug)
void mutate({ action: 'add-member', userId: person.userId, slug }, `已将 ${person.displayName} 加入「${group?.name || slug}」`)
event.target.value = ''
}} className="min-h-11 w-full max-w-[160px] rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm text-[#4f6077] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
<option value="">加组…</option>{extraGroups.map((group) => <option key={group.slug} value={group.slug}>{group.name}</option>)}
</Select>}
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
)}
</Content>
</Frame>
)
}