forked from jappel/leistungsbilanz-ts
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:
parent
94688d7a8c
commit
bf58296ba5
35 changed files with 3571 additions and 106 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -251,8 +251,9 @@ 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. Version 5 stores
|
cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`;
|
||||||
|
all older supported versions are upgraded with `null` for these fields. 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
|
||||||
|
|
@ -370,6 +371,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 `0009`); 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
|
||||||
|
|
@ -474,11 +479,14 @@ Required operations:
|
||||||
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 are the provisional kbp CD (`--pd-*` variables) until the
|
||||||
official CD specification exists. Cover fields shown as "[ergänzen]"
|
official CD specification exists. 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 `0008`), 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 `0008`) and `document_branding_settings` (migration `0009`),
|
||||||
|
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.
|
||||||
|
|
@ -581,8 +589,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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": { ... } }`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
`0009`), 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
|
||||||
|
|
|
||||||
|
|
@ -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 `0009`) 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 `0009`),
|
||||||
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
10
src/db/migrations/0009_amazing_cable.sql
Normal file
10
src/db/migrations/0009_amazing_cable.sql
Normal 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;
|
||||||
2656
src/db/migrations/meta/0009_snapshot.json
Normal file
2656
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -64,6 +64,13 @@
|
||||||
"when": 1790356758671,
|
"when": 1790356758671,
|
||||||
"tag": "0008_colossal_terrax",
|
"tag": "0008_colossal_terrax",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790360862160,
|
||||||
|
"tag": "0009_amazing_cable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
13
src/db/schema/document-branding-settings.ts
Normal file
13
src/db/schema/document-branding-settings.ts
Normal 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())`),
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
@ -567,12 +591,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
|
||||||
),
|
),
|
||||||
|
|
@ -583,6 +609,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
|
||||||
),
|
),
|
||||||
|
|
@ -593,6 +620,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
|
||||||
),
|
),
|
||||||
|
|
@ -604,6 +632,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
|
||||||
),
|
),
|
||||||
|
|
@ -616,7 +645,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
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
9
src/shared/constants/document-branding.ts
Normal file
9
src/shared/constants/document-branding.ts
Normal 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;
|
||||||
45
src/shared/constants/project-cover.ts
Normal file
45
src/shared/constants/project-cover.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
77
tests/document-branding.repository.test.ts
Normal file
77
tests/document-branding.repository.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue