Add print cover fields and office profile

- Project settings gain siteAddress, workStage, projectLead and
  projectEditor (project.update-settings schema version 3, migration 0009);
  versions 2 and 1 stay executable and keep the current cover values.
  Snapshot schema 6 carries the fields; older versions upgrade with null.
- Global office profile (office name/address) next to the document logo:
  table document_branding_settings, PUT /api/document-branding/office,
  editable from the print view toolbar.
- Cover page reads all fields; empty values show the "[ergänzen]" marker.
  The Planstände editor column shows the project editor.
- Shared logo constants moved to a dependency-free module so the Next.js
  bundler can import them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-25 20:42:44 +02:00
parent 4779e4a1a7
commit 7692699ee2
35 changed files with 3479 additions and 113 deletions

View file

@ -251,10 +251,11 @@ snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 6 additionally stores the distribution-board Current snapshot schema version 6 additionally stores the distribution-board
info fields `boardNumber`, `location`, `fedFrom` and `remark`; all older info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project
supported versions are upgraded with `null` for these fields. Stored named and cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`;
automatic snapshots of every supported version (1 to 6) remain restorable; all older supported versions are upgraded with `null` for these fields.
they are upgraded while parsing. Version 5 stores Stored named and automatic snapshots of every supported version (1 to 6)
remain restorable; they are upgraded while parsing. Version 5 stores
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed `manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3 external-model source, import batches, room mappings and objects. Version 3
@ -372,6 +373,10 @@ Project settings use the persistent `project.update-settings` command. Project
metadata, the public-building classification, both voltage defaults and the metadata, the public-building classification, both voltage defaults and the
enabled distribution-board supply types change in one revision and Undo/Redo enabled distribution-board supply types change in one revision and Undo/Redo
restores them together; the project PUT route requires `expectedRevision`. restores them together; the project PUT route requires `expectedRevision`.
Schema version 3 additionally carries the print cover fields `siteAddress`,
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
(without them) and baseline version 1 stay executable and keep the current
cover values. The PUT route accepts the cover fields optionally.
The public-building flag is the future sizing input for requiring halogen-free The public-building flag is the future sizing input for requiring halogen-free
cables and lines. The system catalog is `AV`, `SV`, `EV`, cables and lines. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board `USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
@ -475,12 +480,15 @@ Required operations:
- The board hierarchy and "Σ inkl. unterlagert" in the overview are - The board hierarchy and "Σ inkl. unterlagert" in the overview are
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum, PRELIMINARY (text match of `fedFrom` against board number/name, plain sum,
no upstream simultaneity factor). Replace this once the feed link is modeled. no upstream simultaneity factor). Replace this once the feed link is modeled.
- Colors/fonts are the provisional kbp CD (`--pd-*` variables) until the - Colors/fonts follow the kbp corporate design (`--pd-*` variables in
official CD specification exists. Cover fields shown as "[ergänzen]" `print-document.css`). Empty cover values are shown as
(address, Leistungsphase, office staff) have no data model yet. "[ergänzen]"; the Planstände editor column shows the project's current
- The document logo is global office branding: table `projectEditor` because snapshots do not record an editor.
`document_branding_assets` (migration `0007`), endpoints - The document logo and the office profile (office name/address on the
`GET|PUT|DELETE /api/document-branding/logo` and `GET /api/document-branding`. cover) are global office branding: tables `document_branding_assets`
(migration `0007`) and `document_branding_settings` (migration `0008`),
endpoints `GET|PUT|DELETE /api/document-branding/logo`,
`PUT /api/document-branding/office` and `GET /api/document-branding`.
It is intentionally outside project revisions, snapshots and transfer. It is intentionally outside project revisions, snapshots and transfer.
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
is served with `nosniff` and a restrictive CSP. is served with `nosniff` and a restrictive CSP.
@ -583,8 +591,6 @@ Users must be able to override sizing suggestions.
- logical feed link between distribution boards (`fedFrom` stays free text - logical feed link between distribution boards (`fedFrom` stays free text
until the project owner decides whether it references a board, a feeding until the project owner decides whether it references a board, a feeding
circuit or both) and the resulting hierarchical power summation circuit or both) and the resulting hierarchical power summation
- data model for the remaining print cover fields (address, Leistungsphase,
project lead/editor) and the official kbp CD values
Do not implement these while working on an unrelated phase. Do not implement these while working on an unrelated phase.

View file

@ -74,8 +74,9 @@ Protected-board structure uses
persisted inverse of a delete. The dispatcher also supports the documented persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`. commands. The generic editor commands listed here use schema version `1`.
`project.update-settings` uses version `2` and still accepts supported baseline `project.update-settings` uses version `3` (including the print cover fields)
version `1` commands without changing the newer building classification. and still accepts version `2` and supported baseline version `1` commands
without changing the newer cover fields or building classification.
Pre-baseline formats remain unsupported. Unsupported types or schema versions Pre-baseline formats remain unsupported. Unsupported types or schema versions
are rejected. Insert commands contain the complete entity or circuit block are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit with stable ids; delete commands include the expected parent identity. Circuit
@ -173,6 +174,9 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId` - `PUT /projects/:projectId`
- request: - request:
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` `{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- optional print cover fields `siteAddress` (max. 500), `workStage` (max.
100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep
their value, empty strings are stored as `null`
- executes `project.update-settings` as one atomic revision - executes `project.update-settings` as one atomic revision
- response: - response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }` `{ "project": { ... }, "revision": { ... }, "history": { ... } }`
@ -437,7 +441,7 @@ Office-wide branding for the print view. These endpoints are mounted at
and intentionally not part of project revisions, snapshots or transfer. and intentionally not part of project revisions, snapshots or transfer.
- `GET /document-branding` - `GET /document-branding`
- returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null }` - returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null, "office": { "officeName", "officeAddress" } }`
- `GET /document-branding/logo` - `GET /document-branding/logo`
- returns the stored image bytes with its media type, or `404` without logo - returns the stored image bytes with its media type, or `404` without logo
- responses carry `X-Content-Type-Options: nosniff` and a restrictive - responses carry `X-Content-Type-Options: nosniff` and a restrictive
@ -449,3 +453,7 @@ and intentionally not part of project revisions, snapshots or transfer.
- replaces an existing logo; returns the logo metadata - replaces an existing logo; returns the logo metadata
- `DELETE /document-branding/logo` - `DELETE /document-branding/logo`
- removes the logo; returns `204` - removes the logo; returns `204`
- `PUT /document-branding/office`
- request: `{ "officeName": "…" | null, "officeAddress": "…" | null }`
(at most 200 / 500 characters; empty text is stored as not set)
- returns `{ "office": { "officeName", "officeAddress" } }`

