diff --git a/packages/db/prisma/migrations/20260904160000_dossier/migration.sql b/packages/db/prisma/migrations/20260904160000_dossier/migration.sql new file mode 100644 index 0000000..1e98af3 --- /dev/null +++ b/packages/db/prisma/migrations/20260904160000_dossier/migration.sql @@ -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"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a81c061..365451a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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' diff --git a/web/app/(app)/abteilungen/[key]/export/loading.tsx b/web/app/(app)/abteilungen/[key]/export/loading.tsx new file mode 100644 index 0000000..3b1b469 --- /dev/null +++ b/web/app/(app)/abteilungen/[key]/export/loading.tsx @@ -0,0 +1,15 @@ +/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */ +export default function Loading() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/web/app/(app)/abteilungen/[key]/export/page.tsx b/web/app/(app)/abteilungen/[key]/export/page.tsx new file mode 100644 index 0000000..b0fe52d --- /dev/null +++ b/web/app/(app)/abteilungen/[key]/export/page.tsx @@ -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 ( +
+

{de.export.dossierHeading}

+

{de.export.departmentDossierIntro}

+ +
+ +
+ +

+ + {de.export.print} + +

+
+ ) +} diff --git a/web/app/(app)/abteilungen/[key]/page.tsx b/web/app/(app)/abteilungen/[key]/page.tsx index 6398c7e..e79745a 100644 --- a/web/app/(app)/abteilungen/[key]/page.tsx +++ b/web/app/(app)/abteilungen/[key]/page.tsx @@ -68,7 +68,12 @@ export default async function DepartmentPage({ params }: { params: Promise<{ key ) : null}
-

{abteilung.name}

+
+

{abteilung.name}

+ + {de.export.dossier} + +
{abteilung.description ? (

{abteilung.description} diff --git a/web/app/(app)/export/actions.ts b/web/app/(app)/export/actions.ts index 6a82d29..d287cea 100644 --- a/web/app/(app)/export/actions.ts +++ b/web/app/(app)/export/actions.ts @@ -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), + }), ]) /** diff --git a/web/app/(app)/personalakten/[id]/export/loading.tsx b/web/app/(app)/personalakten/[id]/export/loading.tsx new file mode 100644 index 0000000..3b1b469 --- /dev/null +++ b/web/app/(app)/personalakten/[id]/export/loading.tsx @@ -0,0 +1,15 @@ +/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */ +export default function Loading() { + return ( +

+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/web/app/(app)/personalakten/[id]/export/page.tsx b/web/app/(app)/personalakten/[id]/export/page.tsx new file mode 100644 index 0000000..f555504 --- /dev/null +++ b/web/app/(app)/personalakten/[id]/export/page.tsx @@ -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 ( +
+

{de.export.dossierHeading}

+

{de.export.dossierIntro}

+ +
+ +
+ +

+ + {de.export.print} + +

+
+ ) +} diff --git a/web/app/(app)/personalakten/[id]/page.tsx b/web/app/(app)/personalakten/[id]/page.tsx index c2220c5..2b33506 100644 --- a/web/app/(app)/personalakten/[id]/page.tsx +++ b/web/app/(app)/personalakten/[id]/page.tsx @@ -234,7 +234,14 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id: {de.personnel.one}
-

{akte.displayName}

+
+

{akte.displayName}

+ {/* Der Abzug ist kein Nebenweg der Akte, sondern eine eigene Seite: + die Wahl der Höchststufe will erklärt werden. */} + + {de.export.dossier} + +
diff --git a/web/app/(print)/abteilungen/[key]/druck/page.tsx b/web/app/(print)/abteilungen/[key]/druck/page.tsx new file mode 100644 index 0000000..67f6cee --- /dev/null +++ b/web/app/(print)/abteilungen/[key]/druck/page.tsx @@ -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 + const { permissions, displayName } = zugang + + const abteilung = await loadVisibleDepartmentForPrint(permissions, key) + if (!abteilung) return + + 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 ( +
+
+ + {abteilung.minClearance} ·{' '} + {levelName.get(abteilung.minClearance) ?? de.common.unknown} + +

{abteilung.name}

+

{abteilung.key}

+ + + + + + + + + + + + + + + + +
{de.department.members}{de.department.memberCount(members.length)}
{de.export.retrievedBy}{displayName}
{de.export.asOf} + {new Date().toLocaleString('de-DE')} ·{' '} + {de.export.cappedAt(cap, levelName.get(cap) ?? '')} +
+ + {abteilung.description ? ( +

{abteilung.description}

+ ) : null} + +

+ {de.export.coverNote} +

+
+ + {omittedMembers > 0 ? ( +
+

{de.export.omittedHeading}

+

+ {de.export.omittedMembers(omittedMembers)} +

+
+ ) : null} + +

{de.department.entries}

+ {vermerke.length === 0 ? ( +

{de.department.entriesEmpty}

+ ) : ( + vermerke.map((vermerk) => ( +
+

+ {nameVon.get(vermerk.authorId) ?? vermerk.authorId} ·{' '} + {vermerk.createdAt.toLocaleString('de-DE')} + {vermerk.editedAt + ? ` · ${de.export.editedAt(vermerk.version, vermerk.editedAt.toLocaleDateString('de-DE'))}` + : ''} +

+ {vermerk.retractedAt ? ( +

+ {de.entry.retracted( + vermerk.retractedAt.toLocaleDateString('de-DE'), + nameVon.get(vermerk.authorId) ?? vermerk.authorId, + vermerk.retractionReason ?? '', + )} +

+ ) : ( +

{vermerk.body}

+ )} +
+ )) + )} + +

{de.export.membersHeading}

+ {members.length === 0 ? ( +

{de.export.noMembers}

+ ) : ( + members.map((mitglied) => ( +
+ +
+ )) + )} +
+ ) +} diff --git a/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx index de52415..e957c9d 100644 --- a/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx +++ b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx @@ -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 - const { permissions, displayName } = zugang - const doc = await loadVisibleDocument(permissions, documentNumber) + const doc = await loadVisibleDocument(zugang.permissions, documentNumber) if (!doc) return - 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 ( - - ) - } - return (
-
- - {doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown} - -

{doc.title}

-

- {doc.documentNumber} -

- - - - - - - - - - - - - - - - - - - - -
{de.case.status}{de.document.statusNames[doc.status] ?? doc.status}
{de.document.kind}{doc.templateKey}
{de.export.retrievedBy}{displayName}
{de.export.asOf} - {new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '} - {permissions.clearanceLevel} -
-
- - {doc.retractedAt ? ( -

- {de.document.statusNames.RETRACTED} -

- ) : null} - - {sections.map((section) => { - const field = section.field - - if (!isInputField(field)) { - if (field.type === 'pagebreak') { - return
- } - const text = fillPlaceholders(field.text ?? '', werte) - return field.type === 'heading' ? ( -

{text || field.label}

- ) : ( -

- {text} -

- ) - } - - if (field.type === 'signature') { - return ( -
-
-

{fillPlaceholders(field.label, werte)}

-
- ) - } - - return ( -
-

{field.label}

- {!section.visible ? ( -

-  {' '} - - {de.export.redactedAt( - section.value.requiredLevel, - levelName.get(section.value.requiredLevel) ?? '', - )} - -

- ) : ( -

- {String(section.value ?? '-')} -

- )} -
- ) - })} +
) } diff --git a/web/app/(print)/faelle/[caseNumber]/druck/page.tsx b/web/app/(print)/faelle/[caseNumber]/druck/page.tsx index 9dc190d..980ba72 100644 --- a/web/app/(print)/faelle/[caseNumber]/druck/page.tsx +++ b/web/app/(print)/faelle/[caseNumber]/druck/page.tsx @@ -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 - const { permissions, displayName } = zugang - const akte = await loadVisibleCase(permissions, caseNumber) + const akte = await loadVisibleCase(zugang.permissions, caseNumber) if (!akte) return - 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 (
-
- - {akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown} - -

{akte.title}

-

{akte.caseNumber}

- - - - - - - - - - -
- -

- {de.export.coverNote} -

-
- -

{de.section.heading}

- - -

{de.case.timeline}

- {entries.length === 0 ? ( -

{de.entry.empty}

- ) : ( - entries.map((eintrag) => ( -
-

- {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'))}` - : ''} -

- {eintrag.retractedAt ? ( -

- {de.entry.retracted( - eintrag.retractedAt.toLocaleDateString('de-DE'), - nameVon.get(eintrag.authorId) ?? eintrag.authorId, - eintrag.retractionReason ?? '', - )} -

- ) : ( -

{eintrag.body}

- )} -
- )) - )} - -

{de.attachment.heading}

- {anhaenge.length === 0 ? ( -

{de.attachment.empty}

- ) : ( - - - - - - - - - - {anhaenge.map((anhang) => ( - - - - {/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck - gehört - deshalb steht er im Verzeichnis. */} - - - ))} - -
{de.export.fileName}{de.export.fileSize}{de.export.fileHash}
{anhang.originalName}{anhang.sizeBytes} - {anhang.sha256} -
- )} +
) } - -function Zeile({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ) -} - -/** - * 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 -}) { - const werte = Object.fromEntries( - sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]), - ) - - return ( -
- {sections.map((section) => { - const field = section.field - - if (!isInputField(field)) { - if (field.type === 'pagebreak') { - return
- } - const text = fillPlaceholders(field.text ?? '', werte) - return field.type === 'heading' ? ( -

{text || field.label}

- ) : ( -

- {text} -

- ) - } - - return ( -
-

{field.label}

- {!section.visible ? ( -

-  {' '} - - {de.export.redactedAt( - section.value.requiredLevel, - levelName.get(section.value.requiredLevel) ?? '', - )} - -

- ) : section.segments ? ( -

- {section.segments.map((segment, index) => - segment.kind === 'text' ? ( - {segment.text} - ) : ( - // Balken statt Auslassung, mit der Stufe daneben: der - // Empfaenger soll sehen, dass hier etwas fehlt. - - -   - - [{segment.requiredLevel}] - - ), - )} -

- ) : ( -

- {String(section.value ?? '-')} -

- )} -
- ) - })} -
- ) -} diff --git a/web/app/(print)/personalakten/[id]/druck/page.tsx b/web/app/(print)/personalakten/[id]/druck/page.tsx new file mode 100644 index 0000000..d072a88 --- /dev/null +++ b/web/app/(print)/personalakten/[id]/druck/page.tsx @@ -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 + 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 + + const cap = effectiveCap(permissions, Number(stufe ?? permissions.clearanceLevel)) + + return ( +
+ +
+ ) +} diff --git a/web/app/globals.css b/web/app/globals.css index 401eaa2..4579f7a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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; diff --git a/web/components/export/ExportPanel.tsx b/web/components/export/ExportPanel.tsx index aa5065f..6a68f5e 100644 --- a/web/components/export/ExportPanel.tsx +++ b/web/components/export/ExportPanel.tsx @@ -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( requestExportAction, @@ -51,10 +65,35 @@ export function ExportPanel({ {target.kind === 'CASE' ? ( - ) : ( + ) : target.kind === 'DOCUMENT' ? ( + ) : target.kind === 'PERSONNEL' ? ( + + ) : ( + )} + {levels ? ( +
+ + +

{exportText.maxLevelHint}

+
+ ) : null} +