Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import type { Metadata } from 'next'
|
|
import { Suspense } from 'react'
|
|
import Link from 'next/link'
|
|
import { notFound } from 'next/navigation'
|
|
import { db } from '@/lib/db'
|
|
import { requireUser } from '@/lib/auth/current'
|
|
import {
|
|
caseTypesOfDepartment,
|
|
listDepartmentCases,
|
|
listDepartmentDocuments,
|
|
listMembers,
|
|
loadVisibleDepartment,
|
|
} from '@/lib/departments/service'
|
|
import { listEntries } from '@/lib/departments/entries'
|
|
import { loadChatView } from '@/lib/chat/load'
|
|
import { de } from '@/lib/i18n/de'
|
|
import { PieChart } from '@/components/PieChart'
|
|
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
|
import { SkeletonLine } from '@/components/Skeleton'
|
|
import { ChatLazy } from '@/components/chat/ChatLazy'
|
|
import { DepartmentEntries } from './DepartmentEntries'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ key: string }>
|
|
}): Promise<Metadata> {
|
|
const user = await requireUser()
|
|
const { key } = await params
|
|
const abteilung = await loadVisibleDepartment(user.permissions, key)
|
|
return { title: abteilung?.name ?? de.department.one }
|
|
}
|
|
|
|
/**
|
|
* Eine Abteilung.
|
|
*
|
|
* Dasselbe Bild wie eine Personalakte, eine Stufe höher: wer gehört dazu, was
|
|
* ist dazu aktenkundig, was ist dazu vermerkt, und worüber wird geredet. Der
|
|
* Unterschied ist, dass hier **nichts eingetragen** wird - die Fälle ergeben
|
|
* sich aus den Beteiligten und deren Personalakten.
|
|
*/
|
|
export default async function DepartmentPage({ params }: { params: Promise<{ key: string }> }) {
|
|
const user = await requireUser()
|
|
const { key } = await params
|
|
|
|
const abteilung = await loadVisibleDepartment(user.permissions, key)
|
|
// Kein Zugriff heisst 404, nicht 403.
|
|
if (!abteilung) notFound()
|
|
|
|
const [levels, verteilung] = await Promise.all([
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
caseTypesOfDepartment(user.permissions, abteilung.id),
|
|
])
|
|
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
|
|
|
return (
|
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
|
<header>
|
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
|
<span className="font-mono text-xs text-text-dim">{abteilung.key}</span>
|
|
<span className="font-condensed text-2xs text-text-dim">{de.department.one}</span>
|
|
{abteilung.isArchived ? (
|
|
<span className="font-condensed text-2xs text-text-dim">
|
|
{de.department.archived}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<h1 className="mt-2 font-condensed">{abteilung.name}</h1>
|
|
{abteilung.description ? (
|
|
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
|
|
{abteilung.description}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
|
|
<section className="mt-8 border border-line p-5">
|
|
<h2 className="font-condensed text-xs text-text-dim">{de.department.chart}</h2>
|
|
<div className="mt-4">
|
|
<PieChart
|
|
slices={verteilung}
|
|
emptyLabel={de.department.chartEmpty}
|
|
totalLabel={de.department.chartTotal}
|
|
/>
|
|
</div>
|
|
<p className="mt-4 max-w-(--container-measure) text-2xs text-text-dim">
|
|
{de.department.derivedHint}
|
|
</p>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.department.members}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="50%" />}>
|
|
<Members
|
|
departmentId={abteilung.id}
|
|
permissions={user.permissions}
|
|
levelName={levelName}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.department.cases}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="60%" />}>
|
|
<Cases
|
|
departmentId={abteilung.id}
|
|
permissions={user.permissions}
|
|
levelName={levelName}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.department.documents}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="55%" />}>
|
|
<Documents departmentId={abteilung.id} permissions={user.permissions} />
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.departmentEntry.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="60%" />}>
|
|
<Entries
|
|
departmentKey={abteilung.key}
|
|
departmentId={abteilung.id}
|
|
user={user}
|
|
levels={levels}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.chat.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense fallback={<SkeletonLine width="60%" />}>
|
|
<ChatBlock departmentId={abteilung.id} user={user} />
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
async function Members({
|
|
departmentId,
|
|
permissions,
|
|
levelName,
|
|
}: {
|
|
departmentId: string
|
|
permissions: EffectivePermissions
|
|
levelName: Map<number, string>
|
|
}) {
|
|
const mitglieder = await listMembers(permissions, departmentId)
|
|
if (mitglieder.length === 0) {
|
|
return <p className="text-text-dim">{de.department.membersEmpty}</p>
|
|
}
|
|
|
|
return (
|
|
<ul className="border-t border-line">
|
|
{mitglieder.map((person) => (
|
|
<li key={person.id} className="border-b border-line">
|
|
<Link
|
|
href={`/personalakten/${person.id}`}
|
|
className="record-row py-2 pl-3 text-text no-underline"
|
|
style={classificationEdgeStyle(person.classification)}
|
|
>
|
|
<span className="record-rank">{person.rank ? `[${person.rank}]` : ''}</span>
|
|
<span className="record-title">{person.displayName}</span>
|
|
<StampMark
|
|
level={person.classification}
|
|
name={levelName.get(person.classification) ?? de.common.unknown}
|
|
/>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
async function Cases({
|
|
departmentId,
|
|
permissions,
|
|
levelName,
|
|
}: {
|
|
departmentId: string
|
|
permissions: EffectivePermissions
|
|
levelName: Map<number, string>
|
|
}) {
|
|
const faelle = await listDepartmentCases(permissions, departmentId)
|
|
if (faelle.length === 0) return <p className="text-text-dim">{de.department.casesEmpty}</p>
|
|
|
|
return (
|
|
<ul className="border-t border-line">
|
|
{faelle.map((akte) => (
|
|
<li key={akte.id} className="border-b border-line">
|
|
<Link
|
|
href={`/faelle/${akte.caseNumber}`}
|
|
className="record-row py-2 pl-3 text-text no-underline"
|
|
style={classificationEdgeStyle(akte.classification)}
|
|
>
|
|
<span className="record-key">{akte.caseNumber}</span>
|
|
<span className="record-title">{akte.title}</span>
|
|
<span className="record-meta">
|
|
{de.case.statusNames[akte.status] ?? akte.status}
|
|
</span>
|
|
<StampMark
|
|
level={akte.classification}
|
|
name={levelName.get(akte.classification) ?? de.common.unknown}
|
|
/>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
async function Documents({
|
|
departmentId,
|
|
permissions,
|
|
}: {
|
|
departmentId: string
|
|
permissions: EffectivePermissions
|
|
}) {
|
|
const dokumente = await listDepartmentDocuments(permissions, departmentId)
|
|
if (dokumente.length === 0) {
|
|
return <p className="text-text-dim">{de.department.documentsEmpty}</p>
|
|
}
|
|
|
|
return (
|
|
<ul className="border-t border-line">
|
|
{dokumente.map((dokument) => (
|
|
<li key={dokument.id} className="border-b border-line">
|
|
<Link
|
|
href={`/dokumente/${dokument.documentNumber}`}
|
|
className="record-row py-2 text-text no-underline"
|
|
>
|
|
<span className="record-key">{dokument.documentNumber}</span>
|
|
<span className="record-title">{dokument.title}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
async function Entries({
|
|
departmentKey,
|
|
departmentId,
|
|
user,
|
|
levels,
|
|
}: {
|
|
departmentKey: string
|
|
departmentId: string
|
|
user: { id: string; permissions: EffectivePermissions }
|
|
levels: { level: number; name: string }[]
|
|
}) {
|
|
const vermerke = await listEntries(user.permissions, departmentId)
|
|
const autoren = await db.user.findMany({
|
|
where: { id: { in: [...new Set(vermerke.flatMap((v) => [v.authorId, v.retractedById ?? ''])) ].filter(Boolean) } },
|
|
select: { id: true, displayName: true },
|
|
})
|
|
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
|
|
|
|
return (
|
|
<DepartmentEntries
|
|
departmentKey={departmentKey}
|
|
currentUserId={user.id}
|
|
canWrite={user.permissions.isAdmin || user.permissions.permissions.has('entry.create')}
|
|
canRetract={user.permissions.isAdmin || user.permissions.permissions.has('entry.retract')}
|
|
canEditAny={user.permissions.isAdmin || user.permissions.permissions.has('entry.edit_any')}
|
|
canEditOwn={user.permissions.isAdmin || user.permissions.permissions.has('entry.edit_own')}
|
|
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
|
|
entries={vermerke.map((vermerk) => ({
|
|
id: vermerk.id,
|
|
body: vermerk.body,
|
|
minClearance: vermerk.minClearance,
|
|
authorId: vermerk.authorId,
|
|
authorName: nameVon.get(vermerk.authorId) ?? vermerk.authorId,
|
|
createdAt: vermerk.createdAt.toISOString(),
|
|
version: vermerk.version,
|
|
editedAt: vermerk.editedAt?.toISOString() ?? null,
|
|
retractedAt: vermerk.retractedAt?.toISOString() ?? null,
|
|
retractedByName: vermerk.retractedById
|
|
? (nameVon.get(vermerk.retractedById) ?? vermerk.retractedById)
|
|
: null,
|
|
retractionReason: vermerk.retractionReason,
|
|
}))}
|
|
/>
|
|
)
|
|
}
|
|
|
|
async function ChatBlock({
|
|
departmentId,
|
|
user,
|
|
}: {
|
|
departmentId: string
|
|
user: { id: string; permissions: EffectivePermissions }
|
|
}) {
|
|
const ansicht = await loadChatView(user.permissions, {
|
|
kind: 'department',
|
|
departmentId,
|
|
})
|
|
|
|
return (
|
|
<ChatLazy
|
|
scope={{ kind: 'department', departmentId }}
|
|
topic={`department:${departmentId}`}
|
|
initial={ansicht.messages}
|
|
initialReactions={ansicht.reactions}
|
|
currentUserId={user.id}
|
|
lastReadAt={ansicht.lastReadAt}
|
|
canAdopt={false}
|
|
mentionNames={ansicht.mentionNames}
|
|
/>
|
|
)
|
|
}
|