View file

@ -31,7 +31,8 @@
supply structure and "Σ inkl. unterlagert" only from exact text matches with supply structure and "Σ inkl. unterlagert" only from exact text matches with
board numbers/names and adds values without an upstream simultaneity factor board numbers/names and adds values without an upstream simultaneity factor
(see `docs/print-view.md`). (see `docs/print-view.md`).
- The print view cover page shows placeholders for address, Leistungsphase and - The print view uses a kbp corporate design derived from logo and
office staff, and uses a provisional kbp corporate design. On screen, long presentation material. Snapshots do not record an editor, so the cover's
Planstände list shows the project's current editor for every entry. On screen, long
board plans are not split into pages; pagination happens in the browser print board plans are not split into pages; pagination happens in the browser print
dialog. dialog.

View file

@ -232,8 +232,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert Projektname, interne und externe `project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
@ -421,7 +422,8 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
Snapshot-Schema 6 enthält diese Felder; ältere Versionen werden mit `null` Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
Projekts; ältere Versionen werden mit `null`
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand enthält bereits den vollständigen externen Modellzustand

View file

@ -1,7 +1,6 @@
# Seitenansicht und PDF-Export # Seitenansicht und PDF-Export
Stand: erste Ausbaustufe (Beispiel zur Abstimmung). Die Seitenansicht ist Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
rein lesend und verändert keine Projektdaten.
## Aufruf ## Aufruf
@ -12,14 +11,15 @@ rein lesend und verändert keine Projektdaten.
wird als Dateiname vorgeschlagen. wird als Dateiname vorgeschlagen.
- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um. - **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um.
Deckblatt bleibt A4 hoch. Deckblatt bleibt A4 hoch.
- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle
Projekte).
- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo. - **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo.
## Seiten ## Seiten
1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument, 1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument,
Planstände (aus benannten Sicherungspunkten) und Inhalt. Angaben ohne Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben
Datenmodell erscheinen als orangefarbenes „[ergänzen]“ (Anschrift, erscheinen als orangefarbenes „[ergänzen]“.
Leistungsphase, Projektleitung, Bearbeitung).
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je 2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF, Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
@ -51,8 +51,34 @@ Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
- **Corporate Design:** Farben (`--pd-*` in - **Corporate Design:** Farben (`--pd-*` in
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo `src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
CD-Vorgabe ersetzt. Der Büroname steht vorläufig als Konstante CD-Vorgabe ersetzt, falls diese abweicht.
`printOfficeName` in `src/frontend/print/print-document.tsx`. - **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die
aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte
(noch) keinen Bearbeiter speichern.
## Datenquellen des Deckblatts
| Feld | Quelle |
|---|---|
| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen |
| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) |
| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) |
| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch |
| Planstände | benannte Sicherungspunkte |
Die vier Projektfelder laufen über `project.update-settings` (Schema-Version
3, Migration `0008`) und sind Teil von Snapshot-Schema 6 und Projektexport.
Im `PUT /api/projects/:projectId` sind sie optional; fehlende Felder behalten
ihren Wert.
## Büroangaben
- Tabelle `document_branding_settings` (Schlüssel/Wert, Migration `0008`),
global wie das Logo.
- `GET /api/document-branding` liefert zusätzlich
`office: { officeName, officeAddress }`.
- `PUT /api/document-branding/office` mit `{ "officeName", "officeAddress" }`
(max. 200 / 500 Zeichen, leerer Text = nicht gesetzt).
## Logo ## Logo

View file

@ -13,6 +13,7 @@ const requiredTables = [
"distribution_board_components", "distribution_board_components",
"distribution_board_component_protection_devices", "distribution_board_component_protection_devices",
"document_branding_assets", "document_branding_assets",
"document_branding_settings",
]; ];
const tablePlaceholders = requiredTables.map(() => "?").join(", "); const tablePlaceholders = requiredTables.map(() => "?").join(", ");
@ -88,6 +89,10 @@ const requiredProjectColumns = [
"description", "description",
"is_public_building", "is_public_building",
"enabled_distribution_board_supply_types", "enabled_distribution_board_supply_types",
"site_address",
"work_stage",
"project_lead",
"project_editor",
]; ];
const projectColumns = new Set( const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name) db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)

View file

