Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
import type { Metadata } from 'next'
|
|
import { Suspense } from 'react'
|
|
import Link from 'next/link'
|
|
import { notFound } from 'next/navigation'
|
|
import { db } from '@/lib/db'
|
|
import { requirePermission } from '@/lib/auth/current'
|
|
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
|
|
import {
|
|
listAssignments,
|
|
listStatusTags,
|
|
loadBacklinks,
|
|
loadVisiblePersonnelFile,
|
|
} from '@/lib/personnel/service'
|
|
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
|
|
import { canWritePersonnel } from '@/lib/access/documentWrite'
|
|
import { trackOpen } from '@/lib/recent/track'
|
|
import { listDepartments } from '@/lib/departments/service'
|
|
import { loadChatView } from '@/lib/chat/load'
|
|
import { ChatLazy } from '@/components/chat/ChatLazy'
|
|
import { de } from '@/lib/i18n/de'
|
|
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
|
import { SkeletonLine } from '@/components/Skeleton'
|
|
import { DocumentList } from '@/components/documents/DocumentList'
|
|
import { NewDocument } from '@/components/documents/NewDocument'
|
|
import { PersonnelHead } from './PersonnelHead'
|
|
import { Assignments } from './Assignments'
|
|
import { LinkCase } from './LinkCase'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}): Promise<Metadata> {
|
|
const user = await requirePermission('personnel.read')
|
|
const { id } = await params
|
|
const akte = await loadVisiblePersonnelFile(user.permissions, id)
|
|
return { title: akte?.displayName ?? de.personnel.one }
|
|
}
|
|
|
|
/**
|
|
* Rückverweise: die Fälle, in denen diese Person vorkommt.
|
|
*
|
|
* Streamt in einer eigenen Suspense-Grenze nach, weil die Abfrage über die
|
|
* Sichtbarkeitsprüfung der Fallakten läuft und damit die teuerste auf dieser
|
|
* Seite ist. Die Kopfdaten stehen währenddessen schon.
|
|
*/
|
|
async function Backlinks({
|
|
file,
|
|
permissions,
|
|
canLink,
|
|
}: {
|
|
file: { id: string; userId: string | null }
|
|
permissions: EffectivePermissions
|
|
canLink: boolean
|
|
}) {
|
|
const { total, byRole } = await loadBacklinks(permissions, file)
|
|
|
|
if (total === 0) {
|
|
return (
|
|
<div>
|
|
<p className="text-text-dim">{de.personnel.backlinksEmpty}</p>
|
|
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{byRole.map(({ role, eintraege }) => (
|
|
<div key={role}>
|
|
<h3 className="font-condensed text-xs text-text-dim">
|
|
{de.participant.roles[role] ?? role}
|
|
</h3>
|
|
<ul className="mt-2 border-t border-line">
|
|
{eintraege.map(({ case: akte }) => (
|
|
<li key={`${role}:${akte.id}`} className="border-b border-line">
|
|
<Link
|
|
href={`/faelle/${akte.caseNumber}`}
|
|
prefetch
|
|
className="record-row py-2 pl-3 text-text no-underline"
|
|
style={classificationEdgeStyle(akte.classification)}
|
|
>
|
|
<span className="record-key">{akte.caseNumber}</span>
|
|
<span className="record-title">{akte.title}</span>
|
|
<span className="font-condensed text-2xs text-text-dim">
|
|
{de.case.statusNames[akte.status]}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function Documents({
|
|
file,
|
|
permissions,
|
|
canEdit,
|
|
}: {
|
|
file: { id: string; classification: number }
|
|
permissions: EffectivePermissions
|
|
canEdit: boolean
|
|
}) {
|
|
const [documents, templates, levels] = await Promise.all([
|
|
listDocuments(permissions, { personnelFileId: file.id }),
|
|
canEdit ? listTemplatesFor('PERSON_DOCUMENT') : Promise.resolve([]),
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
])
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<DocumentList
|
|
documents={documents}
|
|
levelName={new Map(levels.map((l) => [l.level, l.name]))}
|
|
/>
|
|
{canEdit ? (
|
|
<NewDocument
|
|
templates={templates}
|
|
levels={levels.filter((l) => l.level <= permissions.clearanceLevel)}
|
|
defaultLevel={Math.min(file.classification, permissions.clearanceLevel)}
|
|
personnelFileId={file.id}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function AssignmentBlock({
|
|
fileId,
|
|
permissions,
|
|
canEdit,
|
|
}: {
|
|
fileId: string
|
|
permissions: EffectivePermissions
|
|
canEdit: boolean
|
|
}) {
|
|
const [assignments, members] = await Promise.all([
|
|
listAssignments(permissions, fileId),
|
|
canEdit
|
|
? db.user.findMany({
|
|
where: { isActive: true },
|
|
orderBy: { displayName: 'asc' },
|
|
take: 200,
|
|
select: { id: true, displayName: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
])
|
|
|
|
return (
|
|
<Assignments
|
|
personnelFileId={fileId}
|
|
assignments={assignments}
|
|
members={members}
|
|
canEdit={canEdit}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Der Chat einer Personalakte.
|
|
*
|
|
* Denselben formlosen Teil hat die Fallakte auch. Er steht bewusst **in** der
|
|
* Akte und nicht in einem freien Raum daneben: über eine Person zu reden, ohne
|
|
* dass das Gespräch bei ihrer Akte steht, war genau das, was vorher auf
|
|
* Discord passiert ist. Siehe docs/13-entscheidungen.md.
|
|
*
|
|
* Übernehmen als Vermerk gibt es hier nicht - dafür braucht es einen Verlauf,
|
|
* und den führt nur die Fallakte.
|
|
*/
|
|
async function ChatBlock({
|
|
file,
|
|
user,
|
|
}: {
|
|
file: { id: string }
|
|
user: { id: string; permissions: EffectivePermissions }
|
|
}) {
|
|
const ansicht = await loadChatView(user.permissions, { kind: 'personnel', fileId: file.id })
|
|
|
|
return (
|
|
<ChatLazy
|
|
scope={{ kind: 'personnel', fileId: file.id }}
|
|
topic={`personnel:${file.id}`}
|
|
initial={ansicht.messages}
|
|
initialReactions={ansicht.reactions}
|
|
currentUserId={user.id}
|
|
lastReadAt={ansicht.lastReadAt}
|
|
canAdopt={false}
|
|
mentionNames={ansicht.mentionNames}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default async function PersonnelPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const user = await requirePermission('personnel.read')
|
|
const { id } = await params
|
|
|
|
const akte = await loadVisiblePersonnelFile(user.permissions, id)
|
|
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
|
|
if (!akte) notFound()
|
|
|
|
// Harte Regel 3: jeder Lesezugriff ab Stufe 3 wird protokolliert.
|
|
if (akte.classification >= READ_AUDIT_FROM_LEVEL) {
|
|
recordRead({
|
|
actorId: user.id,
|
|
action: 'personnel.read',
|
|
targetType: 'PersonnelFile',
|
|
targetId: akte.id,
|
|
clearanceAtTime: user.permissions.clearanceLevel,
|
|
meta: { displayName: akte.displayName, classification: akte.classification },
|
|
})
|
|
}
|
|
|
|
await trackOpen(user.id, 'PERSONNEL', akte.id)
|
|
|
|
const [levels, statusTags, abteilungen] = await Promise.all([
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
listStatusTags(),
|
|
listDepartments(user.permissions),
|
|
])
|
|
const level = levels.find((l) => l.level === akte.classification)
|
|
const canEdit = canWritePersonnel(user.permissions, akte.classification).ok
|
|
|
|
return (
|
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
|
<header className="pl-3" style={classificationEdgeStyle(akte.classification)}>
|
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
|
<span className="font-condensed text-xs text-text-dim">{de.personnel.one}</span>
|
|
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
|
|
</div>
|
|
<h1 className="mt-2 font-condensed">{akte.displayName}</h1>
|
|
</header>
|
|
|
|
<section className="mt-8">
|
|
<h2 className="font-condensed">{de.personnel.headData}</h2>
|
|
<div className="mt-4">
|
|
<PersonnelHead
|
|
file={akte}
|
|
canEdit={canEdit}
|
|
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
|
|
statusTags={statusTags}
|
|
departments={abteilungen}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.assignment.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="50%" />}>
|
|
<AssignmentBlock
|
|
fileId={akte.id}
|
|
permissions={user.permissions}
|
|
canEdit={canEdit}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.document.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="60%" />}>
|
|
<Documents file={akte} permissions={user.permissions} canEdit={canEdit} />
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.personnel.backlinks}</h2>
|
|
<div className="mt-4">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-3">
|
|
<SkeletonLine width="8rem" />
|
|
<SkeletonLine width="70%" />
|
|
<SkeletonLine width="55%" />
|
|
</div>
|
|
}
|
|
>
|
|
<Backlinks file={akte} permissions={user.permissions} canLink={canEdit} />
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.chat.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="60%" />}>
|
|
<ChatBlock file={akte} user={user} />
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|