Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
9.5 KiB
TypeScript
300 lines
9.5 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 { 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="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
|
|
style={classificationEdgeStyle(akte.classification)}
|
|
>
|
|
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
|
|
<span className="grow">{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] = await Promise.all([
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
listStatusTags(),
|
|
])
|
|
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}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|