@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ChangeEvent, useEffect, useMemo, useState } from "react"; import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import "../../../../frontend/print/print-document.css"; import "../../../../frontend/print/print-document.css";
import { import {
deleteDocumentLogo, deleteDocumentLogo,
@ -14,13 +14,20 @@ import {
listDistributionBoards, listDistributionBoards,
listFloors, listFloors,
listProjectSnapshots, listProjectSnapshots,
updateOfficeProfile,
uploadDocumentLogo, uploadDocumentLogo,
} from "../../../../frontend/utils/api"; } from "../../../../frontend/utils/api";
import type { import type {
DocumentLogoMetadataDto, DocumentLogoMetadataDto,
OfficeProfileDto,
ProjectDto, ProjectDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
} from "../../../../frontend/types"; } from "../../../../frontend/types";
import { FormModal } from "../../../../frontend/components/form-modal";
import {
officeProfileFieldLabels,
officeProfileFieldMaxLengths,
} from "../../../../shared/constants/project-cover";
import { PrintDocument } from "../../../../frontend/print/print-document"; import { PrintDocument } from "../../../../frontend/print/print-document";
import { import {
buildPreliminaryBoardHierarchy, buildPreliminaryBoardHierarchy,
@ -30,7 +37,7 @@ import {
import { import {
documentLogoMimeTypes, documentLogoMimeTypes,
maxDocumentLogoBytes, maxDocumentLogoBytes,
} from "../../../../shared/validation/document-branding.schemas"; } from "../../../../shared/constants/document-branding";
type PlanFormat = "a4" | "a3"; type PlanFormat = "a4" | "a3";
@ -61,6 +68,14 @@ export default function ProjectPrintPage() {
const projectId = params.projectId; const projectId = params.projectId;
const [data, setData] = useState<PrintData | null>(null); const [data, setData] = useState<PrintData | null>(null);
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null); const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
const [office, setOffice] = useState<OfficeProfileDto>({
officeName: null,
officeAddress: null,
});
const [officeDraft, setOfficeDraft] = useState<{
officeName: string;
officeAddress: string;
} | null>(null);
const [format, setFormat] = useState<PlanFormat>("a4"); const [format, setFormat] = useState<PlanFormat>("a4");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@ -116,6 +131,7 @@ export default function ProjectPrintPage() {
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)), .sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
}); });
setLogo(branding.logo); setLogo(branding.logo);
setOffice(branding.office);
document.title = `Leistungsbilanz – ${project.name}`; document.title = `Leistungsbilanz – ${project.name}`;
} }
load().catch((err: unknown) => { load().catch((err: unknown) => {
@ -172,6 +188,28 @@ export default function ProjectPrintPage() {
} }
} }
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!officeDraft) {
return;
}
setIsBusy(true);
setError(null);
try {
const result = await updateOfficeProfile({
officeName: officeDraft.officeName.trim() || null,
officeAddress: officeDraft.officeAddress.trim() || null,
});
setOffice(result.office);
setOfficeDraft(null);
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleLogoDelete() { async function handleLogoDelete() {
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) { if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
return; return;
@ -213,6 +251,19 @@ export default function ProjectPrintPage() {
<option value="a3">A3 quer</option> <option value="a3">A3 quer</option>
</select> </select>
</label> </label>
<button
className="btn btn-sm btn-outline-primary"
disabled={isBusy}
onClick={() =>
setOfficeDraft({
officeName: office.officeName ?? "",
officeAddress: office.officeAddress ?? "",
})
}
type="button"
>
Büroangaben
</button>
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}> <label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
{logo ? "Logo ersetzen" : "Logo hinterlegen"} {logo ? "Logo ersetzen" : "Logo hinterlegen"}
<input <input
@ -245,11 +296,56 @@ export default function ProjectPrintPage() {
Zum Projekt Zum Projekt
</Link> </Link>
</div> </div>
{officeDraft ? (
<FormModal
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
isSaving={isBusy}
onClose={() => setOfficeDraft(null)}
onSubmit={handleOfficeSave}
submitLabel="Büroangaben speichern"
title="Büroangaben"
>
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="office-name">
{officeProfileFieldLabels.officeName}
</label>
<input
autoFocus
className="form-control"
id="office-name"
maxLength={officeProfileFieldMaxLengths.officeName}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
}
value={officeDraft.officeName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="office-address">
{officeProfileFieldLabels.officeAddress}
</label>
<textarea
className="form-control"
id="office-address"
maxLength={officeProfileFieldMaxLengths.officeAddress}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
}
placeholder={"Straße Nr.\nPLZ Ort"}
rows={3}
value={officeDraft.officeAddress}
/>
</div>
</div>
</FormModal>
) : null}
{data ? ( {data ? (
<PrintDocument <PrintDocument
entries={entries} entries={entries}
logoSrc={logoSrc} logoSrc={logoSrc}
namedSnapshots={data.namedSnapshots} namedSnapshots={data.namedSnapshots}
office={office}
printedAt={printedAt} printedAt={printedAt}
project={data.project} project={data.project}
/> />

View file

@ -0,0 +1,10 @@
CREATE TABLE `document_branding_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_editor` text;

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,13 @@
"when": 1790411540872, "when": 1790411540872,
"tag": "0007_board_info_and_document_logo", "tag": "0007_board_info_and_document_logo",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790411560953,
"tag": "0008_project_cover_and_office_profile",
"breakpoints": true
} }
] ]
} }

View file

@ -1,9 +1,10 @@
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { import {
documentBrandingAssets, documentBrandingAssets,
type DocumentBrandingAsset, type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js"; } from "../schema/document-branding-assets.js";
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
// Plain repository without revision semantics: branding is global office // Plain repository without revision semantics: branding is global office
// configuration, not a project mutation. // configuration, not a project mutation.
@ -49,4 +50,37 @@ export class DocumentBrandingRepository {
.run().changes > 0 .run().changes > 0
); );
} }
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
const rows = this.db
.select()
.from(documentBrandingSettings)
.where(inArray(documentBrandingSettings.key, [...keys]))
.all();
return Object.fromEntries(
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
) as Record<TKey, string | null>;
}
/** Stores the given values atomically; `null` removes a setting. */
setSettings(values: Record<string, string | null>) {
const updatedAt = new Date();
this.db.transaction((tx) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) {
tx.delete(documentBrandingSettings)
.where(eq(documentBrandingSettings.key, key))
.run();
} else {
tx.insert(documentBrandingSettings)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: documentBrandingSettings.key,
set: { value, updatedAt },
})
.run();
}
}
});
}
} }

View file

@ -1,9 +1,12 @@
import { and, eq, isNotNull } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm";
import { import {
assertProjectSettingsUpdateProjectCommand, assertProjectSettingsUpdateProjectCommand,
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand, createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues, type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js"; } from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js";
import type { import type {
ExecuteProjectSettingsUpdateCommandInput, ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore, ProjectSettingsProjectCommandStore,
@ -40,13 +43,27 @@ export class ProjectSettingsProjectCommandRepository
if (!current) { if (!current) {
throw new Error("Project not found."); throw new Error("Project not found.");
} }
const currentCoverValues = {
siteAddress: current.siteAddress,
workStage: current.workStage,
projectLead: current.projectLead,
projectEditor: current.projectEditor,
};
// Older command versions do not carry every value; missing values keep
// the current project state and are therefore also absent from the
// inverse of the same version.
const command = input.command;
const target: ProjectSettingsValues = const target: ProjectSettingsValues =
input.command.schemaVersion === 1 command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? { ? {
...input.command.payload, ...command.payload,
isPublicBuilding: current.isPublicBuilding, isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
} }
: input.command.payload; : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
: command.payload;
if (projectSettingsEqual(current, target)) { if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change."); throw new Error("Project settings did not change.");
} }
@ -62,10 +79,14 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV, threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
}; };
const inverse = const inverse =
input.command.schemaVersion === 1 command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? createBaselineProjectSettingsUpdateCommand(inverseValues) ? createBaselineProjectSettingsUpdateCommand(inverseValues)
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues); : createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target); assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx const updated = tx
@ -82,11 +103,32 @@ export class ProjectSettingsProjectCommandRepository
} }
} }
function createWithoutCoverProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
internalProjectNumber: values.internalProjectNumber,
externalProjectNumber: values.externalProjectNumber,
buildingOwner: values.buildingOwner,
description: values.description,
isPublicBuilding: values.isPublicBuilding,
singlePhaseVoltageV: values.singlePhaseVoltageV,
threePhaseVoltageV: values.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
values.enabledDistributionBoardSupplyTypes,
},
};
}
function createBaselineProjectSettingsUpdateCommand( function createBaselineProjectSettingsUpdateCommand(
values: ProjectSettingsValues values: ProjectSettingsValues
) { ) {
return { return {
schemaVersion: 1 as const, schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const, type: "project.update-settings" as const,
payload: { payload: {
name: values.name, name: values.name,
@ -114,8 +156,9 @@ function projectSettingsEqual(
current.description === target.description && current.description === target.description &&
current.isPublicBuilding === target.isPublicBuilding && current.isPublicBuilding === target.isPublicBuilding &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV && current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV current.threePhaseVoltageV === target.threePhaseVoltageV &&
&& sameSupplyTypes( projectCoverFields.every((field) => current[field] === target[field]) &&
sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes target.enabledDistributionBoardSupplyTypes
) )

View file

@ -233,6 +233,10 @@ function replaceProjectState(
threePhaseVoltageV: state.project.threePhaseVoltageV, threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes, state.project.enabledDistributionBoardSupplyTypes,
siteAddress: state.project.siteAddress,
workStage: state.project.workStage,
projectLead: state.project.projectLead,
projectEditor: state.project.projectEditor,
}) })
.where(eq(projects.id, state.project.id)) .where(eq(projects.id, state.project.id))
.run(); .run();

View file

@ -46,6 +46,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: projects.threePhaseVoltageV, threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes, projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
}) })
.from(projects) .from(projects)
@ -249,6 +253,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes, project.enabledDistributionBoardSupplyTypes,
siteAddress: project.siteAddress,
workStage: project.workStage,
projectLead: project.projectLead,
projectEditor: project.projectEditor,
}, },
distributionBoards: boardRows, distributionBoards: boardRows,
externalCsvConfiguration, externalCsvConfiguration,

