Personalakten und Abteilungen als vollstaendiges Dossier speichern
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
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
Bisher liess sich eine einzelne Fallakte und ein einzelnes Dokument als PDF
sichern. Wer den Stand zu einer Person brauchte, rief beides einzeln ab,
legte es von Hand zusammen und wusste danach nicht, ob er alles hatte.
Ein Dossier ist die Personalakte, dahinter alle ihre Dokumente, dahinter
alle Fallakten, in denen die Person vorkommt - jeweils in derselben
Druckdarstellung wie einzeln. Fuer eine Abteilung dasselbe, je Mitglied
getrennt: eine Abteilung hat keine eigenen Fallakten, sie hat Mitglieder,
die in Fallakten vorkommen. Alles in einen Topf geschuettet staende dieselbe
Akte mehrfach da, ohne dass man saehe, ueber wen sie dort steht.
Der Abrufer waehlt die hoechste Stufe, die aufgenommen wird. Das ist keine
Zugriffspruefung, sondern eine Entscheidung ueber den Umfang - und beides
darf nie durcheinandergeraten:
- Was ueber der Freigabestufe des Abrufers liegt, existiert fuer ihn
nicht. Es steht nicht im Abzug, wird nicht gezaehlt und nicht erwaehnt.
- Was er selbst ausgeschlossen hat, steht als Vermerk im PDF, nach Stufe
aufgeschluesselt. Eine gekuerzte Mappe darf nicht wie eine
vollstaendige aussehen, und der Deckel steht zusaetzlich auf jeder
Seite im Wasserzeichen.
Beide Grenzen laufen in der Datenbankabfrage, nicht danach in JavaScript.
Alles wird als eine Druckseite gerendert statt als mehrere
zusammengeheftete PDFs. So laufen Seitenzahlen, Kopfzeile und Wasserzeichen
durch, und es gibt keinen zweiten Weg, der irgendwann anders aussieht als
der erste. Dafuer sind die vorhandenen Druckansichten zu Bausteinen
geworden - CaseSheet und DocumentSheet stehen jetzt in components/print und
werden von den alten Seiten wie vom Dossier benutzt.
Der Zwischenspeicherschluessel traegt den Deckel, sonst bekaeme ein Abruf
bis Stufe 1 die Datei eines Abrufs bis Stufe 4. Und der Stand ist der
spaeteste ueber alle enthaltenen Akten, nicht der der Kopfdaten allein -
sonst gaebe der Zwischenspeicher einen Abzug heraus, in dem ein
inzwischen geaendertes Dokument im alten Wortlaut steht.
Jeder Abruf steht mit seinem Deckel im Protokoll. Ohne den sagt der
Eintrag, dass jemand etwas ausser Haus gegeben hat, aber nicht wie viel.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- Dossier: Personalakte und Abteilung als vollständiger Abzug.
|
||||
--
|
||||
-- Bisher liess sich eine einzelne Fallakte und ein einzelnes Dokument als PDF
|
||||
-- speichern. Wer den Stand zu einer Person brauchte, musste beides einzeln
|
||||
-- abrufen und von Hand zusammenlegen - und wusste danach nicht, ob er alles
|
||||
-- hatte.
|
||||
--
|
||||
-- Der Abzug ist gedeckelt: der Abrufer wählt die höchste Stufe, die
|
||||
-- aufgenommen wird. Das ist keine Zugriffsprüfung, sondern eine Entscheidung
|
||||
-- über den Umfang - die Prüfung bleibt die Freigabestufe des Abrufers, und
|
||||
-- `maxLevel` liegt nie darüber. Was dadurch wegfällt, steht als Vermerk im
|
||||
-- PDF: eine gekürzte Mappe darf nicht wie eine vollständige aussehen.
|
||||
--
|
||||
-- `maxLevel` gehört in den Zwischenspeicherschlüssel, sonst bekäme ein Abruf
|
||||
-- bis Stufe 1 die Datei eines Abrufs bis Stufe 4.
|
||||
|
||||
ALTER TYPE "ExportKind" ADD VALUE IF NOT EXISTS 'PERSONNEL';
|
||||
ALTER TYPE "ExportKind" ADD VALUE IF NOT EXISTS 'DEPARTMENT';
|
||||
|
||||
ALTER TABLE "ExportJob" ADD COLUMN "maxLevel" INTEGER;
|
||||
|
||||
-- Nie mehr aufnehmen, als der Abrufer selbst sehen darf. Die Anwendung deckelt
|
||||
-- ohnehin an drei Stellen; hier steht es als Bedingung, die nicht umgangen
|
||||
-- werden kann.
|
||||
ALTER TABLE "ExportJob"
|
||||
ADD CONSTRAINT "ExportJob_maxLevel_nicht_ueber_stufe"
|
||||
CHECK ("maxLevel" IS NULL OR "maxLevel" <= "clearanceAtTime");
|
||||
@@ -1006,6 +1006,10 @@ model ExportJob {
|
||||
/// Die Stufe des Abrufers zum Zeitpunkt der Anfrage. Sie steckt im
|
||||
/// Schluessel und im Wasserzeichen.
|
||||
clearanceAtTime Int
|
||||
/// Hoechste Stufe, die in den Abzug aufgenommen wird. Beim Dossier waehlbar,
|
||||
/// nie hoeher als clearanceAtTime. Steht im Schluessel, damit zwei Abzuege
|
||||
/// mit verschiedenen Deckeln nicht dieselbe Datei bekommen.
|
||||
maxLevel Int?
|
||||
status ExportStatus @default(QUEUED)
|
||||
cacheKey String
|
||||
/// Pfad unterhalb von FILES_DIR, sobald fertig.
|
||||
@@ -1033,6 +1037,11 @@ enum ExportKind {
|
||||
CASE
|
||||
/// ein einzelnes Dokument, ohne Verlauf
|
||||
DOCUMENT
|
||||
/// eine Personalakte samt ihren Dokumenten und den Fallakten, in denen die
|
||||
/// Person vorkommt
|
||||
PERSONNEL
|
||||
/// eine Abteilung, je Mitglied ein vollstaendiges Dossier
|
||||
DEPARTMENT
|
||||
/// mehrere Akten als ZIP, nur mit Recht 'admin'
|
||||
BUNDLE
|
||||
/// vollstaendiger JSON-Abzug, nur mit Recht 'admin'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="max-w-(--container-measure) animate-pulse">
|
||||
<div className="h-5 w-48 rounded bg-line" />
|
||||
<div className="mt-4 h-3 w-full rounded bg-line" />
|
||||
<div className="mt-2 h-3 w-2/3 rounded bg-line" />
|
||||
<div className="mt-6 border border-line p-4">
|
||||
<div className="h-3 w-32 rounded bg-line" />
|
||||
<div className="mt-3 h-9 w-56 rounded-(--radius-field) bg-line" />
|
||||
<div className="mt-4 h-8 w-40 rounded-(--radius-control) bg-line" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { loadVisibleDepartment } from '@/lib/departments/service'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { ExportPanel } from '@/components/export/ExportPanel'
|
||||
|
||||
export const metadata: Metadata = { title: de.export.dossierHeading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Abteilungsabzug als PDF: je Mitglied ein vollständiges Dossier.
|
||||
*
|
||||
* Der Hinweis auf den Umfang steht bewusst da. Wer bei fünfzig Mitgliedern
|
||||
* ohne Deckel abruft, bekommt ein Bündel, das niemand mehr liest.
|
||||
*/
|
||||
export default async function DepartmentExportPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ key: string }>
|
||||
}) {
|
||||
const user = await requireUser()
|
||||
const { key } = await params
|
||||
|
||||
const abteilung = await loadVisibleDepartment(user.permissions, key)
|
||||
if (!abteilung) notFound()
|
||||
|
||||
const stufen = await db.clearanceLevelConfig.findMany({
|
||||
where: user.permissions.isAdmin ? {} : { level: { lte: user.permissions.clearanceLevel } },
|
||||
orderBy: { level: 'asc' },
|
||||
select: { level: true, name: true },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-(--container-measure)">
|
||||
<h2 className="font-condensed">{de.export.dossierHeading}</h2>
|
||||
<p className="mt-3 text-sm text-text-dim">{de.export.departmentDossierIntro}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ExportPanel
|
||||
target={{ kind: 'DEPARTMENT', departmentKey: abteilung.key }}
|
||||
topic={`user:${user.id}`}
|
||||
levels={stufen}
|
||||
defaultLevel={user.permissions.clearanceLevel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-8">
|
||||
<Link href={`/abteilungen/${abteilung.key}/druck`} className="btn btn-sm">
|
||||
{de.export.print}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +68,12 @@ export default async function DepartmentPage({ params }: { params: Promise<{ key
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mt-2 font-condensed">{abteilung.name}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<h1 className="font-condensed">{abteilung.name}</h1>
|
||||
<Link href={`/abteilungen/${abteilung.key}/export`} className="btn btn-sm">
|
||||
{de.export.dossier}
|
||||
</Link>
|
||||
</div>
|
||||
{abteilung.description ? (
|
||||
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
|
||||
{abteilung.description}
|
||||
|
||||
@@ -23,6 +23,18 @@ function toMessage(error: unknown): string {
|
||||
const requestSchema = z.union([
|
||||
z.object({ kind: z.literal('CASE'), caseNumber: z.string().min(1) }),
|
||||
z.object({ kind: z.literal('DOCUMENT'), documentNumber: z.string().min(1) }),
|
||||
z.object({
|
||||
kind: z.literal('PERSONNEL'),
|
||||
personnelFileId: z.string().min(1),
|
||||
// Gedeckelt wird im Dienst auf die Stufe des Abrufers. Hier steht nur die
|
||||
// Form: eine Zahl im gültigen Bereich, nicht irgendein Text.
|
||||
maxLevel: z.coerce.number().int().min(0).max(5),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('DEPARTMENT'),
|
||||
departmentKey: z.string().min(1),
|
||||
maxLevel: z.coerce.number().int().min(0).max(5),
|
||||
}),
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="max-w-(--container-measure) animate-pulse">
|
||||
<div className="h-5 w-48 rounded bg-line" />
|
||||
<div className="mt-4 h-3 w-full rounded bg-line" />
|
||||
<div className="mt-2 h-3 w-2/3 rounded bg-line" />
|
||||
<div className="mt-6 border border-line p-4">
|
||||
<div className="h-3 w-32 rounded bg-line" />
|
||||
<div className="mt-3 h-9 w-56 rounded-(--radius-field) bg-line" />
|
||||
<div className="mt-4 h-8 w-40 rounded-(--radius-control) bg-line" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { db } from '@/lib/db'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { loadVisiblePersonnelFile } from '@/lib/personnel/service'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { ExportPanel } from '@/components/export/ExportPanel'
|
||||
|
||||
export const metadata: Metadata = { title: de.export.dossierHeading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Dossier einer Person als PDF.
|
||||
*
|
||||
* Eigene Seite wie beim Fallexport: die Personalakte ist voll, und die Wahl
|
||||
* der Höchststufe braucht Platz und eine Erklärung. Siehe
|
||||
* docs/13-entscheidungen.md.
|
||||
*/
|
||||
export default async function PersonnelExportPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const user = await requirePermission('personnel.read')
|
||||
const { id } = await params
|
||||
|
||||
const akte = await loadVisiblePersonnelFile(user.permissions, id)
|
||||
if (!akte) notFound()
|
||||
|
||||
// Nur Stufen anbieten, die der Abrufer selbst hat. Eine Auswahl, die er
|
||||
// ohnehin nicht bekommt, wäre eine Falle - und der Dienst deckelt sie später
|
||||
// stillschweigend weg.
|
||||
const stufen = await db.clearanceLevelConfig.findMany({
|
||||
where: user.permissions.isAdmin ? {} : { level: { lte: user.permissions.clearanceLevel } },
|
||||
orderBy: { level: 'asc' },
|
||||
select: { level: true, name: true },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-(--container-measure)">
|
||||
<h2 className="font-condensed">{de.export.dossierHeading}</h2>
|
||||
<p className="mt-3 text-sm text-text-dim">{de.export.dossierIntro}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ExportPanel
|
||||
target={{ kind: 'PERSONNEL', personnelFileId: akte.id }}
|
||||
topic={`user:${user.id}`}
|
||||
levels={stufen}
|
||||
defaultLevel={user.permissions.clearanceLevel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-8">
|
||||
<Link href={`/personalakten/${akte.id}/druck`} className="btn btn-sm">
|
||||
{de.export.print}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -234,7 +234,14 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
|
||||
<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>
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<h1 className="font-condensed">{akte.displayName}</h1>
|
||||
{/* Der Abzug ist kein Nebenweg der Akte, sondern eine eigene Seite:
|
||||
die Wahl der Höchststufe will erklärt werden. */}
|
||||
<Link href={`/personalakten/${akte.id}/export`} className="btn btn-sm">
|
||||
{de.export.dossier}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mt-8">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { visibleDepartmentEntryWhere } from '@/lib/access/departmentFilter'
|
||||
import { permissionsForPrint } from '@/lib/export/printAccess'
|
||||
import {
|
||||
departmentMembers,
|
||||
effectiveCap,
|
||||
loadVisibleDepartmentForPrint,
|
||||
} from '@/lib/export/dossier'
|
||||
import { DossierBody } from '@/components/print/DossierBody'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { NotAvailable } from '../../../NotAvailable'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: de.export.dossier,
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
/**
|
||||
* Druckansicht einer Abteilung: die Abteilung selbst, dahinter je Mitglied ein
|
||||
* vollständiges Dossier.
|
||||
*
|
||||
* Getrennt nach Mitgliedern und nicht als ein grosser Topf: eine Abteilung hat
|
||||
* keine eigenen Fallakten, sie hat Mitglieder, die in Fallakten vorkommen.
|
||||
* Alles ineinandergeschüttet stünde dieselbe Akte mehrfach da, ohne dass man
|
||||
* sähe, über wen sie hier steht.
|
||||
*
|
||||
* Das wird umfangreich. Genau deshalb ist die Höchststufe hier keine Zierde.
|
||||
*/
|
||||
export default async function PrintDepartmentDossierPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ key: string }>
|
||||
searchParams: Promise<{ token?: string; stufe?: string }>
|
||||
}) {
|
||||
const { key } = await params
|
||||
const { token, stufe } = await searchParams
|
||||
|
||||
const zugang = await permissionsForPrint(token)
|
||||
if (!zugang) return <NotAvailable />
|
||||
const { permissions, displayName } = zugang
|
||||
|
||||
const abteilung = await loadVisibleDepartmentForPrint(permissions, key)
|
||||
if (!abteilung) return <NotAvailable />
|
||||
|
||||
const cap = effectiveCap(permissions, Number(stufe ?? permissions.clearanceLevel))
|
||||
|
||||
const [{ members, omittedMembers }, vermerke, levels, autoren] = await Promise.all([
|
||||
departmentMembers(permissions, abteilung.id, cap),
|
||||
db.departmentEntry.findMany({
|
||||
where: {
|
||||
AND: [visibleDepartmentEntryWhere(permissions), { departmentId: abteilung.id }],
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
body: true,
|
||||
minClearance: true,
|
||||
authorId: true,
|
||||
createdAt: true,
|
||||
version: true,
|
||||
editedAt: true,
|
||||
retractedAt: true,
|
||||
retractionReason: true,
|
||||
},
|
||||
}),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
db.user.findMany({ select: { id: true, displayName: true } }),
|
||||
])
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
|
||||
|
||||
return (
|
||||
<main className="print-sheet">
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">
|
||||
{abteilung.minClearance} ·{' '}
|
||||
{levelName.get(abteilung.minClearance) ?? de.common.unknown}
|
||||
</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>{abteilung.name}</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>{abteilung.key}</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{de.department.members}</th>
|
||||
<td>{de.department.memberCount(members.length)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.retrievedBy}</th>
|
||||
<td>{displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.asOf}</th>
|
||||
<td>
|
||||
{new Date().toLocaleString('de-DE')} ·{' '}
|
||||
{de.export.cappedAt(cap, levelName.get(cap) ?? '')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{abteilung.description ? (
|
||||
<p style={{ marginTop: '4mm', whiteSpace: 'pre-wrap' }}>{abteilung.description}</p>
|
||||
) : null}
|
||||
|
||||
<p className="print-note" style={{ marginTop: '4mm' }}>
|
||||
{de.export.coverNote}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{omittedMembers > 0 ? (
|
||||
<section className="print-omitted" style={{ breakInside: 'avoid' }}>
|
||||
<p className="print-label">{de.export.omittedHeading}</p>
|
||||
<p className="print-note" style={{ margin: '1mm 0 0' }}>
|
||||
{de.export.omittedMembers(omittedMembers)}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<h2>{de.department.entries}</h2>
|
||||
{vermerke.length === 0 ? (
|
||||
<p className="print-note">{de.department.entriesEmpty}</p>
|
||||
) : (
|
||||
vermerke.map((vermerk) => (
|
||||
<div key={vermerk.id} className="print-entry">
|
||||
<p className="print-label">
|
||||
{nameVon.get(vermerk.authorId) ?? vermerk.authorId} ·{' '}
|
||||
{vermerk.createdAt.toLocaleString('de-DE')}
|
||||
{vermerk.editedAt
|
||||
? ` · ${de.export.editedAt(vermerk.version, vermerk.editedAt.toLocaleDateString('de-DE'))}`
|
||||
: ''}
|
||||
</p>
|
||||
{vermerk.retractedAt ? (
|
||||
<p style={{ margin: '1mm 0', fontStyle: 'italic' }}>
|
||||
{de.entry.retracted(
|
||||
vermerk.retractedAt.toLocaleDateString('de-DE'),
|
||||
nameVon.get(vermerk.authorId) ?? vermerk.authorId,
|
||||
vermerk.retractionReason ?? '',
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>{vermerk.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<h2 style={{ breakBefore: 'page' }}>{de.export.membersHeading}</h2>
|
||||
{members.length === 0 ? (
|
||||
<p className="print-note">{de.export.noMembers}</p>
|
||||
) : (
|
||||
members.map((mitglied) => (
|
||||
<section key={mitglied.id} style={{ breakBefore: 'page' }}>
|
||||
<DossierBody
|
||||
permissions={permissions}
|
||||
displayName={displayName}
|
||||
akte={mitglied}
|
||||
cap={cap}
|
||||
withCover={false}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { loadDocumentSections } from '@/lib/documents/service'
|
||||
import { loadVisibleDocument } from '@/lib/documents/service'
|
||||
import { resolvePhotoUrls } from '@/lib/files/service'
|
||||
import { permissionsForPrint } from '@/lib/export/printAccess'
|
||||
import { Sheet } from '@/components/documents/Sheet'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { DocumentSheet } from '@/components/print/DocumentSheet'
|
||||
import { NotAvailable } from '../../../NotAvailable'
|
||||
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -21,11 +16,8 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
/**
|
||||
* Druckansicht eines einzelnen Dokuments.
|
||||
*
|
||||
* Kein Verlauf, keine Kopfdatentabelle einer Akte - das Dokument selbst, so
|
||||
* wie seine Vorlage es vorsieht. Ein Aktenblatt kommt als Aktenblatt heraus,
|
||||
* ein Vertrag als Vertrag. Siehe docs/08-suche-und-export.md.
|
||||
* Druckansicht eines einzelnen Dokuments. Der Bogen selbst steht in
|
||||
* `components/print/DocumentSheet`, weil ihn auch das Dossier braucht.
|
||||
*/
|
||||
export default async function PrintDocumentPage({
|
||||
params,
|
||||
@@ -39,142 +31,17 @@ export default async function PrintDocumentPage({
|
||||
|
||||
const zugang = await permissionsForPrint(token)
|
||||
if (!zugang) return <NotAvailable />
|
||||
const { permissions, displayName } = zugang
|
||||
|
||||
const doc = await loadVisibleDocument(permissions, documentNumber)
|
||||
const doc = await loadVisibleDocument(zugang.permissions, documentNumber)
|
||||
if (!doc) return <NotAvailable />
|
||||
|
||||
const [sections, template, foundationLevels, levels] = await Promise.all([
|
||||
loadDocumentSections(permissions, doc),
|
||||
db.documentTemplate.findUnique({
|
||||
where: { id: doc.templateId },
|
||||
select: { sheetTitle: true, sheetSubtitle: true, name: true },
|
||||
}),
|
||||
db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
])
|
||||
|
||||
const photoUrls = await resolvePhotoUrls(
|
||||
permissions,
|
||||
sections.map((section) => ({
|
||||
fieldKey: section.fieldKey,
|
||||
visible: section.visible,
|
||||
value: section.visible ? section.value : null,
|
||||
type: section.field.type,
|
||||
})),
|
||||
)
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const werte = Object.fromEntries(
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
// Das Aktenblatt bringt sein eigenes Aussehen mit und braucht keinen
|
||||
// Berichtsrahmen darum.
|
||||
if (doc.layout === 'DOSSIER') {
|
||||
return (
|
||||
<Sheet
|
||||
sections={sections}
|
||||
title={template?.sheetTitle ?? de.app.name}
|
||||
subtitle={template?.sheetSubtitle ?? template?.name ?? doc.title}
|
||||
documentNumber={doc.documentNumber}
|
||||
issuedAt={doc.createdAt}
|
||||
foundationLevels={foundationLevels}
|
||||
footerNote={de.document.footerNote}
|
||||
photoUrls={photoUrls}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="print-sheet">
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">
|
||||
{doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>{doc.title}</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>
|
||||
{doc.documentNumber}
|
||||
</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{de.case.status}</th>
|
||||
<td>{de.document.statusNames[doc.status] ?? doc.status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.document.kind}</th>
|
||||
<td>{doc.templateKey}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.retrievedBy}</th>
|
||||
<td>{displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.asOf}</th>
|
||||
<td>
|
||||
{new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '}
|
||||
{permissions.clearanceLevel}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{doc.retractedAt ? (
|
||||
<p className="print-stamp" style={{ marginBottom: '4mm' }}>
|
||||
{de.document.statusNames.RETRACTED}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
|
||||
if (!isInputField(field)) {
|
||||
if (field.type === 'pagebreak') {
|
||||
return <div key={field.key} style={{ breakAfter: 'page' }} />
|
||||
}
|
||||
const text = fillPlaceholders(field.text ?? '', werte)
|
||||
return field.type === 'heading' ? (
|
||||
<h3 key={field.key}>{text || field.label}</h3>
|
||||
) : (
|
||||
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'signature') {
|
||||
return (
|
||||
<div key={field.key} style={{ marginTop: '10mm', breakInside: 'avoid' }}>
|
||||
<div style={{ borderTop: '1pt solid #000', width: '70mm' }} />
|
||||
<p className="print-note">{fillPlaceholders(field.label, werte)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
|
||||
<p className="print-label">{field.label}</p>
|
||||
{!section.visible ? (
|
||||
<p style={{ margin: '1mm 0' }}>
|
||||
<span className="print-redacted"> </span>{' '}
|
||||
<span className="print-note">
|
||||
{de.export.redactedAt(
|
||||
section.value.requiredLevel,
|
||||
levelName.get(section.value.requiredLevel) ?? '',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{String(section.value ?? '-')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<DocumentSheet
|
||||
permissions={zugang.permissions}
|
||||
displayName={zugang.displayName}
|
||||
doc={doc}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadSections } from '@/lib/cases/sectionService'
|
||||
import { permissionsForPrint } from '@/lib/export/printAccess'
|
||||
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { CaseSheet } from '@/components/print/CaseSheet'
|
||||
import { NotAvailable } from '../../../NotAvailable'
|
||||
import type { RenderedSection } from '@/lib/sections/render'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,11 +20,8 @@ export async function generateMetadata({
|
||||
*
|
||||
* Sie ist die einzige Vorlage für den PDF-Export: kein zweites Layout von Hand
|
||||
* in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite auf.
|
||||
*
|
||||
* **Ein Export darf nie sauberer aussehen als die Akte ist.** Deshalb:
|
||||
* gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen,
|
||||
* geänderte Einträge sind als geändert gekennzeichnet, zurückgezogene stehen
|
||||
* als Grabstein mit Begründung. Siehe docs/08-suche-und-export.md.
|
||||
* Der Bogen selbst steht in `components/print/CaseSheet`, weil ihn auch das
|
||||
* Dossier einer Person und der Bogen einer Abteilung brauchen.
|
||||
*/
|
||||
export default async function PrintCasePage({
|
||||
params,
|
||||
@@ -44,224 +35,17 @@ export default async function PrintCasePage({
|
||||
|
||||
const zugang = await permissionsForPrint(token)
|
||||
if (!zugang) return <NotAvailable />
|
||||
const { permissions, displayName } = zugang
|
||||
|
||||
const akte = await loadVisibleCase(permissions, caseNumber)
|
||||
const akte = await loadVisibleCase(zugang.permissions, caseNumber)
|
||||
if (!akte) return <NotAvailable />
|
||||
|
||||
const [sections, entries, anhaenge, levels, autoren] = await Promise.all([
|
||||
loadSections(permissions, akte.id, akte.templateId),
|
||||
db.caseEntry.findMany({
|
||||
where: { AND: [visibleEntryWhere(permissions), { caseId: akte.id }] },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
body: true,
|
||||
authorId: true,
|
||||
createdAt: true,
|
||||
version: true,
|
||||
editedAt: true,
|
||||
editVisible: true,
|
||||
retractedAt: true,
|
||||
retractionReason: true,
|
||||
},
|
||||
}),
|
||||
db.attachment.findMany({
|
||||
where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
|
||||
}),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
db.user.findMany({ select: { id: true, displayName: true } }),
|
||||
])
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
|
||||
const stand = new Date()
|
||||
|
||||
return (
|
||||
<main className="print-sheet">
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">
|
||||
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>{akte.title}</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>{akte.caseNumber}</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<Zeile label={de.case.status} value={de.case.statusNames[akte.status] ?? akte.status} />
|
||||
<Zeile label={de.case.type} value={akte.templateKey} />
|
||||
<Zeile
|
||||
label={de.case.createdBy}
|
||||
value={nameVon.get(akte.createdById) ?? akte.createdById}
|
||||
/>
|
||||
<Zeile
|
||||
label={de.case.dueAt}
|
||||
value={akte.dueAt ? akte.dueAt.toLocaleDateString('de-DE') : '-'}
|
||||
/>
|
||||
<Zeile label={de.export.retrievedBy} value={displayName} />
|
||||
<Zeile
|
||||
label={de.export.asOf}
|
||||
value={`${stand.toLocaleString('de-DE')} · ${de.clearance.label} ${permissions.clearanceLevel}`}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="print-note" style={{ marginTop: '4mm' }}>
|
||||
{de.export.coverNote}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2>{de.section.heading}</h2>
|
||||
<PrintSections sections={sections} levelName={levelName} />
|
||||
|
||||
<h2>{de.case.timeline}</h2>
|
||||
{entries.length === 0 ? (
|
||||
<p className="print-note">{de.entry.empty}</p>
|
||||
) : (
|
||||
entries.map((eintrag) => (
|
||||
<div key={eintrag.id} className="print-entry">
|
||||
<p className="print-label">
|
||||
{de.entry.types[eintrag.type] ?? eintrag.type} ·{' '}
|
||||
{nameVon.get(eintrag.authorId) ?? eintrag.authorId} ·{' '}
|
||||
{eintrag.createdAt.toLocaleString('de-DE')}
|
||||
{eintrag.editVisible && eintrag.editedAt
|
||||
? ` · ${de.export.editedAt(eintrag.version, eintrag.editedAt.toLocaleDateString('de-DE'))}`
|
||||
: ''}
|
||||
</p>
|
||||
{eintrag.retractedAt ? (
|
||||
<p style={{ margin: '1mm 0', fontStyle: 'italic' }}>
|
||||
{de.entry.retracted(
|
||||
eintrag.retractedAt.toLocaleDateString('de-DE'),
|
||||
nameVon.get(eintrag.authorId) ?? eintrag.authorId,
|
||||
eintrag.retractionReason ?? '',
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>{eintrag.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<h2>{de.attachment.heading}</h2>
|
||||
{anhaenge.length === 0 ? (
|
||||
<p className="print-note">{de.attachment.empty}</p>
|
||||
) : (
|
||||
<table className="print-meta">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{de.export.fileName}</th>
|
||||
<th>{de.export.fileSize}</th>
|
||||
<th>{de.export.fileHash}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anhaenge.map((anhang) => (
|
||||
<tr key={anhang.id}>
|
||||
<td>{anhang.originalName}</td>
|
||||
<td>{anhang.sizeBytes}</td>
|
||||
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
|
||||
gehört - deshalb steht er im Verzeichnis. */}
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '7pt', wordBreak: 'break-all' }}>
|
||||
{anhang.sha256}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<CaseSheet
|
||||
permissions={zugang.permissions}
|
||||
displayName={zugang.displayName}
|
||||
akte={akte}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<th>{label}</th>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abschnitte im Druck. Ein gesperrtes Feld wird als Balken ausgegeben, mit
|
||||
* dem Vermerk, ab welcher Stufe es lesbar wäre - sonst merkt der Empfänger
|
||||
* nicht, dass er eine gekürzte Fassung in der Hand hält.
|
||||
*/
|
||||
function PrintSections({
|
||||
sections,
|
||||
levelName,
|
||||
}: {
|
||||
sections: RenderedSection[]
|
||||
levelName: Map<number, string>
|
||||
}) {
|
||||
const werte = Object.fromEntries(
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
|
||||
if (!isInputField(field)) {
|
||||
if (field.type === 'pagebreak') {
|
||||
return <div key={field.key} style={{ breakAfter: 'page' }} />
|
||||
}
|
||||
const text = fillPlaceholders(field.text ?? '', werte)
|
||||
return field.type === 'heading' ? (
|
||||
<h3 key={field.key}>{text || field.label}</h3>
|
||||
) : (
|
||||
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
|
||||
<p className="print-label">{field.label}</p>
|
||||
{!section.visible ? (
|
||||
<p style={{ margin: '1mm 0' }}>
|
||||
<span className="print-redacted"> </span>{' '}
|
||||
<span className="print-note">
|
||||
{de.export.redactedAt(
|
||||
section.value.requiredLevel,
|
||||
levelName.get(section.value.requiredLevel) ?? '',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : section.segments ? (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{section.segments.map((segment, index) =>
|
||||
segment.kind === 'text' ? (
|
||||
<span key={index}>{segment.text}</span>
|
||||
) : (
|
||||
// Balken statt Auslassung, mit der Stufe daneben: der
|
||||
// Empfaenger soll sehen, dass hier etwas fehlt.
|
||||
<span key={index}>
|
||||
<span
|
||||
className="print-redacted"
|
||||
style={{ width: `${segment.blockLength}ch` }}
|
||||
>
|
||||
|
||||
</span>
|
||||
<span className="print-note"> [{segment.requiredLevel}]</span>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{String(section.value ?? '-')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import { permissionsForPrint } from '@/lib/export/printAccess'
|
||||
import { effectiveCap } from '@/lib/export/dossier'
|
||||
import { DossierBody } from '@/components/print/DossierBody'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { NotAvailable } from '../../../NotAvailable'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: de.export.dossier,
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
/**
|
||||
* Druckansicht einer Personalakte als vollständiges Dossier.
|
||||
*
|
||||
* Alles in **einer** Seite und nicht als mehrere zusammengeheftete PDFs: die
|
||||
* Druckansicht ist ohnehin die einzige Vorlage, und so laufen Seitenzahlen,
|
||||
* Wasserzeichen und Kopfzeile durch. Ein PDF-Zusammenfüger wäre ein zweiter
|
||||
* Weg, der irgendwann anders aussieht als der erste.
|
||||
*
|
||||
* `stufe` deckelt, was aufgenommen wird. Der Wert wird hier **erneut** auf die
|
||||
* Stufe des Abrufers gedeckelt und nicht bloss übernommen: er steht in der
|
||||
* Adresse, und eine Adresse ist eine Eingabe.
|
||||
*/
|
||||
export default async function PrintPersonnelDossierPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ token?: string; stufe?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { token, stufe } = await searchParams
|
||||
|
||||
const zugang = await permissionsForPrint(token)
|
||||
if (!zugang) return <NotAvailable />
|
||||
const { permissions, displayName } = zugang
|
||||
|
||||
const akte = await db.personnelFile.findFirst({
|
||||
where: { AND: [visiblePersonnelWhere(permissions), { id }] },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
displayName: true,
|
||||
rank: true,
|
||||
classification: true,
|
||||
statusTags: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
department: { select: { key: true, name: true } },
|
||||
},
|
||||
})
|
||||
if (!akte) return <NotAvailable />
|
||||
|
||||
const cap = effectiveCap(permissions, Number(stufe ?? permissions.clearanceLevel))
|
||||
|
||||
return (
|
||||
<main className="print-sheet">
|
||||
<DossierBody
|
||||
permissions={permissions}
|
||||
displayName={displayName}
|
||||
akte={akte}
|
||||
cap={cap}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -929,6 +929,25 @@
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* Der Kopf einer Akte **innerhalb** eines Dossiers. Kein voller Deckel: der
|
||||
steht vorn, einer je Abzug. Hier genügt eine Marke, die sagt, was jetzt
|
||||
anfängt. */
|
||||
.print-runin {
|
||||
border-top: 2pt solid #000;
|
||||
border-bottom: 0.75pt solid #000;
|
||||
padding: 3mm 0;
|
||||
margin: 0 0 4mm;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Was der Abrufer bewusst weggelassen hat. Auffällig, weil eine gekürzte
|
||||
Mappe nicht wie eine vollständige aussehen darf - siehe docs/08. */
|
||||
.print-omitted {
|
||||
border: 1.5pt dashed #000;
|
||||
padding: 3mm 4mm;
|
||||
margin: 4mm 0;
|
||||
}
|
||||
|
||||
/* Die Schwärzung im Druck: ein voller Balken, kein Weglassen. */
|
||||
.print-redacted {
|
||||
display: inline-block;
|
||||
|
||||
@@ -13,13 +13,27 @@ import { requestExportAction, type ExportState } from '@/app/(app)/export/action
|
||||
* der Knopf zum Herunterladen bereit - automatisch losladen wäre unhöflich,
|
||||
* wenn jemand die Seite inzwischen wieder verlassen hat und zurückkommt.
|
||||
*/
|
||||
export type ExportPanelTarget =
|
||||
| { kind: 'CASE'; caseNumber: string }
|
||||
| { kind: 'DOCUMENT'; documentNumber: string }
|
||||
| { kind: 'PERSONNEL'; personnelFileId: string }
|
||||
| { kind: 'DEPARTMENT'; departmentKey: string }
|
||||
|
||||
export function ExportPanel({
|
||||
target,
|
||||
topic,
|
||||
levels,
|
||||
defaultLevel,
|
||||
}: {
|
||||
target: { kind: 'CASE'; caseNumber: string } | { kind: 'DOCUMENT'; documentNumber: string }
|
||||
target: ExportPanelTarget
|
||||
/** Eigenes Nutzerthema: der Export geht nur den Auftraggeber an. */
|
||||
topic: string
|
||||
/**
|
||||
* Wählbare Höchststufen. Nur beim Dossier gesetzt - eine einzelne Akte hat
|
||||
* genau eine Stufe, da gäbe es nichts zu wählen.
|
||||
*/
|
||||
levels?: { level: number; name: string }[]
|
||||
defaultLevel?: number
|
||||
}) {
|
||||
const [state, action, pending] = useActionState<ExportState | null, FormData>(
|
||||
requestExportAction,
|
||||
@@ -51,10 +65,35 @@ export function ExportPanel({
|
||||
<input type="hidden" name="kind" value={target.kind} />
|
||||
{target.kind === 'CASE' ? (
|
||||
<input type="hidden" name="caseNumber" value={target.caseNumber} />
|
||||
) : (
|
||||
) : target.kind === 'DOCUMENT' ? (
|
||||
<input type="hidden" name="documentNumber" value={target.documentNumber} />
|
||||
) : target.kind === 'PERSONNEL' ? (
|
||||
<input type="hidden" name="personnelFileId" value={target.personnelFileId} />
|
||||
) : (
|
||||
<input type="hidden" name="departmentKey" value={target.departmentKey} />
|
||||
)}
|
||||
|
||||
{levels ? (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="maxLevel" className="field-label">
|
||||
{exportText.maxLevel}
|
||||
</label>
|
||||
<select
|
||||
id="maxLevel"
|
||||
name="maxLevel"
|
||||
defaultValue={defaultLevel ?? levels[levels.length - 1]?.level ?? 0}
|
||||
className="field"
|
||||
>
|
||||
{levels.map((stufe) => (
|
||||
<option key={stufe.level} value={stufe.level}>
|
||||
{stufe.level} · {stufe.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="field-hint">{exportText.maxLevelHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || Boolean(state?.jobId)}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
||||
import { loadSections } from '@/lib/cases/sectionService'
|
||||
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import type { RenderedSection } from '@/lib/sections/render'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Der Druckbogen einer Fallakte, ohne die Seite drumherum.
|
||||
*
|
||||
* Ausgelagert, weil er an drei Stellen gebraucht wird: in der Druckansicht der
|
||||
* Akte selbst, im Dossier einer Person und im Bogen einer Abteilung. Stünde
|
||||
* das Layout dort jeweils noch einmal, liefe es auseinander - und ein Export,
|
||||
* der je nach Weg anders aussieht, ist keiner.
|
||||
*
|
||||
* **Ein Export darf nie sauberer aussehen als die Akte ist.** Deshalb:
|
||||
* gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen,
|
||||
* geänderte Einträge sind als geändert gekennzeichnet, zurückgezogene stehen
|
||||
* als Grabstein mit Begründung. Siehe docs/08-suche-und-export.md.
|
||||
*/
|
||||
export type CaseForPrint = {
|
||||
id: string
|
||||
caseNumber: string
|
||||
title: string
|
||||
status: string
|
||||
templateId: string
|
||||
templateKey: string
|
||||
classification: number
|
||||
createdById: string
|
||||
dueAt: Date | null
|
||||
}
|
||||
|
||||
export async function CaseSheet({
|
||||
permissions,
|
||||
displayName,
|
||||
akte,
|
||||
/** Im Dossier steht der Deckel schon vorn; dann nur der Inhalt. */
|
||||
withCover = true,
|
||||
authors,
|
||||
}: {
|
||||
permissions: EffectivePermissions
|
||||
displayName: string
|
||||
akte: CaseForPrint
|
||||
withCover?: boolean
|
||||
/** Einmal geladen und durchgereicht, statt je Akte erneut. */
|
||||
authors?: Map<string, string>
|
||||
}) {
|
||||
const [sections, entries, anhaenge, levels, autoren] = await Promise.all([
|
||||
loadSections(permissions, akte.id, akte.templateId),
|
||||
db.caseEntry.findMany({
|
||||
where: { AND: [visibleEntryWhere(permissions), { caseId: akte.id }] },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
body: true,
|
||||
authorId: true,
|
||||
createdAt: true,
|
||||
version: true,
|
||||
editedAt: true,
|
||||
editVisible: true,
|
||||
retractedAt: true,
|
||||
retractionReason: true,
|
||||
},
|
||||
}),
|
||||
db.attachment.findMany({
|
||||
where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
|
||||
}),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
authors ? [] : db.user.findMany({ select: { id: true, displayName: true } }),
|
||||
])
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const nameVon = authors ?? new Map(autoren.map((a) => [a.id, a.displayName]))
|
||||
const stand = new Date()
|
||||
|
||||
return (
|
||||
<>
|
||||
{withCover ? (
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">
|
||||
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>{akte.title}</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>{akte.caseNumber}</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<Zeile label={de.case.status} value={de.case.statusNames[akte.status] ?? akte.status} />
|
||||
<Zeile label={de.case.type} value={akte.templateKey} />
|
||||
<Zeile
|
||||
label={de.case.createdBy}
|
||||
value={nameVon.get(akte.createdById) ?? akte.createdById}
|
||||
/>
|
||||
<Zeile
|
||||
label={de.case.dueAt}
|
||||
value={akte.dueAt ? akte.dueAt.toLocaleDateString('de-DE') : '-'}
|
||||
/>
|
||||
<Zeile label={de.export.retrievedBy} value={displayName} />
|
||||
<Zeile
|
||||
label={de.export.asOf}
|
||||
value={`${stand.toLocaleString('de-DE')} · ${de.clearance.label} ${permissions.clearanceLevel}`}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="print-note" style={{ marginTop: '4mm' }}>
|
||||
{de.export.coverNote}
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<header className="print-runin">
|
||||
<span className="print-stamp">
|
||||
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h2 style={{ margin: '2mm 0 0' }}>{akte.title}</h2>
|
||||
<p className="print-note" style={{ margin: 0 }}>
|
||||
{akte.caseNumber} · {de.case.statusNames[akte.status] ?? akte.status}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<h2>{de.section.heading}</h2>
|
||||
<PrintSections sections={sections} levelName={levelName} />
|
||||
|
||||
<h2>{de.case.timeline}</h2>
|
||||
{entries.length === 0 ? (
|
||||
<p className="print-note">{de.entry.empty}</p>
|
||||
) : (
|
||||
entries.map((eintrag) => (
|
||||
<div key={eintrag.id} className="print-entry">
|
||||
<p className="print-label">
|
||||
{de.entry.types[eintrag.type] ?? eintrag.type} ·{' '}
|
||||
{nameVon.get(eintrag.authorId) ?? eintrag.authorId} ·{' '}
|
||||
{eintrag.createdAt.toLocaleString('de-DE')}
|
||||
{eintrag.editVisible && eintrag.editedAt
|
||||
? ` · ${de.export.editedAt(eintrag.version, eintrag.editedAt.toLocaleDateString('de-DE'))}`
|
||||
: ''}
|
||||
</p>
|
||||
{eintrag.retractedAt ? (
|
||||
<p style={{ margin: '1mm 0', fontStyle: 'italic' }}>
|
||||
{de.entry.retracted(
|
||||
eintrag.retractedAt.toLocaleDateString('de-DE'),
|
||||
nameVon.get(eintrag.authorId) ?? eintrag.authorId,
|
||||
eintrag.retractionReason ?? '',
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>{eintrag.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<h2>{de.attachment.heading}</h2>
|
||||
{anhaenge.length === 0 ? (
|
||||
<p className="print-note">{de.attachment.empty}</p>
|
||||
) : (
|
||||
<table className="print-meta">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{de.export.fileName}</th>
|
||||
<th>{de.export.fileSize}</th>
|
||||
<th>{de.export.fileHash}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anhaenge.map((anhang) => (
|
||||
<tr key={anhang.id}>
|
||||
<td>{anhang.originalName}</td>
|
||||
<td>{anhang.sizeBytes}</td>
|
||||
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
|
||||
gehört - deshalb steht er im Verzeichnis. */}
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '7pt', wordBreak: 'break-all' }}>
|
||||
{anhang.sha256}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<th>{label}</th>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abschnitte im Druck. Ein gesperrtes Feld wird als Balken ausgegeben, mit
|
||||
* dem Vermerk, ab welcher Stufe es lesbar wäre - sonst merkt der Empfänger
|
||||
* nicht, dass er eine gekürzte Fassung in der Hand hält.
|
||||
*/
|
||||
function PrintSections({
|
||||
sections,
|
||||
levelName,
|
||||
}: {
|
||||
sections: RenderedSection[]
|
||||
levelName: Map<number, string>
|
||||
}) {
|
||||
const werte = Object.fromEntries(
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
|
||||
if (!isInputField(field)) {
|
||||
if (field.type === 'pagebreak') {
|
||||
return <div key={field.key} style={{ breakAfter: 'page' }} />
|
||||
}
|
||||
const text = fillPlaceholders(field.text ?? '', werte)
|
||||
return field.type === 'heading' ? (
|
||||
<h3 key={field.key}>{text || field.label}</h3>
|
||||
) : (
|
||||
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
|
||||
<p className="print-label">{field.label}</p>
|
||||
{!section.visible ? (
|
||||
<p style={{ margin: '1mm 0' }}>
|
||||
<span className="print-redacted"> </span>{' '}
|
||||
<span className="print-note">
|
||||
{de.export.redactedAt(
|
||||
section.value.requiredLevel,
|
||||
levelName.get(section.value.requiredLevel) ?? '',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : section.segments ? (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{section.segments.map((segment, index) =>
|
||||
segment.kind === 'text' ? (
|
||||
<span key={index}>{segment.text}</span>
|
||||
) : (
|
||||
// Balken statt Auslassung, mit der Stufe daneben: der
|
||||
// Empfaenger soll sehen, dass hier etwas fehlt.
|
||||
<span key={index}>
|
||||
<span
|
||||
className="print-redacted"
|
||||
style={{ width: `${segment.blockLength}ch` }}
|
||||
>
|
||||
|
||||
</span>
|
||||
<span className="print-note"> [{segment.requiredLevel}]</span>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{String(section.value ?? '-')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { loadDocumentSections } from '@/lib/documents/service'
|
||||
import { resolvePhotoUrls } from '@/lib/files/service'
|
||||
import { Sheet } from '@/components/documents/Sheet'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Der Druckbogen eines Dokuments, ohne die Seite drumherum.
|
||||
*
|
||||
* Kein Verlauf, keine Kopfdatentabelle einer Akte - das Dokument selbst, so
|
||||
* wie seine Vorlage es vorsieht. Ein Aktenblatt kommt als Aktenblatt heraus,
|
||||
* ein Vertrag als Vertrag. Siehe docs/08-suche-und-export.md.
|
||||
*
|
||||
* Ausgelagert aus derselben Not wie CaseSheet: das Dossier einer Person hängt
|
||||
* alle Dokumente hintereinander, und zwar in genau dieser Darstellung.
|
||||
*/
|
||||
export type DocumentForPrint = {
|
||||
id: string
|
||||
documentNumber: string
|
||||
title: string
|
||||
templateId: string
|
||||
templateKey: string
|
||||
layout: string
|
||||
status: string
|
||||
classification: number
|
||||
createdAt: Date
|
||||
retractedAt: Date | null
|
||||
}
|
||||
|
||||
export async function DocumentSheet({
|
||||
permissions,
|
||||
displayName,
|
||||
doc,
|
||||
/** Im Dossier steht der Deckel schon vorn; dann nur der Inhalt. */
|
||||
withCover = true,
|
||||
}: {
|
||||
permissions: EffectivePermissions
|
||||
displayName: string
|
||||
doc: DocumentForPrint
|
||||
withCover?: boolean
|
||||
}) {
|
||||
const [sections, template, foundationLevels, levels] = await Promise.all([
|
||||
loadDocumentSections(permissions, doc),
|
||||
db.documentTemplate.findUnique({
|
||||
where: { id: doc.templateId },
|
||||
select: { sheetTitle: true, sheetSubtitle: true, name: true },
|
||||
}),
|
||||
db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
])
|
||||
|
||||
const photoUrls = await resolvePhotoUrls(
|
||||
permissions,
|
||||
sections.map((section) => ({
|
||||
fieldKey: section.fieldKey,
|
||||
visible: section.visible,
|
||||
value: section.visible ? section.value : null,
|
||||
type: section.field.type,
|
||||
})),
|
||||
)
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const werte = Object.fromEntries(
|
||||
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
|
||||
)
|
||||
|
||||
// Das Aktenblatt bringt sein eigenes Aussehen mit und braucht keinen
|
||||
// Berichtsrahmen darum.
|
||||
if (doc.layout === 'DOSSIER') {
|
||||
return (
|
||||
<Sheet
|
||||
sections={sections}
|
||||
title={template?.sheetTitle ?? de.app.name}
|
||||
subtitle={template?.sheetSubtitle ?? template?.name ?? doc.title}
|
||||
documentNumber={doc.documentNumber}
|
||||
issuedAt={doc.createdAt}
|
||||
foundationLevels={foundationLevels}
|
||||
footerNote={de.document.footerNote}
|
||||
photoUrls={photoUrls}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{withCover ? (
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">
|
||||
{doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>{doc.title}</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>
|
||||
{doc.documentNumber}
|
||||
</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{de.case.status}</th>
|
||||
<td>{de.document.statusNames[doc.status] ?? doc.status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.document.kind}</th>
|
||||
<td>{doc.templateKey}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.retrievedBy}</th>
|
||||
<td>{displayName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{de.export.asOf}</th>
|
||||
<td>
|
||||
{new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '}
|
||||
{permissions.clearanceLevel}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
) : (
|
||||
<header className="print-runin">
|
||||
<span className="print-stamp">
|
||||
{doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
|
||||
</span>
|
||||
<h3 style={{ margin: '2mm 0 0' }}>{doc.title}</h3>
|
||||
<p className="print-note" style={{ margin: 0 }}>
|
||||
{doc.documentNumber} · {de.document.statusNames[doc.status] ?? doc.status}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{doc.retractedAt ? (
|
||||
<p className="print-stamp" style={{ marginBottom: '4mm' }}>
|
||||
{de.document.statusNames.RETRACTED}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sections.map((section) => {
|
||||
const field = section.field
|
||||
|
||||
if (!isInputField(field)) {
|
||||
if (field.type === 'pagebreak') {
|
||||
return <div key={field.key} style={{ breakAfter: 'page' }} />
|
||||
}
|
||||
const text = fillPlaceholders(field.text ?? '', werte)
|
||||
return field.type === 'heading' ? (
|
||||
<h3 key={field.key}>{text || field.label}</h3>
|
||||
) : (
|
||||
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'signature') {
|
||||
return (
|
||||
<div key={field.key} style={{ marginTop: '10mm', breakInside: 'avoid' }}>
|
||||
<div style={{ borderTop: '1pt solid #000', width: '70mm' }} />
|
||||
<p className="print-note">{fillPlaceholders(field.label, werte)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
|
||||
<p className="print-label">{field.label}</p>
|
||||
{!section.visible ? (
|
||||
<p style={{ margin: '1mm 0' }}>
|
||||
<span className="print-redacted"> </span>{' '}
|
||||
<span className="print-note">
|
||||
{de.export.redactedAt(
|
||||
section.value.requiredLevel,
|
||||
levelName.get(section.value.requiredLevel) ?? '',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
||||
{String(section.value ?? '-')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { collectPersonnelDossier, type Dossier } from '@/lib/export/dossier'
|
||||
import { CaseSheet } from '@/components/print/CaseSheet'
|
||||
import { DocumentSheet } from '@/components/print/DocumentSheet'
|
||||
import { PersonnelSheet, type PersonnelForPrint } from '@/components/print/PersonnelSheet'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Ein Dossier: die Personalakte, dahinter ihre Dokumente, dahinter die
|
||||
* Fallakten, in denen die Person vorkommt.
|
||||
*
|
||||
* Die Reihenfolge ist die Vorgabe und keine Geschmacksfrage - wer eine Akte
|
||||
* aufschlägt, will zuerst wissen, um wen es geht.
|
||||
*
|
||||
* Der Vermerk über das Weggelassene steht **vorn**, gleich hinter der Akte,
|
||||
* nicht am Ende: wer die letzten Seiten nicht liest, muss trotzdem erfahren,
|
||||
* dass die Mappe gekürzt ist.
|
||||
*/
|
||||
export async function DossierBody({
|
||||
permissions,
|
||||
displayName,
|
||||
akte,
|
||||
cap,
|
||||
/** Im Abteilungsbogen steht der Deckel schon vorn. */
|
||||
withCover = true,
|
||||
}: {
|
||||
permissions: EffectivePermissions
|
||||
displayName: string
|
||||
akte: PersonnelForPrint
|
||||
cap: number
|
||||
withCover?: boolean
|
||||
}) {
|
||||
const [inhalt, autoren] = await Promise.all([
|
||||
collectPersonnelDossier(permissions, { id: akte.id, userId: akte.userId }, cap),
|
||||
db.user.findMany({ select: { id: true, displayName: true } }),
|
||||
])
|
||||
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
|
||||
|
||||
return (
|
||||
<>
|
||||
<PersonnelSheet
|
||||
permissions={permissions}
|
||||
displayName={displayName}
|
||||
akte={akte}
|
||||
withCover={withCover}
|
||||
/>
|
||||
|
||||
<OmittedNote inhalt={inhalt} eigeneStufe={permissions.clearanceLevel} />
|
||||
|
||||
<h2 style={{ breakBefore: 'page' }}>{de.export.documentsHeading}</h2>
|
||||
{inhalt.documents.length === 0 ? (
|
||||
<p className="print-note">{de.export.noDocuments}</p>
|
||||
) : (
|
||||
inhalt.documents.map((doc, index) => (
|
||||
<section
|
||||
key={doc.id}
|
||||
style={index === 0 ? undefined : { breakBefore: 'page' }}
|
||||
>
|
||||
<DocumentSheet
|
||||
permissions={permissions}
|
||||
displayName={displayName}
|
||||
doc={doc}
|
||||
withCover={false}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
<h2 style={{ breakBefore: 'page' }}>{de.export.casesHeading}</h2>
|
||||
{inhalt.cases.length === 0 ? (
|
||||
<p className="print-note">{de.export.noCases}</p>
|
||||
) : (
|
||||
inhalt.cases.map((akte, index) => (
|
||||
<section
|
||||
key={akte.id}
|
||||
style={index === 0 ? undefined : { breakBefore: 'page' }}
|
||||
>
|
||||
<CaseSheet
|
||||
permissions={permissions}
|
||||
displayName={displayName}
|
||||
akte={akte}
|
||||
withCover={false}
|
||||
authors={nameVon}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Was der Abrufer hätte sehen dürfen und selbst ausgeschlossen hat.
|
||||
*
|
||||
* Steht bewusst da, auch wenn nichts weggelassen wurde - dann als leere
|
||||
* Aussage: "nichts weggelassen" ist eine Information. Nur was **über** der
|
||||
* Stufe des Abrufers liegt, taucht hier nie auf; das existiert für ihn nicht.
|
||||
*/
|
||||
export function OmittedNote({
|
||||
inhalt,
|
||||
eigeneStufe,
|
||||
}: {
|
||||
inhalt: Pick<Dossier, 'omitted' | 'omittedTotal' | 'truncated'>
|
||||
eigeneStufe: number
|
||||
}) {
|
||||
if (inhalt.omittedTotal === 0 && !inhalt.truncated) return null
|
||||
|
||||
return (
|
||||
<section className="print-omitted" style={{ breakInside: 'avoid' }}>
|
||||
<p className="print-label">{de.export.omittedHeading}</p>
|
||||
{inhalt.omittedTotal > 0 ? (
|
||||
<>
|
||||
<p className="print-note" style={{ margin: '1mm 0' }}>
|
||||
{de.export.omittedNote(eigeneStufe)}
|
||||
</p>
|
||||
<ul style={{ margin: '1mm 0 0', paddingLeft: '5mm' }}>
|
||||
{inhalt.omitted.map((zeile) => (
|
||||
<li key={zeile.level}>
|
||||
{de.export.omittedRow(zeile.level, zeile.documents, zeile.cases)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
{inhalt.truncated ? (
|
||||
<p className="print-note" style={{ margin: '1mm 0 0' }}>
|
||||
{de.export.truncated}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Der Druckbogen einer Personalakte: die Kopfdaten selbst.
|
||||
*
|
||||
* Die Personalakte trägt keine Vorlage mit Abschnitten wie eine Fallakte - was
|
||||
* über die Person aktenkundig ist, steht in ihren Dokumenten. Deshalb ist
|
||||
* dieser Bogen kurz; das Gewicht des Dossiers liegt in dem, was dahinter
|
||||
* kommt. Siehe lib/export/dossier.ts.
|
||||
*/
|
||||
export type PersonnelForPrint = {
|
||||
id: string
|
||||
userId: string | null
|
||||
displayName: string
|
||||
rank: string | null
|
||||
classification: number
|
||||
statusTags: string[]
|
||||
note: string | null
|
||||
department: { key: string; name: string } | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export async function PersonnelSheet({
|
||||
permissions,
|
||||
displayName,
|
||||
akte,
|
||||
withCover = true,
|
||||
}: {
|
||||
permissions: EffectivePermissions
|
||||
displayName: string
|
||||
akte: PersonnelForPrint
|
||||
withCover?: boolean
|
||||
}) {
|
||||
const [levels, etiketten] = await Promise.all([
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
akte.statusTags.length === 0
|
||||
? []
|
||||
: db.personnelStatusTag.findMany({
|
||||
where: { key: { in: akte.statusTags } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { key: true, name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const stufe = `${akte.classification} · ${levelName.get(akte.classification) ?? de.common.unknown}`
|
||||
|
||||
return (
|
||||
<>
|
||||
{withCover ? (
|
||||
<section className="print-cover">
|
||||
<span className="print-stamp">{stufe}</span>
|
||||
<h1 style={{ marginTop: '4mm' }}>
|
||||
{akte.rank ? `[${akte.rank}] ` : ''}
|
||||
{akte.displayName}
|
||||
</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>
|
||||
{de.personnel.one}
|
||||
</p>
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<Zeile label={de.personnel.rank} value={akte.rank ?? '-'} />
|
||||
<Zeile
|
||||
label={de.personnel.department}
|
||||
value={akte.department?.name ?? de.personnel.noDepartment}
|
||||
/>
|
||||
<Zeile
|
||||
label={de.personnel.linkedAccount}
|
||||
value={akte.userId ?? de.personnel.noAccount}
|
||||
/>
|
||||
<Zeile label={de.export.retrievedBy} value={displayName} />
|
||||
<Zeile
|
||||
label={de.export.asOf}
|
||||
value={`${new Date().toLocaleString('de-DE')} · ${de.clearance.label} ${permissions.clearanceLevel}`}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="print-note" style={{ marginTop: '4mm' }}>
|
||||
{de.export.coverNote}
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<header className="print-runin">
|
||||
<span className="print-stamp">{stufe}</span>
|
||||
<h2 style={{ margin: '2mm 0 0' }}>
|
||||
{akte.rank ? `[${akte.rank}] ` : ''}
|
||||
{akte.displayName}
|
||||
</h2>
|
||||
<p className="print-note" style={{ margin: 0 }}>
|
||||
{akte.department?.name ?? de.personnel.noDepartment}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<table className="print-meta">
|
||||
<tbody>
|
||||
<Zeile
|
||||
label={de.personnel.status}
|
||||
value={
|
||||
etiketten.length === 0
|
||||
? de.personnel.statusNone
|
||||
: etiketten.map((tag) => tag.name).join(', ')
|
||||
}
|
||||
/>
|
||||
<Zeile label={de.personnel.note} value={akte.note ?? '-'} />
|
||||
<Zeile label={de.case.createdAt} value={akte.createdAt.toLocaleDateString('de-DE')} />
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<th>{label}</th>
|
||||
<td style={{ whiteSpace: 'pre-wrap' }}>{value}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,14 @@ import { createHash } from 'node:crypto'
|
||||
* Dokument geben, das für eine höhere Stufe gerendert wurde.
|
||||
*
|
||||
* Der Stand (`updatedAt`) sorgt dafür, dass eine geänderte Akte neu gerendert
|
||||
* wird - ein Export darf nie einen älteren Stand zeigen, als die Akte hat.
|
||||
* wird - ein Export darf nie einen älteren Stand zeigen, als die Akte hat. Bei
|
||||
* einem Dossier ist das der **späteste** Stand über alle enthaltenen Akten,
|
||||
* siehe lib/export/dossier.ts.
|
||||
*
|
||||
* `maxLevel` gehört aus demselben Grund hinein wie die Stufe: zwei Abzüge mit
|
||||
* verschiedenen Deckeln sind verschiedene Dateien, und ein Treffer im
|
||||
* Zwischenspeicher darf keinen ungedeckelten Abzug für einen gedeckelten
|
||||
* ausgeben.
|
||||
*/
|
||||
export function exportCacheKey(input: {
|
||||
kind: string
|
||||
@@ -17,6 +24,7 @@ export function exportCacheKey(input: {
|
||||
updatedAt: Date
|
||||
clearanceLevel: number
|
||||
templateVersion: number | string
|
||||
maxLevel?: number | null
|
||||
}): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
@@ -26,6 +34,7 @@ export function exportCacheKey(input: {
|
||||
input.updatedAt.toISOString(),
|
||||
String(input.clearanceLevel),
|
||||
String(input.templateVersion),
|
||||
String(input.maxLevel ?? ''),
|
||||
].join('|'),
|
||||
)
|
||||
.digest('hex')
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { Prisma } from '@afis/db'
|
||||
import { db } from '@/lib/db'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import {
|
||||
visibleDepartmentEntryWhere,
|
||||
visibleDepartmentWhere,
|
||||
} from '@/lib/access/departmentFilter'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
import type { CaseForPrint } from '@/components/print/CaseSheet'
|
||||
import type { DocumentForPrint } from '@/components/print/DocumentSheet'
|
||||
|
||||
/**
|
||||
* Was in ein Dossier gehört.
|
||||
*
|
||||
* Ein Dossier ist die Personalakte einer Person, dahinter alle ihre Dokumente
|
||||
* und dahinter alle Fallakten, in denen sie vorkommt - alles in derselben
|
||||
* Druckdarstellung wie einzeln. Für eine Abteilung dasselbe, je Mitglied
|
||||
* getrennt.
|
||||
*
|
||||
* Zwei Grenzen wirken hier, und sie sind **nicht** dasselbe:
|
||||
*
|
||||
* 1. **Die Freigabestufe des Abrufers.** Was darüber liegt, existiert für ihn
|
||||
* nicht: es steht in keiner Liste, in keiner Zählung und in keinem
|
||||
* Hinweis. Das ist harte Regel 6, und sie gilt hier wie überall.
|
||||
* 2. **Die gewählte Höchststufe des Exports.** Das ist eine Entscheidung des
|
||||
* Abrufers, kein Schutz vor ihm. Was er dadurch weglässt, **muss im PDF
|
||||
* stehen** - sonst hält der Empfänger eine gekürzte Mappe für eine
|
||||
* vollständige. Deshalb zählt `omitted` mit, und deshalb zählt es nur,
|
||||
* was der Abrufer selbst sehen dürfte.
|
||||
*
|
||||
* Beide laufen in der Datenbankabfrage, nicht danach in JavaScript.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Obergrenze je Abschnitt. Bei dieser Nutzerzahl unerreichbar; sie steht hier,
|
||||
* damit ein Abzug nicht unbemerkt ins Unendliche wächst. Wird sie erreicht,
|
||||
* sagt das PDF es - abgeschnitten und stillschweigend wäre das Schlimmste.
|
||||
*/
|
||||
export const DOSSIER_LIMIT = 250
|
||||
|
||||
export type OmittedCount = { level: number; documents: number; cases: number }
|
||||
|
||||
export type Dossier = {
|
||||
documents: DocumentForPrint[]
|
||||
cases: CaseForPrint[]
|
||||
/** Innerhalb der eigenen Stufe, aber über der gewählten Höchststufe. */
|
||||
omitted: OmittedCount[]
|
||||
omittedTotal: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const DOKUMENT_FELDER = {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
title: true,
|
||||
templateId: true,
|
||||
templateKey: true,
|
||||
layout: true,
|
||||
status: true,
|
||||
classification: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
retractedAt: true,
|
||||
} satisfies Prisma.DocumentSelect
|
||||
|
||||
const AKTE_FELDER = {
|
||||
id: true,
|
||||
caseNumber: true,
|
||||
title: true,
|
||||
status: true,
|
||||
templateId: true,
|
||||
templateKey: true,
|
||||
classification: true,
|
||||
createdById: true,
|
||||
dueAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.CaseSelect
|
||||
|
||||
/** Die gewählte Höchststufe, gedeckelt auf das, was der Abrufer selbst darf. */
|
||||
export function effectiveCap(p: EffectivePermissions, requested: number): number {
|
||||
const gewuenscht = Number.isInteger(requested) ? requested : p.clearanceLevel
|
||||
return Math.max(0, Math.min(gewuenscht, p.clearanceLevel))
|
||||
}
|
||||
|
||||
/** Dokumente an dieser Personalakte, und Fallakten, in denen sie vorkommt. */
|
||||
function bedingungen(fileId: string, userId: string | null) {
|
||||
const wenDieAkteMeint: Prisma.CaseParticipantWhereInput[] = [{ personnelFileId: fileId }]
|
||||
// Dieselbe Person kann über ihre Personalakte oder über ihr Discord-Konto
|
||||
// eingetragen sein. Beides zählt, sonst fehlen Fälle - wie bei den
|
||||
// Rückverweisen in lib/personnel/service.ts.
|
||||
if (userId) wenDieAkteMeint.push({ userId })
|
||||
|
||||
return {
|
||||
dokumente: { personnelFileId: fileId } satisfies Prisma.DocumentWhereInput,
|
||||
faelle: {
|
||||
participants: { some: { OR: wenDieAkteMeint } },
|
||||
} satisfies Prisma.CaseWhereInput,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles, was zu einer Person ins Dossier gehört.
|
||||
*
|
||||
* `cap` ist bereits gedeckelt zu übergeben (siehe `effectiveCap`) - diese
|
||||
* Funktion deckelt nicht noch einmal, damit klar bleibt, wo das passiert.
|
||||
*/
|
||||
export async function collectPersonnelDossier(
|
||||
p: EffectivePermissions,
|
||||
file: { id: string; userId: string | null },
|
||||
cap: number,
|
||||
): Promise<Dossier> {
|
||||
const wo = bedingungen(file.id, file.userId)
|
||||
|
||||
const [dokumente, faelle, dokumenteDrueber, faelleDrueber] = await Promise.all([
|
||||
db.document.findMany({
|
||||
where: {
|
||||
AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }],
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
take: DOSSIER_LIMIT,
|
||||
select: DOKUMENT_FELDER,
|
||||
}),
|
||||
db.case.findMany({
|
||||
where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] },
|
||||
orderBy: [{ caseNumber: 'asc' }],
|
||||
take: DOSSIER_LIMIT,
|
||||
select: AKTE_FELDER,
|
||||
}),
|
||||
|
||||
// Was der Abrufer sehen dürfte, aber selbst ausgeschlossen hat. Gezählt
|
||||
// wird in der Datenbank und **innerhalb** der Sichtbarkeitsbedingung: was
|
||||
// über seiner eigenen Stufe liegt, taucht auch hier nicht auf.
|
||||
db.document.groupBy({
|
||||
by: ['classification'],
|
||||
where: {
|
||||
AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { gt: cap } }],
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
db.case.groupBy({
|
||||
by: ['classification'],
|
||||
where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { gt: cap } }] },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
documents: dokumente,
|
||||
cases: faelle,
|
||||
...zusammenfassen(dokumenteDrueber, faelleDrueber),
|
||||
truncated: dokumente.length === DOSSIER_LIMIT || faelle.length === DOSSIER_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
function zusammenfassen(
|
||||
dokumente: { classification: number; _count: { _all: number } }[],
|
||||
faelle: { classification: number; _count: { _all: number } }[],
|
||||
): { omitted: OmittedCount[]; omittedTotal: number } {
|
||||
const nachStufe = new Map<number, OmittedCount>()
|
||||
const hinein = (stufe: number, art: 'documents' | 'cases', anzahl: number) => {
|
||||
const zeile = nachStufe.get(stufe) ?? { level: stufe, documents: 0, cases: 0 }
|
||||
zeile[art] += anzahl
|
||||
nachStufe.set(stufe, zeile)
|
||||
}
|
||||
|
||||
for (const zeile of dokumente) hinein(zeile.classification, 'documents', zeile._count._all)
|
||||
for (const zeile of faelle) hinein(zeile.classification, 'cases', zeile._count._all)
|
||||
|
||||
const omitted = [...nachStufe.values()].sort((a, b) => a.level - b.level)
|
||||
return {
|
||||
omitted,
|
||||
omittedTotal: omitted.reduce((summe, zeile) => summe + zeile.documents + zeile.cases, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Mitglied mit **allen** Kopfdaten, die der Druckbogen zeigt.
|
||||
*
|
||||
* Nicht nur Name und Dienstgrad: würde der Bogen im Abteilungsabzug mit
|
||||
* leeren Feldern gerendert, stünde dort "Kein Status gesetzt" bei einer
|
||||
* Person, die sehr wohl einen trägt. Ein Export darf nie sauberer aussehen,
|
||||
* als die Akte ist.
|
||||
*/
|
||||
export type DepartmentMember = {
|
||||
id: string
|
||||
userId: string | null
|
||||
displayName: string
|
||||
rank: string | null
|
||||
classification: number
|
||||
statusTags: string[]
|
||||
note: string | null
|
||||
createdAt: Date
|
||||
department: { key: string; name: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Mitglieder einer Abteilung, soweit sie der Abrufer sehen darf.
|
||||
*
|
||||
* Eine Personalakte über seiner Stufe fehlt hier vollständig und wird auch
|
||||
* nicht gezählt - sonst verriete die Mitgliederzahl ihre Existenz.
|
||||
*/
|
||||
export async function departmentMembers(
|
||||
p: EffectivePermissions,
|
||||
departmentId: string,
|
||||
cap: number,
|
||||
): Promise<{ members: DepartmentMember[]; omittedMembers: number }> {
|
||||
const [members, drueber] = await Promise.all([
|
||||
db.personnelFile.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
visiblePersonnelWhere(p),
|
||||
{ departmentId },
|
||||
{ classification: { lte: cap } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ displayName: 'asc' }],
|
||||
take: DOSSIER_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
displayName: true,
|
||||
rank: true,
|
||||
classification: true,
|
||||
statusTags: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
department: { select: { key: true, name: true } },
|
||||
},
|
||||
}),
|
||||
db.personnelFile.count({
|
||||
where: {
|
||||
AND: [visiblePersonnelWhere(p), { departmentId }, { classification: { gt: cap } }],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return { members, omittedMembers: drueber }
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Stand eines Dossiers für den Zwischenspeicherschlüssel.
|
||||
*
|
||||
* Nicht das `updatedAt` der Personalakte allein: ein Dossier ändert sich auch
|
||||
* dann, wenn eines seiner Dokumente oder eine seiner Fallakten sich ändert.
|
||||
* Ohne das gäbe der Zwischenspeicher einen veralteten Abzug heraus, und ein
|
||||
* Export darf nie einen älteren Stand zeigen, als die Akten haben.
|
||||
*/
|
||||
export async function personnelDossierStand(
|
||||
p: EffectivePermissions,
|
||||
file: { id: string; userId: string | null; updatedAt: Date },
|
||||
cap: number,
|
||||
): Promise<Date> {
|
||||
const wo = bedingungen(file.id, file.userId)
|
||||
|
||||
const [dokument, akte] = await Promise.all([
|
||||
db.document.aggregate({
|
||||
where: { AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }] },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
db.case.aggregate({
|
||||
where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return neuestes([file.updatedAt, dokument._max.updatedAt, akte._max.updatedAt])
|
||||
}
|
||||
|
||||
/** Derselbe Gedanke für eine Abteilung, über alle sichtbaren Mitglieder. */
|
||||
export async function departmentDossierStand(
|
||||
p: EffectivePermissions,
|
||||
department: { id: string; updatedAt: Date },
|
||||
cap: number,
|
||||
): Promise<Date> {
|
||||
const { members } = await departmentMembers(p, department.id, cap)
|
||||
|
||||
const staende = await Promise.all(
|
||||
members.map((mitglied) =>
|
||||
personnelDossierStand(
|
||||
p,
|
||||
{ id: mitglied.id, userId: mitglied.userId, updatedAt: department.updatedAt },
|
||||
cap,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Vermerke tragen kein `updatedAt`: sie werden nicht überschrieben, sondern
|
||||
// bekommen eine Fassung oder einen Grabstein. Der Stand ist deshalb der
|
||||
// späteste dieser drei Zeitpunkte.
|
||||
const vermerk = await db.departmentEntry.aggregate({
|
||||
where: { AND: [visibleDepartmentEntryWhere(p), { departmentId: department.id }] },
|
||||
_max: { createdAt: true, editedAt: true, retractedAt: true },
|
||||
})
|
||||
|
||||
return neuestes([
|
||||
department.updatedAt,
|
||||
vermerk._max?.createdAt,
|
||||
vermerk._max?.editedAt,
|
||||
vermerk._max?.retractedAt,
|
||||
...staende,
|
||||
])
|
||||
}
|
||||
|
||||
function neuestes(werte: (Date | null | undefined)[]): Date {
|
||||
let neueste = new Date(0)
|
||||
for (const wert of werte) {
|
||||
if (wert && wert.getTime() > neueste.getTime()) neueste = wert
|
||||
}
|
||||
return neueste
|
||||
}
|
||||
|
||||
/** Die Abteilung, sofern der Abrufer sie sehen darf. */
|
||||
export async function loadVisibleDepartmentForPrint(p: EffectivePermissions, key: string) {
|
||||
return db.department.findFirst({
|
||||
where: { AND: [visibleDepartmentWhere(p), { key }] },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
minClearance: true,
|
||||
isArchived: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
+116
-13
@@ -5,6 +5,13 @@ import { writeAudit } from '@/lib/audit/write'
|
||||
import { AccessError } from '@/lib/errors'
|
||||
import { loadVisibleCase } from '@/lib/cases/service'
|
||||
import { loadVisibleDocument } from '@/lib/documents/service'
|
||||
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||
import {
|
||||
departmentDossierStand,
|
||||
effectiveCap,
|
||||
loadVisibleDepartmentForPrint,
|
||||
personnelDossierStand,
|
||||
} from '@/lib/export/dossier'
|
||||
import { ensureDirFor, storageKeyFor } from '@/lib/files/paths'
|
||||
import { exportCacheKey } from '@/lib/export/cacheKey'
|
||||
import { createRenderToken } from '@/lib/export/token'
|
||||
@@ -36,6 +43,10 @@ import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
export type ExportTarget =
|
||||
| { kind: 'CASE'; caseNumber: string }
|
||||
| { kind: 'DOCUMENT'; documentNumber: string }
|
||||
/** Personalakte samt Dokumenten und den Fallakten, in denen sie vorkommt. */
|
||||
| { kind: 'PERSONNEL'; personnelFileId: string; maxLevel: number }
|
||||
/** Abteilung, je Mitglied ein vollständiges Dossier. */
|
||||
| { kind: 'DEPARTMENT'; departmentKey: string; maxLevel: number }
|
||||
|
||||
/**
|
||||
* Legt einen Auftrag an - oder gibt einen fertigen zurück, wenn genau dieser
|
||||
@@ -59,6 +70,7 @@ export async function requestExport(
|
||||
updatedAt: ziel.updatedAt,
|
||||
clearanceLevel: p.clearanceLevel,
|
||||
templateVersion: ziel.templateVersion,
|
||||
maxLevel: ziel.maxLevel,
|
||||
})
|
||||
|
||||
// Derselbe Stand, dieselbe Stufe: das PDF liegt schon da.
|
||||
@@ -67,7 +79,12 @@ export async function requestExport(
|
||||
select: { id: true },
|
||||
})
|
||||
if (fertig) {
|
||||
await protokolliere(p, 'export.pdf', ziel.id, { cached: true, kind: target.kind })
|
||||
await protokolliere(p, 'export.pdf', ziel.id, {
|
||||
cached: true,
|
||||
kind: target.kind,
|
||||
label: ziel.label,
|
||||
maxLevel: ziel.maxLevel,
|
||||
})
|
||||
return { jobId: fertig.id, status: 'READY' }
|
||||
}
|
||||
|
||||
@@ -85,6 +102,7 @@ export async function requestExport(
|
||||
kind: target.kind,
|
||||
label: ziel.label,
|
||||
classification: ziel.classification,
|
||||
maxLevel: ziel.maxLevel,
|
||||
})
|
||||
return { jobId: laufend.id, status: laufend.status }
|
||||
}
|
||||
@@ -96,6 +114,7 @@ export async function requestExport(
|
||||
targetId: ziel.id,
|
||||
requestedById: p.userId,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
maxLevel: ziel.maxLevel,
|
||||
cacheKey,
|
||||
status: 'QUEUED',
|
||||
},
|
||||
@@ -105,13 +124,17 @@ export async function requestExport(
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'export.pdf',
|
||||
targetType: target.kind === 'CASE' ? 'Case' : 'Document',
|
||||
targetType: zielArt(target.kind),
|
||||
targetId: ziel.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: {
|
||||
jobId: angelegt.id,
|
||||
kind: target.kind,
|
||||
label: ziel.label,
|
||||
classification: ziel.classification,
|
||||
// Der Deckel gehört ins Protokoll: er sagt, wie viel das Haus
|
||||
// verlassen hat.
|
||||
maxLevel: ziel.maxLevel,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -135,19 +158,66 @@ async function aufloesen(p: EffectivePermissions, target: ExportTarget) {
|
||||
classification: akte.classification,
|
||||
updatedAt: akte.updatedAt,
|
||||
templateVersion: vorlage?.version ?? 0,
|
||||
maxLevel: null,
|
||||
printPath: `/faelle/${akte.caseNumber}/druck`,
|
||||
}
|
||||
}
|
||||
|
||||
const doc = await loadVisibleDocument(p, target.documentNumber)
|
||||
if (!doc) throw new AccessError('not-found')
|
||||
if (target.kind === 'DOCUMENT') {
|
||||
const doc = await loadVisibleDocument(p, target.documentNumber)
|
||||
if (!doc) throw new AccessError('not-found')
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.documentNumber,
|
||||
classification: doc.classification,
|
||||
updatedAt: doc.updatedAt,
|
||||
templateVersion: doc.templateVersion,
|
||||
maxLevel: null,
|
||||
printPath: `/dokumente/${doc.documentNumber}/druck`,
|
||||
}
|
||||
}
|
||||
|
||||
// Ab hier die Dossiers. Der Deckel wird sofort auf die Stufe des Abrufers
|
||||
// gebracht - alles Weitere rechnet mit dem gedeckelten Wert, damit es keinen
|
||||
// zweiten Ort gibt, an dem das schiefgehen könnte.
|
||||
const cap = effectiveCap(p, target.maxLevel)
|
||||
|
||||
if (target.kind === 'PERSONNEL') {
|
||||
const akte = await db.personnelFile.findFirst({
|
||||
where: { AND: [visiblePersonnelWhere(p), { id: target.personnelFileId }] },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
displayName: true,
|
||||
classification: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
if (!akte) throw new AccessError('not-found')
|
||||
|
||||
return {
|
||||
id: akte.id,
|
||||
label: akte.displayName,
|
||||
classification: akte.classification,
|
||||
// Der Stand über das ganze Dossier, nicht nur über die Kopfdaten.
|
||||
updatedAt: await personnelDossierStand(p, akte, cap),
|
||||
templateVersion: 0,
|
||||
maxLevel: cap,
|
||||
printPath: `/personalakten/${akte.id}/druck`,
|
||||
}
|
||||
}
|
||||
|
||||
const abteilung = await loadVisibleDepartmentForPrint(p, target.departmentKey)
|
||||
if (!abteilung) throw new AccessError('not-found')
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.documentNumber,
|
||||
classification: doc.classification,
|
||||
updatedAt: doc.updatedAt,
|
||||
templateVersion: doc.templateVersion,
|
||||
printPath: `/dokumente/${doc.documentNumber}/druck`,
|
||||
id: abteilung.id,
|
||||
label: abteilung.name,
|
||||
classification: abteilung.minClearance,
|
||||
updatedAt: await departmentDossierStand(p, abteilung, cap),
|
||||
templateVersion: 0,
|
||||
maxLevel: cap,
|
||||
printPath: `/abteilungen/${abteilung.key}/druck`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +255,10 @@ export async function runExport(jobId: string): Promise<void> {
|
||||
jobId,
|
||||
userId: auftrag.requestedById,
|
||||
clearanceLevel: auftrag.clearanceAtTime,
|
||||
...(auftrag.maxLevel === null ? {} : { maxLevel: auftrag.maxLevel }),
|
||||
})
|
||||
const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}`
|
||||
const deckel = auftrag.maxLevel === null ? '' : `&stufe=${auftrag.maxLevel}`
|
||||
const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}${deckel}`
|
||||
|
||||
const antwort = await fetch(`${env.PDF_SERVICE_URL.replace(/\/$/, '')}/render`, {
|
||||
method: 'POST',
|
||||
@@ -198,6 +270,7 @@ export async function runExport(jobId: string): Promise<void> {
|
||||
headerHtml: wasserzeichen(
|
||||
nutzer?.displayName ?? auftrag.requestedById,
|
||||
auftrag.clearanceAtTime,
|
||||
auftrag.maxLevel,
|
||||
),
|
||||
footerHtml:
|
||||
'<div style="width:100%;font-size:7pt;font-family:monospace;padding:0 16mm;' +
|
||||
@@ -238,19 +311,24 @@ export async function runExport(jobId: string): Promise<void> {
|
||||
await publish(userTopic(auftrag.requestedById), 'export.ready', jobId)
|
||||
}
|
||||
|
||||
function wasserzeichen(name: string, stufe: number): string {
|
||||
function wasserzeichen(name: string, stufe: number, deckel: number | null): string {
|
||||
const sicher = name.replace(/[<>&]/g, '')
|
||||
// Der Deckel gehört auf jede Seite, nicht nur auf den Deckel: wer Blatt 40
|
||||
// in der Hand hält, soll sehen, dass der Abzug gekürzt ist.
|
||||
const bis = deckel === null || deckel >= stufe ? '' : ` · Abzug bis ${deckel}`
|
||||
return (
|
||||
'<div style="width:100%;font-size:7pt;font-family:monospace;padding:0 16mm;' +
|
||||
'display:flex;justify-content:space-between;color:#666">' +
|
||||
`<span>${sicher} · Stufe ${stufe}</span>` +
|
||||
`<span>${sicher} · Stufe ${stufe}${bis}</span>` +
|
||||
'<span class="date"></span>' +
|
||||
'</div>'
|
||||
)
|
||||
}
|
||||
|
||||
/** Aus dem gespeicherten Auftrag zurück zur Druckansicht. */
|
||||
async function druckPfad(kind: string, targetId: string | null): Promise<string | null> {
|
||||
if (!targetId) return null
|
||||
|
||||
if (kind === 'CASE') {
|
||||
const akte = await db.case.findUnique({
|
||||
where: { id: targetId },
|
||||
@@ -258,6 +336,23 @@ async function druckPfad(kind: string, targetId: string | null): Promise<string
|
||||
})
|
||||
return akte ? `/faelle/${akte.caseNumber}/druck` : null
|
||||
}
|
||||
|
||||
if (kind === 'PERSONNEL') {
|
||||
const akte = await db.personnelFile.findUnique({
|
||||
where: { id: targetId },
|
||||
select: { id: true },
|
||||
})
|
||||
return akte ? `/personalakten/${akte.id}/druck` : null
|
||||
}
|
||||
|
||||
if (kind === 'DEPARTMENT') {
|
||||
const abteilung = await db.department.findUnique({
|
||||
where: { id: targetId },
|
||||
select: { key: true },
|
||||
})
|
||||
return abteilung ? `/abteilungen/${abteilung.key}/druck` : null
|
||||
}
|
||||
|
||||
const doc = await db.document.findUnique({
|
||||
where: { id: targetId },
|
||||
select: { documentNumber: true },
|
||||
@@ -265,6 +360,14 @@ async function druckPfad(kind: string, targetId: string | null): Promise<string
|
||||
return doc ? `/dokumente/${doc.documentNumber}/druck` : null
|
||||
}
|
||||
|
||||
/** Für das Protokoll: worauf sich der Eintrag bezieht. */
|
||||
function zielArt(kind: ExportTarget['kind']): string {
|
||||
if (kind === 'CASE') return 'Case'
|
||||
if (kind === 'DOCUMENT') return 'Document'
|
||||
if (kind === 'PERSONNEL') return 'PersonnelFile'
|
||||
return 'Department'
|
||||
}
|
||||
|
||||
async function protokolliere(
|
||||
p: EffectivePermissions,
|
||||
action: 'export.pdf',
|
||||
|
||||
@@ -21,6 +21,13 @@ export type RenderClaim = {
|
||||
jobId: string
|
||||
userId: string
|
||||
clearanceLevel: number
|
||||
/**
|
||||
* Höchste Stufe im Abzug. Steht im Zeichen und nicht nur in der Adresse,
|
||||
* damit der Deckel nicht an einem Abfrageparameter hängt. Die Druckansicht
|
||||
* deckelt ihn trotzdem noch einmal auf die Stufe des Abrufers - eine
|
||||
* Adresse ist eine Eingabe.
|
||||
*/
|
||||
maxLevel?: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const caseText = {
|
||||
statusChangeHint: 'Welcher Zustand von hier aus erreichbar ist, gibt der Lebenszyklus vor.',
|
||||
outcome: 'Ergebnis',
|
||||
createdBy: 'Angelegt von',
|
||||
createdAt: 'Angelegt am',
|
||||
timeline: 'Verlauf',
|
||||
sections: 'Abschnitte',
|
||||
chat: 'Aktenchat',
|
||||
|
||||
@@ -17,6 +17,37 @@ export const exportText = {
|
||||
'Dieser Ausdruck gibt den Stand zum oben genannten Zeitpunkt wieder und ist auf die Freigabestufe des Abrufers gekürzt. Ein späterer Stand kann abweichen.',
|
||||
editedAt: (version: number, datum: string) => `bearbeitet, v${version}, ${datum}`,
|
||||
redactedAt: (stufe: number, name: string) => `Geschwärzt, Stufe ${stufe} ${name}`.trim(),
|
||||
|
||||
// --- Dossier: Personalakte oder Abteilung mit allem, was daran hängt ---
|
||||
dossier: 'Dossier',
|
||||
dossierHeading: 'Vollständiges Dossier',
|
||||
dossierIntro:
|
||||
'Die Akte selbst, dahinter alle ihre Dokumente, dahinter alle Fallakten, in denen die Person vorkommt - in derselben Darstellung wie einzeln gedruckt.',
|
||||
departmentDossierIntro:
|
||||
'Die Abteilung mit ihren Vermerken, dahinter je Mitglied ein vollständiges Dossier. Das wird umfangreich.',
|
||||
maxLevel: 'Höchste Stufe im Abzug',
|
||||
maxLevelHint:
|
||||
'Alles über dieser Stufe bleibt draussen. Wie viel das war, steht im Abzug - der Empfänger soll sehen, dass er eine gekürzte Mappe hat.',
|
||||
maxLevelCapped: 'Höher als deine eigene Freigabestufe geht nicht.',
|
||||
documentsHeading: 'Dokumente',
|
||||
casesHeading: 'Fallakten',
|
||||
membersHeading: 'Mitglieder',
|
||||
memberOf: (n: number) => (n === 1 ? '1 Mitglied' : `${n} Mitglieder`),
|
||||
noDocuments: 'Keine Dokumente in diesem Abzug.',
|
||||
noCases: 'Keine Fallakten in diesem Abzug.',
|
||||
noMembers: 'Keine Mitglieder in diesem Abzug.',
|
||||
omittedHeading: 'Nicht aufgenommen',
|
||||
omittedNote: (stufe: number) =>
|
||||
`Über der gewählten Höchststufe. Zum Abruf mit Stufe ${stufe} berechtigt, aber bewusst weggelassen.`,
|
||||
omittedRow: (stufe: number, dokumente: number, faelle: number) =>
|
||||
`Stufe ${stufe}: ${dokumente} Dokumente, ${faelle} Fallakten`,
|
||||
omittedMembers: (n: number) =>
|
||||
n === 1
|
||||
? '1 Mitglied über der gewählten Höchststufe nicht aufgenommen'
|
||||
: `${n} Mitglieder über der gewählten Höchststufe nicht aufgenommen`,
|
||||
truncated:
|
||||
'Der Abzug wurde bei der Höchstzahl je Abschnitt abgeschnitten. Enger fassen und erneut abrufen.',
|
||||
cappedAt: (stufe: number, name: string) => `Abzug bis Stufe ${stufe} ${name}`.trim(),
|
||||
fileName: 'Datei',
|
||||
fileSize: 'Bytes',
|
||||
fileHash: 'SHA-256',
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { db } from '@/lib/db'
|
||||
import {
|
||||
collectPersonnelDossier,
|
||||
departmentMembers,
|
||||
effectiveCap,
|
||||
personnelDossierStand,
|
||||
} from '@/lib/export/dossier'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { OmittedNote } from '@/components/print/DossierBody'
|
||||
import { exportCacheKey } from '@/lib/export/cacheKey'
|
||||
import { requestExport } from '@/lib/export/service'
|
||||
import { AccessError } from '@/lib/errors'
|
||||
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Das Dossier: eine Personalakte mit allem, was daran hängt.
|
||||
*
|
||||
* Zwei Grenzen wirken darin, und der ganze Wert dieser Prüfungen liegt darin,
|
||||
* sie auseinanderzuhalten:
|
||||
*
|
||||
* - Die **Freigabestufe des Abrufers** ist der Schutz. Was darüber liegt,
|
||||
* darf nicht im Abzug stehen, nicht gezählt werden und nicht als
|
||||
* weggelassen erwähnt werden - harte Regel 6.
|
||||
* - Die **gewählte Höchststufe** ist eine Entscheidung über den Umfang. Was
|
||||
* sie ausschliesst, muss im Abzug vermerkt sein, sonst hält der Empfänger
|
||||
* eine gekürzte Mappe für eine vollständige.
|
||||
*
|
||||
* Ein Deckel, der nach oben wirkt, wäre die schlimmste denkbare Verwechslung.
|
||||
* Dafür steht die erste Gruppe.
|
||||
*/
|
||||
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||
const suite = url ? describe : describe.skip
|
||||
|
||||
const marke = `dossier-${Date.now()}`
|
||||
|
||||
function perms(
|
||||
userId: string,
|
||||
clearanceLevel: number,
|
||||
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
|
||||
): EffectivePermissions {
|
||||
return {
|
||||
userId,
|
||||
clearanceLevel,
|
||||
permissions: new Set<Permission>(options.permissions ?? []),
|
||||
isAdmin: options.isAdmin ?? false,
|
||||
hasMembership: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Der Deckel wirkt nur nach unten', () => {
|
||||
it('lässt niemanden über die eigene Stufe hinaus', () => {
|
||||
// Der Kern. Wäre das je anders, wäre der Deckel ein Aufzug.
|
||||
expect(effectiveCap(perms('u', 2), 5)).toBe(2)
|
||||
expect(effectiveCap(perms('u', 0), 4)).toBe(0)
|
||||
})
|
||||
|
||||
it('nimmt eine niedrigere Wahl an', () => {
|
||||
expect(effectiveCap(perms('u', 4), 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('fällt bei Unsinn auf die eigene Stufe zurück, nicht nach oben', () => {
|
||||
expect(effectiveCap(perms('u', 3), Number.NaN)).toBe(3)
|
||||
expect(effectiveCap(perms('u', 3), -7)).toBe(0)
|
||||
})
|
||||
|
||||
it('trennt zwei Abzüge mit verschiedenem Deckel im Zwischenspeicher', () => {
|
||||
// Sonst bekäme ein Abruf bis Stufe 1 die Datei eines Abrufs bis Stufe 4.
|
||||
const basis = {
|
||||
kind: 'PERSONNEL',
|
||||
targetId: 'abc',
|
||||
updatedAt: new Date('2026-03-14T10:00:00Z'),
|
||||
clearanceLevel: 4,
|
||||
templateVersion: 0,
|
||||
}
|
||||
expect(exportCacheKey({ ...basis, maxLevel: 1 })).not.toBe(
|
||||
exportCacheKey({ ...basis, maxLevel: 4 }),
|
||||
)
|
||||
expect(exportCacheKey({ ...basis, maxLevel: 2 })).toBe(
|
||||
exportCacheKey({ ...basis, maxLevel: 2 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Der Vermerk über das Weggelassene', () => {
|
||||
/**
|
||||
* Wirklich gerendert und nicht nur gezählt: der Vermerk ist das einzige, was
|
||||
* dem Empfänger sagt, dass er eine gekürzte Mappe hält. Fehlte er im Markup,
|
||||
* wäre die Zählung dahinter wertlos.
|
||||
*/
|
||||
const markup = (inhalt: Parameters<typeof OmittedNote>[0]['inhalt'], stufe = 4) =>
|
||||
renderToStaticMarkup(createElement(OmittedNote, { inhalt, eigeneStufe: stufe }))
|
||||
|
||||
it('nennt Stufe und Anzahl', () => {
|
||||
const html = markup({
|
||||
omitted: [{ level: 3, documents: 2, cases: 1 }],
|
||||
omittedTotal: 3,
|
||||
truncated: false,
|
||||
})
|
||||
expect(html).toContain('Stufe 3')
|
||||
expect(html).toContain('2 Dokumente')
|
||||
expect(html).toContain('1 Fallakten')
|
||||
})
|
||||
|
||||
it('schweigt, wenn nichts weggelassen wurde', () => {
|
||||
// Ein Kasten "nichts weggelassen" auf jedem Abzug wäre Lärm; fehlt der
|
||||
// Kasten, ist der Abzug vollständig.
|
||||
expect(markup({ omitted: [], omittedTotal: 0, truncated: false })).toBe('')
|
||||
})
|
||||
|
||||
it('sagt es, wenn der Abzug abgeschnitten wurde', () => {
|
||||
const html = markup({ omitted: [], omittedTotal: 0, truncated: true })
|
||||
expect(html).toContain('abgeschnitten')
|
||||
})
|
||||
})
|
||||
|
||||
suite('Was ins Dossier kommt', () => {
|
||||
const abrufer = `${marke}-abrufer`
|
||||
const fremd = `${marke}-fremd`
|
||||
let personId = ''
|
||||
let abteilungId = ''
|
||||
const stufen = [1, 3, 5] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.user.createMany({
|
||||
data: [abrufer, fremd].map((id) => ({ id, username: id, displayName: id })),
|
||||
})
|
||||
|
||||
const vorlage = await db.documentTemplate.create({
|
||||
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
||||
})
|
||||
const abteilung = await db.department.create({
|
||||
data: {
|
||||
// Kürzel: Grossbuchstaben, Ziffern, Bindestrich - und je Lauf anders,
|
||||
// weil die Zeile stehen bleibt.
|
||||
key: `PRUEF-${Date.now().toString(36).toUpperCase()}`.slice(0, 21),
|
||||
name: 'Prüfabteilung',
|
||||
minClearance: 0,
|
||||
},
|
||||
})
|
||||
abteilungId = abteilung.id
|
||||
|
||||
const person = await db.personnelFile.create({
|
||||
data: { displayName: `${marke}-person`, classification: 1, departmentId: abteilung.id },
|
||||
})
|
||||
personId = person.id
|
||||
|
||||
// Je eine Personalakte, ein Dokument und eine Fallakte auf Stufe 1, 3, 5.
|
||||
for (const stufe of stufen) {
|
||||
await db.document.create({
|
||||
data: {
|
||||
documentNumber: `${marke}-D${stufe}`,
|
||||
title: `Dokument Stufe ${stufe}`,
|
||||
templateId: vorlage.id,
|
||||
templateKey: vorlage.key,
|
||||
templateVersion: 1,
|
||||
classification: stufe,
|
||||
personnelFileId: person.id,
|
||||
createdById: abrufer,
|
||||
},
|
||||
})
|
||||
|
||||
const akte = await db.case.create({
|
||||
data: {
|
||||
caseNumber: `${marke}-F${stufe}`,
|
||||
title: `Fall Stufe ${stufe}`,
|
||||
templateId: vorlage.id,
|
||||
templateKey: vorlage.key,
|
||||
classification: stufe,
|
||||
status: 'OPEN',
|
||||
createdById: abrufer,
|
||||
},
|
||||
})
|
||||
await db.caseParticipant.create({
|
||||
data: { caseId: akte.id, personnelFileId: person.id, role: 'SUBJECT' },
|
||||
})
|
||||
}
|
||||
|
||||
// Eine Fallakte ohne diese Person - sie darf nie auftauchen.
|
||||
await db.case.create({
|
||||
data: {
|
||||
caseNumber: `${marke}-Fremd`,
|
||||
title: 'Ohne die Person',
|
||||
templateId: vorlage.id,
|
||||
templateKey: vorlage.key,
|
||||
classification: 1,
|
||||
status: 'OPEN',
|
||||
createdById: abrufer,
|
||||
},
|
||||
})
|
||||
|
||||
// Mitglieder der Abteilung auf verschiedenen Stufen.
|
||||
await db.personnelFile.createMany({
|
||||
data: stufen.map((stufe) => ({
|
||||
displayName: `${marke}-mitglied-${stufe}`,
|
||||
classification: stufe,
|
||||
departmentId: abteilung.id,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
// Kein Aufräumen: Akten, Dokumente und Protokolleinträge lassen sich in
|
||||
// diesem System nicht löschen - Regeln in der Datenbank verbieten es (harte
|
||||
// Regel 2). Jeder Lauf arbeitet deshalb unter einer eigenen Marke.
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
it('nimmt alles bis zur gewählten Stufe auf', async () => {
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(abrufer, 5),
|
||||
{ id: personId, userId: null },
|
||||
3,
|
||||
)
|
||||
expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3])
|
||||
expect(inhalt.cases.map((c) => c.classification).sort()).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('vermerkt, was der Abrufer selbst weggelassen hat', async () => {
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(abrufer, 5),
|
||||
{ id: personId, userId: null },
|
||||
1,
|
||||
)
|
||||
// Stufe 3 und 5 dürfte er sehen, hat sie aber ausgeschlossen: beides muss
|
||||
// im Abzug stehen, sonst wirkt die Mappe vollständig.
|
||||
expect(inhalt.omitted).toEqual([
|
||||
{ level: 3, documents: 1, cases: 1 },
|
||||
{ level: 5, documents: 1, cases: 1 },
|
||||
])
|
||||
expect(inhalt.omittedTotal).toBe(4)
|
||||
})
|
||||
|
||||
it('zählt nicht mit, was über der eigenen Stufe liegt', async () => {
|
||||
// Der wichtigste negative Fall: Stufe 3 wählt Deckel 3. Was auf Stufe 5
|
||||
// liegt, darf weder im Abzug stehen noch als weggelassen gezählt werden -
|
||||
// die Zahl allein verriete, dass es etwas gibt.
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(abrufer, 3),
|
||||
{ id: personId, userId: null },
|
||||
3,
|
||||
)
|
||||
expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3])
|
||||
expect(inhalt.omitted).toEqual([])
|
||||
expect(inhalt.omittedTotal).toBe(0)
|
||||
})
|
||||
|
||||
it('zählt auch dann nichts Höheres, wenn der Deckel niedriger liegt', async () => {
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(abrufer, 3),
|
||||
{ id: personId, userId: null },
|
||||
1,
|
||||
)
|
||||
// Nur Stufe 3 fehlt sichtbar. Stufe 5 bleibt unerwähnt.
|
||||
expect(inhalt.omitted).toEqual([{ level: 3, documents: 1, cases: 1 }])
|
||||
})
|
||||
|
||||
it('nimmt keine Fallakte auf, in der die Person nicht vorkommt', async () => {
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(abrufer, 5),
|
||||
{ id: personId, userId: null },
|
||||
5,
|
||||
)
|
||||
expect(inhalt.cases.map((c) => c.caseNumber)).not.toContain(`${marke}-Fremd`)
|
||||
expect(inhalt.cases).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('gibt einem Abrufer ohne Stufe gar nichts', async () => {
|
||||
const inhalt = await collectPersonnelDossier(
|
||||
perms(fremd, 0),
|
||||
{ id: personId, userId: null },
|
||||
5,
|
||||
)
|
||||
expect(inhalt.documents).toEqual([])
|
||||
expect(inhalt.cases).toEqual([])
|
||||
expect(inhalt.omitted).toEqual([])
|
||||
})
|
||||
|
||||
it('führt den Stand über das ganze Dossier, nicht nur über die Kopfdaten', async () => {
|
||||
const akte = await db.personnelFile.findUniqueOrThrow({ where: { id: personId } })
|
||||
const vorher = await personnelDossierStand(
|
||||
perms(abrufer, 5),
|
||||
{ id: personId, userId: null, updatedAt: akte.updatedAt },
|
||||
5,
|
||||
)
|
||||
|
||||
// Ein Dokument ändern, die Personalakte nicht anfassen.
|
||||
await db.document.update({
|
||||
where: { documentNumber: `${marke}-D1` },
|
||||
data: { title: 'Geändert' },
|
||||
})
|
||||
|
||||
const nachher = await personnelDossierStand(
|
||||
perms(abrufer, 5),
|
||||
{ id: personId, userId: null, updatedAt: akte.updatedAt },
|
||||
5,
|
||||
)
|
||||
// Ohne das gäbe der Zwischenspeicher den alten Abzug heraus.
|
||||
expect(nachher.getTime()).toBeGreaterThan(vorher.getTime())
|
||||
})
|
||||
|
||||
it('lässt Mitglieder über der eigenen Stufe aus der Abteilung verschwinden', async () => {
|
||||
const { members, omittedMembers } = await departmentMembers(perms(abrufer, 3), abteilungId, 3)
|
||||
|
||||
const stufenDerMitglieder = members.map((m) => m.classification).sort()
|
||||
expect(stufenDerMitglieder).toEqual([1, 1, 3])
|
||||
// Das Mitglied auf Stufe 5 wird auch nicht gezählt.
|
||||
expect(omittedMembers).toBe(0)
|
||||
})
|
||||
|
||||
it('zählt Mitglieder, die der Abrufer weggelassen hat', async () => {
|
||||
const { members, omittedMembers } = await departmentMembers(perms(abrufer, 5), abteilungId, 1)
|
||||
expect(members.map((m) => m.classification).sort()).toEqual([1, 1])
|
||||
expect(omittedMembers).toBe(2)
|
||||
})
|
||||
|
||||
it('lädt die vollständigen Kopfdaten der Mitglieder', async () => {
|
||||
// Sonst stünde im Abteilungsabzug bei jedem "Kein Status gesetzt", auch bei
|
||||
// jemandem, der einen trägt.
|
||||
const { members } = await departmentMembers(perms(abrufer, 5), abteilungId, 5)
|
||||
for (const mitglied of members) {
|
||||
expect(mitglied).toHaveProperty('statusTags')
|
||||
expect(mitglied).toHaveProperty('note')
|
||||
expect(mitglied.createdAt).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('Der Auftrag selbst', () => {
|
||||
const abrufer = `${marke}-auftrag`
|
||||
let personId = ''
|
||||
let hochId = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.user.create({ data: { id: abrufer, username: abrufer, displayName: abrufer } })
|
||||
const person = await db.personnelFile.create({
|
||||
data: { displayName: `${marke}-auftrag-person`, classification: 1 },
|
||||
})
|
||||
personId = person.id
|
||||
const hoch = await db.personnelFile.create({
|
||||
data: { displayName: `${marke}-auftrag-hoch`, classification: 4 },
|
||||
})
|
||||
hochId = hoch.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
it('legt einen Auftrag mit gedeckelter Höchststufe an', async () => {
|
||||
// Angefragt wird 5, erlaubt ist 2. Im Auftrag muss 2 stehen - und nicht 5,
|
||||
// auch nicht "erstmal 5 und später gedeckelt".
|
||||
const auftrag = await requestExport(perms(abrufer, 2), {
|
||||
kind: 'PERSONNEL',
|
||||
personnelFileId: personId,
|
||||
maxLevel: 5,
|
||||
})
|
||||
|
||||
const zeile = await db.exportJob.findUniqueOrThrow({ where: { id: auftrag.jobId } })
|
||||
expect(zeile.kind).toBe('PERSONNEL')
|
||||
expect(zeile.maxLevel).toBe(2)
|
||||
expect(zeile.clearanceAtTime).toBe(2)
|
||||
})
|
||||
|
||||
it('protokolliert den Abzug mit seinem Deckel', async () => {
|
||||
await requestExport(perms(abrufer, 3), {
|
||||
kind: 'PERSONNEL',
|
||||
personnelFileId: personId,
|
||||
maxLevel: 1,
|
||||
})
|
||||
|
||||
const eintrag = await db.auditLog.findFirst({
|
||||
where: { actorId: abrufer, action: 'export.pdf', targetType: 'PersonnelFile' },
|
||||
orderBy: { at: 'desc' },
|
||||
})
|
||||
// Ohne den Deckel im Protokoll steht dort nicht, wie viel das Haus
|
||||
// verlassen hat.
|
||||
expect(eintrag?.meta).toMatchObject({ kind: 'PERSONNEL', maxLevel: 1 })
|
||||
})
|
||||
|
||||
it('weist ab, wer die Stufe der Personalakte nicht hat', async () => {
|
||||
// Lesen darf man eine Akte unter Umständen auch als Zugeordneter. Ausser
|
||||
// Haus geben nicht.
|
||||
await expect(
|
||||
requestExport(perms(abrufer, 2), {
|
||||
kind: 'PERSONNEL',
|
||||
personnelFileId: hochId,
|
||||
maxLevel: 2,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
})
|
||||
|
||||
it('gibt es nicht, wenn der Abrufer die Akte gar nicht sehen darf', async () => {
|
||||
// 404 statt 403: dass es die Akte gibt, geht ihn nichts an.
|
||||
await expect(
|
||||
requestExport(perms(abrufer, 0), {
|
||||
kind: 'PERSONNEL',
|
||||
personnelFileId: hochId,
|
||||
maxLevel: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'not-found' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user