View file

@ -31,6 +31,10 @@ export class ProjectRepository {
enabledDistributionBoardSupplyTypes: [ enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes, ...defaultDistributionBoardSupplyTypes,
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0, currentRevision: 0,
}; };
await db.insert(projects).values(project); await db.insert(projects).values(project);

View file

@ -0,0 +1,13 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide text settings for printed documents (e.g. office name and
// address on the cover page). Global application data like
// document_branding_assets: outside project revisions, snapshots and transfer.
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});

View file

@ -21,5 +21,9 @@ export const projects = sqliteTable("projects", {
.$type<DistributionBoardSupplyType[]>() .$type<DistributionBoardSupplyType[]>()
.notNull() .notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`), .default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
siteAddress: text("site_address"),
workStage: text("work_stage"),
projectLead: text("project_lead"),
projectEditor: text("project_editor"),
currentRevision: integer("current_revision").notNull().default(0), currentRevision: integer("current_revision").notNull().default(0),
}); });

View file

@ -3,13 +3,23 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js"; } from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
export const projectSettingsUpdateCommandType = export const projectSettingsUpdateCommandType =
"project.update-settings" as const; "project.update-settings" as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const; // Version 3 adds the print cover fields. Versions 2 (without cover fields)
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const; // and 1 (additionally without the public-building flag) stay executable;
// their missing values keep the current project values.
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export interface ProjectSettingsValues { export interface ProjectSettingsValues
extends Record<ProjectCoverField, string | null> {
name: string; name: string;
internalProjectNumber: string | null; internalProjectNumber: string | null;
externalProjectNumber: string | null; externalProjectNumber: string | null;
@ -21,8 +31,13 @@ export interface ProjectSettingsValues {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
} }
type PreviousProjectSettingsValues = Omit< export type WithoutCoverProjectSettingsValues = Omit<
ProjectSettingsValues, ProjectSettingsValues,
ProjectCoverField
>;
export type BaselineProjectSettingsValues = Omit<
WithoutCoverProjectSettingsValues,
"isPublicBuilding" "isPublicBuilding"
>; >;
@ -32,15 +47,22 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType; type: typeof projectSettingsUpdateCommandType;
} }
interface PreviousProjectSettingsUpdateProjectCommand export interface WithoutCoverProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<PreviousProjectSettingsValues> { extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion; schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface BaselineProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType; type: typeof projectSettingsUpdateCommandType;
} }
export type ProjectSettingsUpdateProjectCommand = export type ProjectSettingsUpdateProjectCommand =
| CurrentProjectSettingsUpdateProjectCommand | CurrentProjectSettingsUpdateProjectCommand
| PreviousProjectSettingsUpdateProjectCommand; | WithoutCoverProjectSettingsUpdateProjectCommand
| BaselineProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand( export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues values: ProjectSettingsValues
@ -60,7 +82,10 @@ export function assertProjectSettingsUpdateProjectCommand(
if ( if (
command.type !== projectSettingsUpdateCommandType || command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion && (command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion) command.schemaVersion !==
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion)
) { ) {
throw new Error("Unsupported project settings update command."); throw new Error("Unsupported project settings update command.");
} }
@ -91,13 +116,23 @@ export function assertProjectSettingsUpdateProjectCommand(
"Project settings update command contains invalid supply types." "Project settings update command contains invalid supply types."
); );
} }
const isPreviousSchema = const payload = command.payload;
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion; const expectedKeyCount =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? 8
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? 9
: 9 + projectCoverFields.length;
if ( if (
(isPreviousSchema && Object.keys(command.payload).length !== 8) || Object.keys(payload).length !== expectedKeyCount ||
(!isPreviousSchema && (command.schemaVersion !==
(typeof command.payload.isPublicBuilding !== "boolean" || baselineProjectSettingsUpdateCommandSchemaVersion &&
Object.keys(command.payload).length !== 9)) typeof payload.isPublicBuilding !== "boolean") ||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!projectCoverFields.every((field) =>
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
))
) { ) {
throw new Error( throw new Error(
"Project settings update command contains invalid values." "Project settings update command contains invalid values."

View file

@ -4,6 +4,10 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardInfoField, type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board.js"; } from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { import {
circuitGroupCategories, circuitGroupCategories,
} from "../../shared/constants/circuit-group.js"; } from "../../shared/constants/circuit-group.js";
@ -63,10 +67,30 @@ const previousProjectSchema = z
}) })
.strict(); .strict();
const projectSchema = previousProjectSchema const publicBuildingProjectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() }) .extend({ isPublicBuilding: z.boolean() })
.strict(); .strict();
const projectCoverValueSchema = (field: ProjectCoverField) =>
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
// Schema version 6 adds the print cover fields.
const projectSchema = publicBuildingProjectSchema
.extend({
siteAddress: projectCoverValueSchema("siteAddress"),
workStage: projectCoverValueSchema("workStage"),
projectLead: projectCoverValueSchema("projectLead"),
projectEditor: projectCoverValueSchema("projectEditor"),
})
.strict();
const emptyProjectCoverValues = {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
};
const legacyDistributionBoardSchema = z const legacyDistributionBoardSchema = z
.object({ .object({
id: idSchema, id: idSchema,
@ -494,7 +518,7 @@ export const projectStateSnapshotSchema = z
const boardInfoProjectStateSnapshotSchema = z const boardInfoProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: externalModelStateSchema,
@ -505,7 +529,7 @@ const boardInfoProjectStateSnapshotSchema = z
const previousProjectStateSnapshotSchema = z const previousProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: externalModelStateSchema,
@ -516,7 +540,7 @@ const previousProjectStateSnapshotSchema = z
const externalModelProjectStateSnapshotSchema = z const externalModelProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents, ...projectStateSnapshotContents,
@ -526,7 +550,7 @@ const externalModelProjectStateSnapshotSchema = z
const legacyProjectStateSnapshotSchema = z const legacyProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
@ -581,12 +605,14 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
} }
const schemaVersion = (value as { schemaVersion: number }).schemaVersion; const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
// Every supported version before 6 lacks the distribution-board info // Every supported version before 6 lacks the distribution-board info
// fields; they are upgraded as empty (null) values. // fields and the project cover fields; they are upgraded as empty (null)
// values.
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) { if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
const previous = boardInfoProjectStateSnapshotSchema.parse(value); const previous = boardInfoProjectStateSnapshotSchema.parse(value);
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo( distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards previous.distributionBoards
), ),
@ -597,6 +623,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo( distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards previous.distributionBoards
), ),
@ -607,6 +634,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo( distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards previous.distributionBoards
), ),
@ -618,6 +646,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return { return {
...legacy, ...legacy,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...legacy.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo( distributionBoards: withEmptyDistributionBoardInfo(
legacy.distributionBoards legacy.distributionBoards
), ),
@ -630,7 +659,11 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return { return {
...baseline, ...baseline,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false }, project: {
...baseline.project,
isPublicBuilding: false,
...emptyProjectCoverValues,
},
distributionBoards: withEmptyDistributionBoardInfo( distributionBoards: withEmptyDistributionBoardInfo(
baseline.distributionBoards baseline.distributionBoards
), ),

View file

@ -7,9 +7,16 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board"; } from "../../shared/constants/distribution-board";
import {
projectCoverFieldLabels,
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { FormModal } from "./form-modal"; import { FormModal } from "./form-modal";
export interface ProjectSettingsInput { export interface ProjectSettingsInput
extends Record<ProjectCoverField, string | null> {
name: string; name: string;
internalProjectNumber: string | null; internalProjectNumber: string | null;
externalProjectNumber: string | null; externalProjectNumber: string | null;
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
} }
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
siteAddress: "Straße Nr.\nPLZ Ort",
workStage: "z. B. LPH 5 – Ausführungsplanung",
projectLead: "",
projectEditor: "",
};
interface ProjectSettingsModalProps { interface ProjectSettingsModalProps {
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
] = useState<DistributionBoardSupplyType[]>( ] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes project.enabledDistributionBoardSupplyTypes
); );
const [coverValues, setCoverValues] = useState<
Record<ProjectCoverField, string>
>({
siteAddress: project.siteAddress ?? "",
workStage: project.workStage ?? "",
projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "",
});
const [transfer, setTransfer] = useState<unknown>(null); const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState(""); const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">( const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
singlePhaseVoltageV: Number(singlePhaseVoltageV), singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV), threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes, enabledDistributionBoardSupplyTypes,
siteAddress: toNullableString(coverValues.siteAddress),
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
}); });
} }
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV} value={threePhaseVoltageV}
/> />
</div> </div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Deckblatt</h3>
<p className="text-secondary small">
Angaben für das Deckblatt der Seitenansicht. Büroname und
Büroanschrift werden projektübergreifend in der Seitenansicht
gepflegt.
</p>
<div className="row g-3">
{projectCoverFields.map((field) => (
<div
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label className="form-label" htmlFor={`project-cover-${field}`}>
{projectCoverFieldLabels[field]}
</label>
{field === "siteAddress" ? (
<textarea
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
rows={2}
value={coverValues[field]}
/>
) : (
<input
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
value={coverValues[field]}
/>
)}
</div>
))}
</div>
</div>
<div className="col-12"> <div className="col-12">
<hr className="my-2" /> <hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3> <h3 className="h6">Projekt importieren oder exportieren</h3>

View file

@ -1,6 +1,6 @@
/* Print view (Seitenansicht) in the provisional kbp corporate design. /* Print view (Seitenansicht) in the kbp corporate design. Colors are
Colors are derived from the kbp logo and presentation material and will be derived from the kbp logo and presentation material; change them only
replaced by the official CD specification; change them only here. here.
All selectors are scoped to .print-view. The @page rules (format, margins, All selectors are scoped to .print-view. The @page rules (format, margins,
page numbers) are injected by the print page because they need project page numbers) are injected by the print page because they need project
data. */ data. */
@ -113,6 +113,9 @@
color: var(--pd-accent); color: var(--pd-accent);
font-weight: bold; font-weight: bold;
} }
.pd-multiline {
white-space: pre-line;
}
.pd-logo { .pd-logo {
display: block; display: block;
max-width: 100%; max-width: 100%;

View file

@ -5,6 +5,7 @@ import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto, CircuitTreeComponentDto,
CircuitTreeDeviceRowDto, CircuitTreeDeviceRowDto,
OfficeProfileDto,
ProjectDto, ProjectDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
} from "../types"; } from "../types";
@ -18,17 +19,23 @@ import {
type PrintBoardHierarchyEntry, type PrintBoardHierarchyEntry,
} from "./print-document-model"; } from "./print-document-model";
// Provisional office data until the project/cover fields are added.
export const printOfficeName = "kbp köhler beraten + planen GmbH";
export interface PrintDocumentProps { export interface PrintDocumentProps {
project: ProjectDto; project: ProjectDto;
office: OfficeProfileDto;
entries: PrintBoardHierarchyEntry[]; entries: PrintBoardHierarchyEntry[];
namedSnapshots: ProjectSnapshotMetadataDto[]; namedSnapshots: ProjectSnapshotMetadataDto[];
logoSrc: string | null; logoSrc: string | null;
printedAt: Date; printedAt: Date;
} }
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
if (!value) {
return <Placeholder />;
}
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
}
const dateFormat = new Intl.DateTimeFormat("de-DE", { const dateFormat = new Intl.DateTimeFormat("de-DE", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
@ -129,6 +136,7 @@ function boardLabel(entry: PrintBoardHierarchyEntry) {
function CoverSheet({ function CoverSheet({
project, project,
office,
entries, entries,
namedSnapshots, namedSnapshots,
logoSrc, logoSrc,
@ -149,11 +157,11 @@ function CoverSheet({
<caption>Projekt</caption> <caption>Projekt</caption>
<tbody> <tbody>
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr> <tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
<tr><th>Anschrift</th><td><Placeholder /></td></tr> <tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
<tr><th>Bauherr</th><td>{project.buildingOwner ?? <Placeholder />}</td></tr> <tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
<tr><th>Projektnummer intern</th><td>{project.internalProjectNumber ?? <Placeholder />}</td></tr> <tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
<tr><th>Projektnummer extern</th><td>{project.externalProjectNumber ?? <Placeholder />}</td></tr> <tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
<tr><th>Leistungsphase</th><td><Placeholder /></td></tr> <tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr> <tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr> <tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
</tbody> </tbody>
@ -163,10 +171,10 @@ function CoverSheet({
<table className="pd-kv"> <table className="pd-kv">
<caption>Planverfasser</caption> <caption>Planverfasser</caption>
<tbody> <tbody>
<tr><th>Büro</th><td>{printOfficeName}</td></tr> <tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
<tr><th>Anschrift</th><td><Placeholder /></td></tr> <tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
<tr><th>Projektleitung</th><td><Placeholder /></td></tr> <tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
<tr><th>Bearbeitung</th><td><Placeholder /></td></tr> <tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
</tbody> </tbody>
</table> </table>
<table className="pd-kv"> <table className="pd-kv">
@ -192,7 +200,9 @@ function CoverSheet({
<td>{String.fromCharCode(97 + (index % 26))}</td> <td>{String.fromCharCode(97 + (index % 26))}</td>
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td> <td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
<td>{snapshot.name}</td> <td>{snapshot.name}</td>
<td><Placeholder /></td> {/* Snapshots do not record an editor yet; the project's
current editor is shown for every entry. */}
<td><Value value={project.projectEditor} /></td>
</tr> </tr>
)) ))
) : ( ) : (
@ -582,6 +592,7 @@ function SectionRows({
export function PrintDocument({ export function PrintDocument({
project, project,
office,
entries, entries,
namedSnapshots, namedSnapshots,
logoSrc, logoSrc,
@ -594,6 +605,7 @@ export function PrintDocument({
entries={entries} entries={entries}
logoSrc={logoSrc} logoSrc={logoSrc}
namedSnapshots={namedSnapshots} namedSnapshots={namedSnapshots}
office={office}
project={project} project={project}
stand={stand} stand={stand}
/> />

View file

@ -93,9 +93,18 @@ export interface ProjectDto {
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
siteAddress: string | null;
workStage: string | null;
projectLead: string | null;
projectEditor: string | null;
currentRevision: number; currentRevision: number;
} }
export interface OfficeProfileDto {
officeName: string | null;
officeAddress: string | null;
}
export interface ProjectHistoryStateDto { export interface ProjectHistoryStateDto {
projectId: string; projectId: string;
currentRevision: number; currentRevision: number;

View file

@ -11,6 +11,7 @@ import type {
DistributionBoardInfoDto, DistributionBoardInfoDto,
DocumentLogoMetadataDto, DocumentLogoMetadataDto,
FloorDto, FloorDto,
OfficeProfileDto,
GlobalDeviceDto, GlobalDeviceDto,
ProjectCommandDto, ProjectCommandDto,
ProjectCommandResultDto, ProjectCommandResultDto,
@ -258,7 +259,7 @@ export function updateProjectSettings(
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"]; enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
} } & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
) { ) {
return request<ProjectSettingsCommandResultDto>( return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`, `/api/projects/${projectId}`,
@ -346,9 +347,17 @@ export function updateDistributionBoard(
export const documentLogoUrl = "/api/document-branding/logo"; export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() { export function getDocumentBranding() {
return request<{ logo: DocumentLogoMetadataDto | null }>( return request<{
"/api/document-branding" logo: DocumentLogoMetadataDto | null;
); office: OfficeProfileDto;
}>("/api/document-branding");
}
export function updateOfficeProfile(office: OfficeProfileDto) {
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
method: "PUT",
body: JSON.stringify(office),
});
} }
export async function uploadDocumentLogo(file: File) { export async function uploadDocumentLogo(file: File) {

View file

@ -1,7 +1,9 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js"; import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import { officeProfileFields } from "../../shared/constants/project-cover.js";
import { import {
maxDocumentLogoBytes, maxDocumentLogoBytes,
updateOfficeProfileSchema,
uploadDocumentLogoSchema, uploadDocumentLogoSchema,
type DocumentLogoMimeType, type DocumentLogoMimeType,
} from "../../shared/validation/document-branding.schemas.js"; } from "../../shared/validation/document-branding.schemas.js";
@ -11,7 +13,21 @@ const logoKey = "logo";
export function getDocumentBrandingHandler(_req: Request, res: Response) { export function getDocumentBrandingHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey); const logo = documentBrandingRepository.get(logoKey);
return res.json({ logo: logo ? toLogoMetadata(logo) : null }); return res.json({
logo: logo ? toLogoMetadata(logo) : null,
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function updateOfficeProfileHandler(req: Request, res: Response) {
const parsed = updateOfficeProfileSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
documentBrandingRepository.setSettings(parsed.data);
return res.json({
office: documentBrandingRepository.getSettings(officeProfileFields),
});
} }
export function getDocumentLogoHandler(_req: Request, res: Response) { export function getDocumentLogoHandler(_req: Request, res: Response) {

View file

@ -4,6 +4,10 @@ import {
updateProjectSettingsSchema, updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js"; } from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import {
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { projectRepository } from "../composition/application-repositories.js"; import { projectRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
} }
const { expectedRevision, ...settings } = parsed.data; const { expectedRevision, ...settings } = parsed.data;
const current = await projectRepository.findById(projectId);
if (!current) {
return res.status(404).json({ error: "Project not found" });
}
// Cover fields are optional in the request and keep their current value
// when omitted. `expectedRevision` rejects a project changed meanwhile.
const coverValues = Object.fromEntries(
projectCoverFields.map((field) => [
field,
settings[field] === undefined ? current[field] : settings[field],
])
) as Record<ProjectCoverField, string | null>;
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,
expectedRevision, expectedRevision,
description: "Projekteinstellungen bearbeiten", description: "Projekteinstellungen bearbeiten",
command: createProjectSettingsUpdateProjectCommand(settings), command: createProjectSettingsUpdateProjectCommand({
...settings,
...coverValues,
}),
}); });
const project = await projectRepository.findById(projectId); const project = await projectRepository.findById(projectId);
if (!project) { if (!project) {

View file

@ -4,6 +4,7 @@ import * as documentBrandingController from "../controllers/document-branding.co
export const documentBrandingRouter = express.Router(); export const documentBrandingRouter = express.Router();
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler); documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
documentBrandingRouter.put("/office", documentBrandingController.updateOfficeProfileHandler);
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler); documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler); documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler); documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);

View file

@ -0,0 +1,9 @@
export const documentLogoMimeTypes = [
"image/png",
"image/jpeg",
"image/svg+xml",
] as const;
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
export const maxDocumentLogoBytes = 2 * 1024 * 1024;

View file

@ -0,0 +1,45 @@
/**
* Project data shown on the print view cover page. They are ordinary project
* settings (persisted through `project.update-settings`, part of logical
* snapshots and project transfer).
*/
export const projectCoverFields = [
"siteAddress",
"workStage",
"projectLead",
"projectEditor",
] as const;
export type ProjectCoverField = (typeof projectCoverFields)[number];
export const projectCoverFieldLabels: Record<ProjectCoverField, string> = {
siteAddress: "Anschrift Bauvorhaben",
workStage: "Leistungsphase",
projectLead: "Projektleitung",
projectEditor: "Bearbeitung",
};
export const projectCoverFieldMaxLengths: Record<ProjectCoverField, number> = {
siteAddress: 500,
workStage: 100,
projectLead: 200,
projectEditor: 200,
};
/**
* Office-wide data for the cover page ("Planverfasser"). Global branding like
* the document logo, outside project revisions and snapshots.
*/
export const officeProfileFields = ["officeName", "officeAddress"] as const;
export type OfficeProfileField = (typeof officeProfileFields)[number];
export const officeProfileFieldLabels: Record<OfficeProfileField, string> = {
officeName: "Büro",
officeAddress: "Anschrift Büro",
};
export const officeProfileFieldMaxLengths: Record<OfficeProfileField, number> = {
officeName: 200,
officeAddress: 500,
};

View file

@ -1,14 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../constants/document-branding.js";
import { officeProfileFieldMaxLengths } from "../constants/project-cover.js";
export const documentLogoMimeTypes = [ export {
"image/png", documentLogoMimeTypes,
"image/jpeg", maxDocumentLogoBytes,
"image/svg+xml", type DocumentLogoMimeType,
] as const; } from "../constants/document-branding.js";
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
export const uploadDocumentLogoSchema = z export const uploadDocumentLogoSchema = z
.object({ .object({
@ -23,3 +24,19 @@ export const uploadDocumentLogoSchema = z
.strict(); .strict();
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>; export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;
// Empty text is stored as "not set" (null).
const officeValueSchema = (maximumLength: number) =>
z
.string()
.trim()
.max(maximumLength)
.nullable()
.transform((value) => (value ? value : null));
export const updateOfficeProfileSchema = z
.object({
officeName: officeValueSchema(officeProfileFieldMaxLengths.officeName),
officeAddress: officeValueSchema(officeProfileFieldMaxLengths.officeAddress),
})
.strict();

View file

@ -4,8 +4,22 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardInfoField, type DistributionBoardInfoField,
} from "../constants/distribution-board.js"; } from "../constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../constants/project-cover.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation.
const optionalCoverValueSchema = (field: ProjectCoverField) =>
z
.string()
.trim()
.max(projectCoverFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const createProjectSchema = z.object({ export const createProjectSchema = z.object({
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).optional(), internalProjectNumber: z.string().trim().max(100).optional(),
@ -34,6 +48,11 @@ export const updateProjectSettingsSchema = z
.refine((values) => new Set(values).size === values.length, { .refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.", message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}), }),
// Print cover fields; omitted fields keep their current value.
siteAddress: optionalCoverValueSchema("siteAddress"),
workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"),
}) })
.strict(); .strict();

View file

@ -0,0 +1,77 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { DocumentBrandingRepository } from "../src/db/repositories/document-branding.repository.js";
import { officeProfileFields } from "../src/shared/constants/project-cover.js";
import { updateOfficeProfileSchema } from "../src/shared/validation/document-branding.schemas.js";
function createRepository() {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
return { context, repository: new DocumentBrandingRepository(context.db) };
}
describe("document branding repository", () => {
it("stores, replaces and removes the office profile", () => {
const { context, repository } = createRepository();
try {
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: null,
officeAddress: null,
});
repository.setSettings(
updateOfficeProfileSchema.parse({
officeName: " kbp köhler beraten + planen GmbH ",
officeAddress: "Straße 1\n12345 Ort",
})
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "kbp köhler beraten + planen GmbH",
officeAddress: "Straße 1\n12345 Ort",
});
repository.setSettings(
updateOfficeProfileSchema.parse({ officeName: "Büro", officeAddress: "" })
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "Büro",
officeAddress: null,
});
assert.equal(
updateOfficeProfileSchema.safeParse({ officeName: "x".repeat(201), officeAddress: null })
.success,
false
);
} finally {
context.close();
}
});
it("replaces and deletes the logo", () => {
const { context, repository } = createRepository();
try {
repository.upsert({
key: "logo",
mimeType: "image/png",
fileName: "a.png",
data: Buffer.from([1, 2, 3]),
});
repository.upsert({
key: "logo",
mimeType: "image/svg+xml",
fileName: "b.svg",
data: Buffer.from("<svg/>"),
});
const logo = repository.get("logo");
assert.equal(logo?.mimeType, "image/svg+xml");
assert.equal(logo?.data.toString(), "<svg/>");
assert.equal(repository.delete("logo"), true);
assert.equal(repository.get("logo"), undefined);
} finally {
context.close();
}
});
});

View file

@ -1074,6 +1074,10 @@ describe("project command service", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}), }),
}); });
assert.equal(updated.history.currentRevision, 1); assert.equal(updated.history.currentRevision, 1);

View file

@ -52,6 +52,10 @@ function getSettings(context: DatabaseContext) {
threePhaseVoltageV: projects.threePhaseVoltageV, threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes, projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
}) })
.from(projects) .from(projects)
@ -81,6 +85,10 @@ function settings(
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
...overrides, ...overrides,
}; };
} }
@ -217,6 +225,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}, },
}, },
]); ]);
@ -289,6 +301,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}); });
const undoStep = history.getNextCommand("project-1", "undo"); const undoStep = history.getNextCommand("project-1", "undo");
@ -317,6 +333,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 2, currentRevision: 2,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -356,6 +376,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 3, currentRevision: 3,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -433,6 +457,117 @@ describe("project settings project-command repository", () => {
} }
}); });
it("persists the print cover fields with persisted undo", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const readCover = () =>
context.db
.select({
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand(
settings({
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
})
),
});
assert.deepEqual(readCover(), {
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
});
assert.equal(forward.inverse.schemaVersion, 3);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(readCover(), {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
});
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand(
settings({ workStage: " LPH 5" })
),
/invalid values/
);
} finally {
context.close();
}
});
it("keeps the cover fields when applying a version 2 command", () => {
const context = createTestDatabase();
try {
context.db
.update(projects)
.set({ workStage: "LPH 3", projectLead: "P. Leitung" })
.where(eq(projects.id, "project-1"))
.run();
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const {
siteAddress: _siteAddress,
workStage: _workStage,
projectLead: _projectLead,
projectEditor: _projectEditor,
...versionTwoPayload
} = settings({ name: "Stand aus Version 2" });
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 2,
type: "project.update-settings",
payload: versionTwoPayload,
},
});
const project = context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
assert.equal(project?.name, "Stand aus Version 2");
assert.equal(project?.workStage, "LPH 3");
assert.equal(project?.projectLead, "P. Leitung");
assert.equal(forward.inverse.schemaVersion, 2);
assert.equal(Object.keys(forward.inverse.payload).length, 9);
} finally {
context.close();
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => { it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -8,6 +8,23 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js"; } from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
/** Removes the fields introduced by snapshot schema version 6. */
function withoutVersionSixFields<
T extends { project: object; distributionBoards: object[] },
>(snapshot: T): T {
const project = snapshot.project as Record<string, unknown>;
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete project[field];
}
for (const board of snapshot.distributionBoards) {
const record = board as Record<string, unknown>;
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
delete record[field];
}
}
return snapshot;
}
function minimalSnapshot() { function minimalSnapshot() {
return { return {
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
@ -29,6 +46,10 @@ function minimalSnapshot() {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: "Musterstraße 1\n12345 Musterstadt" as string | null,
workStage: "LPH 5" as string | null,
projectLead: "P. Leitung" as string | null,
projectEditor: null as string | null,
}, },
distributionBoards: [], distributionBoards: [],
externalCsvConfiguration: null, externalCsvConfiguration: null,
@ -155,7 +176,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades the supported baseline snapshot with a safe building default", () => { it("upgrades the supported baseline snapshot with a safe building default", () => {
const baseline = structuredClone(minimalSnapshot()); const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion; baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding; delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration; delete (baseline as Record<string, unknown>).externalCsvConfiguration;
@ -169,7 +190,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades snapshot version 2 with an empty external CSV configuration", () => { it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
const previous = structuredClone(minimalSnapshot()); const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 2 as typeof previous.schemaVersion; previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration; delete (previous as Record<string, unknown>).externalCsvConfiguration;
delete (previous as Record<string, unknown>).externalModel; delete (previous as Record<string, unknown>).externalModel;
@ -187,7 +208,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades snapshot version 3 with an empty external model", () => { it("upgrades snapshot version 3 with an empty external model", () => {
const previous = structuredClone(minimalSnapshot()); const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion; previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel; delete (previous as Record<string, unknown>).externalModel;
@ -203,20 +224,22 @@ describe("project state snapshot model", () => {
}); });
}); });
it("upgrades snapshot version 5 with empty distribution-board info", () => { it("upgrades snapshot version 5 with empty board info and cover fields", () => {
const previous = protectedSnapshot(); const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 5 as typeof previous.schemaVersion; previous.schemaVersion = 5 as typeof previous.schemaVersion;
for (const board of previous.distributionBoards) {
const record = board as Record<string, unknown>;
delete record.boardNumber;
delete record.location;
delete record.fedFrom;
delete record.remark;
}
const upgraded = parseProjectStateSnapshot(previous); const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(
{
siteAddress: upgraded.project.siteAddress,
workStage: upgraded.project.workStage,
projectLead: upgraded.project.projectLead,
projectEditor: upgraded.project.projectEditor,
},
{ siteAddress: null, workStage: null, projectLead: null, projectEditor: null }
);
assert.deepEqual( assert.deepEqual(
{ {
boardNumber: upgraded.distributionBoards[0]?.boardNumber, boardNumber: upgraded.distributionBoards[0]?.boardNumber,
@ -228,27 +251,26 @@ describe("project state snapshot model", () => {
); );
}); });
it("keeps distribution-board info values and rejects them in older versions", () => { it("keeps board info and cover values and rejects them in older versions", () => {
const parsed = parseProjectStateSnapshot(protectedSnapshot()); const parsed = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1"); assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik"); assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1"); assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
assert.equal(parsed.project.siteAddress, "Musterstraße 1\n12345 Musterstadt");
assert.equal(parsed.project.workStage, "LPH 5");
const mislabeled = protectedSnapshot(); const mislabeled = protectedSnapshot();
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion; mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled)); assert.throws(() => parseProjectStateSnapshot(mislabeled));
const missingCover = structuredClone(protectedSnapshot());
delete (missingCover.project as Record<string, unknown>).workStage;
assert.throws(() => parseProjectStateSnapshot(missingCover));
}); });
it("upgrades snapshot version 4 with the existing quantity as manual share", () => { it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot(); const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 4 as typeof previous.schemaVersion; previous.schemaVersion = 4 as typeof previous.schemaVersion;
for (const board of previous.distributionBoards) {
const record = board as Record<string, unknown>;
delete record.boardNumber;
delete record.location;
delete record.fedFrom;
delete record.remark;
}
previous.circuits[0]!.isReserve = false; previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{ previous.circuits[0]!.deviceRows = [{
id: "row-1", id: "row-1",