Compare commits

...

20 Commits

Author SHA1 Message Date
jappel 7257deacdc Redesign and translate grid controls 2026-07-22 23:19:21 +02:00
jappel 17cdeb6174 Update generated Next.js types 2026-07-22 23:08:32 +02:00
jappel 8df37fecae Allow Next.js Docker startup writes 2026-07-22 23:08:15 +02:00
jappel fafccca50f Redesign grid filter menu 2026-07-22 23:04:03 +02:00
jappel d1615bcfee Clarify active grid filters 2026-07-22 22:57:25 +02:00
jappel 01aff46b4f Start Docker stack in background 2026-07-22 22:54:53 +02:00
jappel d0b8239085 Contain circuit editor scrolling 2026-07-22 22:44:49 +02:00
jappel c991f493da Add Docker development workflow 2026-07-22 22:42:28 +02:00
jappel 00cb07f848 Ignore local dev logs 2026-07-22 20:58:21 +02:00
jappel 5dc3ab90be Prepare circuits for future sizing 2026-07-22 20:53:04 +02:00
jappel 6c45c97ffd Extract circuit grid projection 2026-07-22 20:46:32 +02:00
jappel 009152b406 Extract circuit grid model 2026-07-22 20:40:07 +02:00
jappel b3a7ff79da Confirm cross-section device moves 2026-07-22 20:31:55 +02:00
jappel f8802fecdb Harden grid deletion and BMK validation 2026-07-22 20:27:01 +02:00
jappel ea61772e62 Record circuit editor UX debt 2026-07-22 20:21:00 +02:00
jappel a6b83285eb Make Ctrl+Plus context aware 2026-07-22 20:15:33 +02:00
jappel fd62bf2e15 Validate project device drop targets 2026-07-22 20:07:09 +02:00
jappel b2f5ce77a5 Make device sync atomic and undoable 2026-07-22 19:47:10 +02:00
jappel 6f8b292b6b Add explicit project device sync 2026-07-22 19:38:21 +02:00
jappel 9d07ed9856 Align project devices with circuit model 2026-07-22 19:31:08 +02:00
52 changed files with 4093 additions and 961 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.agents
.codex
.next
data
dist
node_modules
npm-debug.log*
+2
View File
@@ -2,3 +2,5 @@ node_modules/
dist/
.next/
data/*.db
data/backups/*.db
.codex/*.log
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-bookworm-slim
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000 3001
+31 -3
View File
@@ -155,11 +155,39 @@ Die folgende Liste fasst die zentralen Anforderungen zusammen und zeigt den aktu
## Schneller Entwicklungsstart
### Mit Docker (empfohlen fuer kurze Sessions)
Voraussetzung: Docker Desktop mit aktivem Docker-Compose-Plugin.
1. Sicherstellen, dass keine direkt gestartete API und kein direkt gestartetes Frontend mehr auf Port 3000/3001 laufen.
2. `npm run docker:up` (baut und startet beide Container im Hintergrund)
3. Frontend unter `http://localhost:3001` oeffnen.
Beim Start fuehrt der API-Container ausstehende Drizzle-Migrationen und die Circuit-First-Schemapruefung aus. Die vorhandene SQLite-Datenbank wird ueber `./data:/app/data` eingebunden und bleibt nach `npm run docker:down` erhalten. Quellcodeaenderungen unter `src/` werden von beiden Entwicklungsservern automatisch erkannt.
Weitere Docker-Befehle:
- `npm run docker:down` - Frontend und API stoppen
- `npm run docker:logs` - laufende Containerlogs anzeigen (`Ctrl+C` beendet nur die Logansicht)
- `docker compose ps` - Status und Healthchecks anzeigen
- `Invoke-WebRequest http://localhost:3000/health` - API-Healthcheck pruefen
Bewusster Datenbank-Reset unter PowerShell:
1. `npm run docker:down`
2. `npm run db:backup`
3. `Remove-Item -LiteralPath .\data\leistungsbilanz.db`
4. `npm run docker:up`
Der Reset entfernt die aktive lokale Datenbank. Die zuvor erzeugte Sicherung bleibt unter `data/backups/` erhalten.
### Ohne Docker
1. `npm install`
2. `npm run db:generate`
3. `npm run db:migrate`
2. `npm run db:migrate`
3. `npm run db:verify:circuit-schema`
4. `npm run dev:api` (API auf Port 3000)
5. `npm run dev:web` (Frontend auf Port 3001)
5. In einem zweiten Terminal `npm run dev:web` (Frontend auf Port 3001)
## Wichtige Befehle
+68
View File
@@ -0,0 +1,68 @@
name: leistungsbilanz
services:
api:
build:
context: .
command:
- sh
- -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
environment:
PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
init: true
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./scripts:/app/scripts
- ./data:/app/data
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
- ./tsconfig.json:/app/tsconfig.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s
timeout: 3s
retries: 12
start_period: 20s
web:
build:
context: .
command:
- npm
- run
- dev:web
- --
- --hostname
- 0.0.0.0
environment:
API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1"
init: true
depends_on:
api:
condition: service_healthy
ports:
- "3001:3001"
volumes:
- ./src:/app/src
- ./next.config.mjs:/app/next.config.mjs:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s
timeout: 3s
retries: 12
start_period: 20s
+16
View File
@@ -149,3 +149,19 @@ Request sketch:
- `DELETE /consumers/:consumerId`
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
## Linked Project Device Review
- `GET /project-devices/projects/:projectId/:projectDeviceId/links`
- returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
- restores the captured pre-sync values for session-local undo
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
- restores a disconnected link if the row has not been linked elsewhere meanwhile
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
+11
View File
@@ -4,6 +4,16 @@
The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits.
## Frontend Grid Modules
- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration.
- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values.
- `circuit-grid-projection.ts` owns block-preserving filtering/sorting and the normalized visible row projection.
- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions.
- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements.
The pure grid modules have no React state and are covered by focused unit tests. This keeps circuit/device ownership rules testable while the editor UI is split incrementally.
## Domain Model Overview
- `CircuitSection`
@@ -16,6 +26,7 @@ The circuit-list editor is a circuit-first planning workspace for distribution-b
- protection data
- cable data
- reserve state
- voltage and optional control requirement for future sizing
- circuit-level remark/status
- `CircuitDeviceRow`
- Load/device line inside a circuit.
+19
View File
@@ -37,6 +37,16 @@ Selection, keyboard movement, and editability checks run against this normalized
- `Tab` / `Shift+Tab`: move between editable cells (commits active edit)
- arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
- `Delete`: delete the selected circuit or device context after confirmation
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
## `-frei-` Placeholder Behavior
@@ -57,9 +67,14 @@ Intent is separated by drag source type:
- project device drag:
- drop to section/placeholder -> create new circuit with linked row
- drop to existing circuit row -> append row to that circuit
- lighting devices are accepted only by the lighting section
- other devices are accepted only by the section matching their phase type
- invalid targets show rejection feedback and do not create data
- device row drag:
- drop to existing circuit -> move row(s) into that circuit
- drop to placeholder -> create new target circuit and move row(s)
- crossing a section boundary requires explicit confirmation
- phase type, category, linked project device and local row values remain unchanged
- circuit drag (BMK handle):
- reorder circuits inside same section only
- cross-section reorder is rejected
@@ -68,6 +83,10 @@ Intent is separated by drag source type:
- multi-circuit move:
- supported for same-section selected circuit blocks
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
## Filtering and Sorting
- Per-column filtering works on normalized displayed values.
@@ -2,7 +2,6 @@
- Undo/redo history is session-local only.
- Undo/redo history is not persisted across reloads or between users.
- No linked project-device sync review dialog is implemented yet.
- No global cross-project device library workflow is implemented yet.
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
+4 -1
View File
@@ -10,7 +10,8 @@ This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in
npm run db:backup
```
2. Apply pending schema migrations (includes `0008_circuit_first_model`)
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
`0009_project_device_circuit_fields` transition)
```bash
npm run db:migrate
@@ -52,6 +53,8 @@ WHERE type = 'table'
ORDER BY name;
```
The verification command also checks the circuit-first project-device columns added by migration `0009`.
```sql
SELECT circuit_list_id, key, prefix, sort_order
FROM circuit_sections
+1
View File
@@ -69,6 +69,7 @@ User-facing fields:
- `rcdAssignment` optional
- `terminalDesignation` optional
- `voltage` optional
- `controlRequirement` optional, for example DALI or KNX
- `status` optional
- `isReserve` optional
- `remark` optional
@@ -265,3 +265,38 @@ Acceptance criteria:
Note:
This phase may be moved earlier if repeated local startup overhead begins to slow down feature development.
## Phase 10: Deferred Circuit Editor UX Polish
Goal:
Improve everyday usability after the core circuit workflows are stable.
Tasks:
- keep column headers visible while scrolling down long circuit lists
- keep undo/redo controls reachable without scrolling to the top
- preserve the user's viewport when toolbar undo/redo is applied instead of jumping to the changed row
- reduce the default table width and contain horizontal scrolling within the editor
- review sensible default columns and responsive behavior for typical browser widths
- redesign column filtering with clearer controls, active-filter indicators and an easy reset action
Implemented layout foundation:
- table scrolling is contained inside the editor so sticky headers remain visible
- the toolbar remains reachable while the page scrolls
- programmatic grid focus preserves the viewport during undo/redo reloads
- wide tables no longer expand the surrounding browser layout
- active sorts and filters are summarized above the grid and can be removed individually or reset together
- column filter buttons show their selected-value count
- filter menus provide value search, select-all/select-none controls and explicit cancel/apply actions
- filter values and visible columns use full-width selectable rows instead of permanent checkbox controls
- column settings use the same searchable panel pattern as column filters
- column headings are visually plain text while remaining keyboard-accessible sort controls
Acceptance criteria:
- users retain column context anywhere in a long list
- undo/redo does not disrupt the current reading position
- the editor remains usable without overflowing the browser layout
- active filters are easy to understand, change and clear
+4 -2
View File
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const apiInternalUrl = (process.env.API_INTERNAL_URL || "http://localhost:3000").replace(/\/$/, "");
const nextConfig = {
typescript: {
tsconfigPath: "./tsconfig.next.json",
@@ -7,11 +9,11 @@ const nextConfig = {
return [
{
source: "/api/:path*",
destination: "http://localhost:3000/api/:path*",
destination: `${apiInternalUrl}/api/:path*`,
},
{
source: "/health",
destination: "http://localhost:3000/health",
destination: `${apiInternalUrl}/health`,
},
];
},
+5 -2
View File
@@ -7,12 +7,15 @@
"dev": "npm run dev:api",
"dev:api": "tsx watch src/server/index.ts",
"dev:web": "next dev -p 3001",
"docker:up": "docker compose up --build --detach",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs --follow",
"build": "npm run build:api",
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
+32
View File
@@ -18,14 +18,46 @@ const rows = db
const existing = new Set(rows.map((row) => row.name));
const missing = requiredTables.filter((name) => !existing.has(name));
const requiredProjectDeviceColumns = [
"phase_type",
"connection_kind",
"cost_group",
"power_per_unit",
"simultaneity_factor",
"cos_phi",
"remark",
];
const projectDeviceColumns = new Set(
db.prepare("PRAGMA table_info(project_devices)").all().map((column) => column.name)
);
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
(name) => !projectDeviceColumns.has(name)
);
const requiredCircuitColumns = ["control_requirement"];
const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
process.exit(1);
}
if (missingProjectDeviceColumns.length > 0) {
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
process.exit(1);
}
if (missingCircuitColumns.length > 0) {
console.error("Missing circuit columns:", missingCircuitColumns.join(", "));
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
+308 -22
View File
@@ -44,12 +44,19 @@ body {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
position: relative;
position: sticky;
top: 0;
z-index: 12;
padding: 0.35rem 0;
background: #fff;
box-shadow: 0 1px 0 rgba(196, 205, 220, 0.8);
}
.editor-toolbar button {
@@ -64,30 +71,106 @@ body {
opacity: 0.45;
}
.active-view-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem;
border: 1px solid #bfdbfe;
border-radius: 5px;
background: #eff6ff;
color: #1e3a5f;
font-size: 0.78rem;
}
.active-view-label {
font-weight: 600;
}
.active-view-chip,
.active-view-reset {
border: 1px solid #93b4df;
border-radius: 999px;
background: #fff;
color: #1e3a5f;
padding: 0.18rem 0.48rem;
font-size: 0.76rem;
cursor: pointer;
}
.active-view-chip:hover,
.active-view-reset:hover {
border-color: #2563eb;
}
.active-view-reset {
margin-left: auto;
font-weight: 600;
}
.column-settings-menu {
position: absolute;
z-index: 9;
margin-top: 2rem;
border: 1px solid #cfd7e5;
background: #fff;
border-radius: 4px;
border-radius: 5px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
padding: 0.45rem;
width: 300px;
padding: 0.55rem;
width: 340px;
color: #1f2937;
text-align: left;
}
.column-settings-actions {
.column-settings-title-row,
.column-settings-footer {
display: flex;
justify-content: flex-end;
margin-bottom: 0.35rem;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.column-settings-title-row {
margin-bottom: 0.25rem;
font-size: 0.82rem;
}
.column-settings-close {
border: 0 !important;
background: transparent !important;
color: #4b5563;
padding: 0.05rem 0.2rem !important;
font-size: 1rem !important;
line-height: 1;
cursor: pointer;
}
.column-settings-explanation {
margin-bottom: 0.4rem;
color: #4b5563;
font-size: 0.72rem;
line-height: 1.3;
}
.column-settings-search {
width: 100%;
margin-bottom: 0.4rem;
border: 1px solid #c4cddc;
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-size: 0.76rem;
}
.column-settings-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 340px;
gap: 0.15rem;
max-height: 300px;
overflow: auto;
border: 1px solid #e1e6ef;
border-radius: 4px;
padding: 0.25rem;
}
.column-settings-item {
@@ -97,7 +180,12 @@ body {
gap: 0.4rem;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.2rem 0.25rem;
padding: 0.1rem;
}
.column-settings-item.selected {
border-color: #bfdbfe;
background: #eff6ff;
}
.column-settings-item.dragging {
@@ -113,11 +201,32 @@ body {
background: #f7fafc;
}
.column-settings-item label {
.column-visibility-button {
display: flex;
gap: 0.3rem;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
border: 0 !important;
background: transparent !important;
padding: 0.2rem 0.25rem !important;
color: inherit;
text-align: left;
font-size: 0.8rem;
cursor: pointer;
}
.column-visibility-button:disabled {
cursor: default;
}
.selection-marker {
display: inline-flex;
flex: 0 0 1rem;
align-items: center;
justify-content: center;
color: #1d4ed8;
font-weight: 700;
}
.column-settings-order {
@@ -137,13 +246,37 @@ body {
overflow: auto;
border: 1px solid #d9dee8;
background: #fff;
width: 100%;
max-width: 100%;
min-width: 0;
max-height: max(28rem, calc(100vh - 12rem));
scrollbar-gutter: stable;
}
.column-settings-empty {
padding: 0.55rem 0.35rem;
color: #6b7280;
font-size: 0.76rem;
}
.column-settings-footer {
justify-content: flex-end;
margin-top: 0.45rem;
}
.column-settings-footer button.primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.tree-editor-layout {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 0.75rem;
min-height: 560px;
min-width: 0;
align-items: start;
}
.project-device-sidebar {
@@ -248,7 +381,6 @@ body {
gap: 0.3rem;
}
.tree-grid .header-sort-btn,
.tree-grid .header-filter-btn {
border: 1px solid #c4cddc;
background: #fff;
@@ -258,12 +390,34 @@ body {
}
.tree-grid .header-sort-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 0;
background: transparent;
padding: 0;
color: #1f2937;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.tree-grid .header-sort-btn:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline;
}
.tree-grid .sort-indicator {
color: #2563eb;
font-size: 0.85rem;
}
.tree-grid .header-filter-btn.active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.tree-grid .header-filter-menu {
@@ -273,33 +427,138 @@ body {
border: 1px solid #cfd7e5;
background: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
width: 220px;
max-height: 260px;
overflow: auto;
padding: 0.35rem;
width: 300px;
padding: 0.55rem;
text-align: left;
}
.tree-grid .header-filter-clear {
.tree-grid .header-filter-title-row,
.tree-grid .header-filter-selection-actions,
.tree-grid .header-filter-footer,
.tree-grid .header-filter-footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.tree-grid .header-filter-title-row {
margin-bottom: 0.25rem;
font-size: 0.82rem;
}
.tree-grid .header-filter-close {
border: 0;
background: transparent;
color: #4b5563;
padding: 0.05rem 0.2rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.tree-grid .header-filter-selection-actions {
justify-content: flex-start;
margin: 0.4rem 0 0.3rem;
font-size: 0.72rem;
}
.tree-grid .header-filter-selection-actions span {
margin-left: auto;
color: #4b5563;
}
.tree-grid .header-filter-selection-actions button,
.tree-grid .header-filter-footer button {
border: 1px solid #c4cddc;
background: #fff;
border-radius: 3px;
padding: 0.18rem 0.35rem;
font-size: 0.72rem;
padding: 0.12rem 0.3rem;
margin-bottom: 0.25rem;
cursor: pointer;
}
.tree-grid .header-filter-explanation {
margin-bottom: 0.4rem;
color: #4b5563;
font-size: 0.72rem;
line-height: 1.3;
white-space: normal;
}
.tree-grid .header-filter-search {
width: 100%;
border: 1px solid #c4cddc;
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-size: 0.76rem;
}
.tree-grid .header-filter-values {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 210px;
overflow: auto;
border: 1px solid #e1e6ef;
border-radius: 4px;
padding: 0.25rem;
}
.tree-grid .header-filter-item {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.35rem;
width: 100%;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
padding: 0.25rem 0.3rem;
color: #1f2937;
font-weight: 400;
font-size: 0.75rem;
text-align: left;
cursor: pointer;
}
.tree-grid .header-filter-item:hover {
background: #f3f4f6;
}
.tree-grid .header-filter-item.selected {
border-color: #bfdbfe;
background: #eff6ff;
}
.tree-grid .header-filter-empty {
padding: 0.45rem 0.25rem;
color: #6b7280;
font-size: 0.74rem;
}
.tree-grid .header-filter-footer {
margin-top: 0.45rem;
}
.tree-grid .header-filter-footer-actions {
margin-left: auto;
}
.tree-grid .header-filter-footer button.primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.tree-grid .header-filter-footer button:disabled {
opacity: 0.45;
cursor: default;
}
.tree-grid .header-filter-warning {
color: #9a3412;
font-size: 0.7rem;
white-space: normal;
}
.tree-grid .num {
@@ -382,6 +641,16 @@ body {
outline-offset: -2px;
}
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline-offset: -2px;
background: #fff7ed !important;
}
.tree-grid .cell-invalid input {
border-color: #c2410c;
}
.tree-grid input {
width: 100%;
min-width: 5rem;
@@ -413,6 +682,11 @@ body {
background: #fff7ed !important;
}
.tree-grid .drop-target-confirm {
box-shadow: inset 0 0 0 2px #d97706;
background: #fffbeb !important;
}
.tree-grid tr.circuit-insert-before td,
.tree-grid tr.circuit-insert-after td {
position: relative;
@@ -486,6 +760,18 @@ body {
border-color: #f5b5b5;
}
.notice.warning {
background: #fff7ed;
border-color: #fdba74;
}
.editor-error-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.notice.muted {
background: #f6f6f6;
border-color: #e4e4e4;
+380 -43
View File
@@ -11,6 +11,8 @@ import {
createProjectDevice,
createRoom,
deleteProjectDevice,
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
@@ -18,6 +20,9 @@ import {
listProjectDevices,
listProjects,
listRooms,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -28,21 +33,46 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDto,
RoomDto,
} from "../../../frontend/types";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
displayName: "Anzeigename",
phaseType: "Phasenart",
connectionKind: "Anschlussart",
costGroup: "Kostengruppe",
category: "Kategorie",
quantity: "Anzahl",
powerPerUnit: "Leistung je Stück",
simultaneityFactor: "Gleichzeitigkeitsfaktor",
cosPhi: "cos Phi",
remark: "Bemerkung",
};
type ProjectDeviceUndoState =
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
displayName: "",
phaseType: "single_phase",
connectionKind: "",
costGroup: "",
category: "",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
voltageV: 230,
phaseCount: 1,
powerFactor: 1,
note: "",
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: "",
};
function toOptionalNumber(value: string) {
@@ -73,16 +103,21 @@ export default function ProjectDetailPage() {
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({
name: "",
displayName: "",
phaseType: "single_phase",
connectionKind: "",
costGroup: "",
category: "",
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
powerPerUnit: "0.1",
simultaneityFactor: "1",
cosPhi: "1",
remark: "",
});
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -230,14 +265,15 @@ export default function ProjectDetailPage() {
const payload: CreateProjectDeviceInput = {
name: projectDeviceForm.name.trim(),
displayName: projectDeviceForm.displayName.trim() || projectDeviceForm.name.trim(),
phaseType: projectDeviceForm.phaseType === "three_phase" ? "three_phase" : "single_phase",
connectionKind: projectDeviceForm.connectionKind.trim() || undefined,
costGroup: projectDeviceForm.costGroup.trim() || undefined,
category: projectDeviceForm.category.trim() || undefined,
quantity: Number(projectDeviceForm.quantity),
installedPowerPerUnitKw: Number(projectDeviceForm.installedPowerPerUnitKw),
demandFactor: Number(projectDeviceForm.demandFactor),
voltageV: toOptionalNumber(projectDeviceForm.voltageV),
phaseCount: projectDeviceForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(projectDeviceForm.powerFactor),
note: projectDeviceForm.note.trim() || undefined,
powerPerUnit: Number(projectDeviceForm.powerPerUnit),
simultaneityFactor: Number(projectDeviceForm.simultaneityFactor),
cosPhi: toOptionalNumber(projectDeviceForm.cosPhi),
remark: projectDeviceForm.remark.trim() || undefined,
};
setIsSaving(true);
@@ -248,14 +284,15 @@ export default function ProjectDetailPage() {
setProjectDeviceForm({
name: emptyProjectDevice.name,
displayName: emptyProjectDevice.displayName,
phaseType: emptyProjectDevice.phaseType,
connectionKind: emptyProjectDevice.connectionKind ?? "",
costGroup: emptyProjectDevice.costGroup ?? "",
category: emptyProjectDevice.category ?? "",
quantity: String(emptyProjectDevice.quantity),
installedPowerPerUnitKw: String(emptyProjectDevice.installedPowerPerUnitKw),
demandFactor: String(emptyProjectDevice.demandFactor),
voltageV: String(emptyProjectDevice.voltageV ?? ""),
phaseCount: String(emptyProjectDevice.phaseCount ?? 1),
powerFactor: String(emptyProjectDevice.powerFactor ?? ""),
note: emptyProjectDevice.note ?? "",
powerPerUnit: String(emptyProjectDevice.powerPerUnit),
simultaneityFactor: String(emptyProjectDevice.simultaneityFactor),
cosPhi: String(emptyProjectDevice.cosPhi ?? ""),
remark: emptyProjectDevice.remark ?? "",
});
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
@@ -292,24 +329,142 @@ export default function ProjectDetailPage() {
const payload: CreateProjectDeviceInput = {
name: key === "name" ? value : device.name,
displayName: key === "displayName" ? value : device.displayName,
phaseType: device.phaseType,
connectionKind: device.connectionKind ?? undefined,
costGroup: device.costGroup ?? undefined,
category: key === "category" ? value : device.category ?? undefined,
quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor,
powerPerUnit: device.powerPerUnit,
simultaneityFactor: device.simultaneityFactor,
cosPhi: device.cosPhi ?? undefined,
remark: device.remark ?? undefined,
voltageV: device.voltageV ?? undefined,
phaseCount: device.phaseCount ?? undefined,
powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined,
};
try {
const updated = await updateProjectDevice(projectId, device.id, payload);
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
await openProjectDeviceSyncPreview(updated.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
}
}
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
if (!projectId) {
return;
}
try {
setProjectDeviceUndo(null);
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
const differentFields = new Set(
preview.rows.flatMap((row) => row.differences.map((difference) => difference.field))
);
setSelectedSyncFields(
projectDeviceSyncFields.filter((field) => field !== "displayName" && differentFields.has(field))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht geladen werden.");
}
}
function toggleSyncRow(rowId: string) {
setSelectedSyncRowIds((current) =>
current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]
);
}
function toggleSyncField(field: ProjectDeviceSyncField) {
setSelectedSyncFields((current) =>
current.includes(field) ? current.filter((entry) => entry !== field) : [...current, field]
);
}
async function handleSynchronizeProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await synchronizeProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
selectedSyncFields
);
setSyncPreview(result.preview);
setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
);
setProjectDeviceUndo({
kind: "synchronize",
projectDeviceId: syncPreview.projectDevice.id,
rows: result.undo.rows,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally {
setIsSaving(false);
}
}
async function handleDisconnectProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await disconnectProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds
);
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
setProjectDeviceUndo({
kind: "disconnect",
projectDeviceId: syncPreview.projectDevice.id,
rowIds: result.undo.rowIds,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally {
setIsSaving(false);
}
}
async function handleUndoProjectDeviceOperation() {
if (!projectId || !projectDeviceUndo) {
return;
}
setIsSaving(true);
setError(null);
try {
const preview =
projectDeviceUndo.kind === "synchronize"
? await restoreProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rows
)
: await reconnectProjectDeviceRows(
projectId,
projectDeviceUndo.projectDeviceId,
projectDeviceUndo.rowIds
);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
setProjectDeviceUndo(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) {
return;
@@ -591,7 +746,7 @@ export default function ProjectDetailPage() {
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateProjectDevice}>
<div className="col-12 col-md-3">
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Interner Name"
@@ -601,7 +756,7 @@ export default function ProjectDetailPage() {
}
/>
</div>
<div className="col-12 col-md-3">
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Anzeigename"
@@ -621,11 +776,32 @@ export default function ProjectDetailPage() {
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Anschlussart"
value={projectDeviceForm.connectionKind}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Kostengruppe"
value={projectDeviceForm.costGroup}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
min="0"
title="Anzahl"
value={projectDeviceForm.quantity}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value }))
@@ -638,11 +814,12 @@ export default function ProjectDetailPage() {
type="number"
step="0.01"
min="0"
value={projectDeviceForm.installedPowerPerUnitKw}
title="Leistung je Stück [kW]"
value={projectDeviceForm.powerPerUnit}
onChange={(event) =>
setProjectDeviceForm((current) => ({
...current,
installedPowerPerUnitKw: event.target.value,
powerPerUnit: event.target.value,
}))
}
/>
@@ -654,25 +831,51 @@ export default function ProjectDetailPage() {
step="0.01"
min="0"
max="1"
value={projectDeviceForm.demandFactor}
title="Gleichzeitigkeitsfaktor"
value={projectDeviceForm.simultaneityFactor}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
setProjectDeviceForm((current) => ({ ...current, simultaneityFactor: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
title="cos Phi"
value={projectDeviceForm.cosPhi}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<select
className="form-select"
value={projectDeviceForm.phaseCount}
title="Phasenart"
value={projectDeviceForm.phaseType}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
setProjectDeviceForm((current) => ({ ...current, phaseType: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
<option value="single_phase">1-ph</option>
<option value="three_phase">3-ph</option>
</select>
</div>
<div className="col-6 col-md-2">
<div className="col-12 col-md-10">
<input
className="form-control"
placeholder="Bemerkung"
value={projectDeviceForm.remark}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, remark: event.target.value }))
}
/>
</div>
<div className="col-12 col-md-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Gerät anlegen
</button>
@@ -707,10 +910,13 @@ export default function ProjectDetailPage() {
<tr>
<th>Interner Name</th>
<th>Anzeigename</th>
<th>Phasenart</th>
<th>Kategorie</th>
<th>Kostengruppe</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Gesamtleistung [kW]</th>
<th>Aktionen</th>
</tr>
</thead>
@@ -739,6 +945,7 @@ export default function ProjectDetailPage() {
}
/>
</td>
<td>{device.phaseType === "three_phase" ? "3-ph" : "1-ph"}</td>
<td>
<input
className="form-control form-control-sm"
@@ -750,11 +957,21 @@ export default function ProjectDetailPage() {
}
/>
</td>
<td>{device.costGroup ?? "-"}</td>
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>{device.powerPerUnit}</td>
<td>{device.simultaneityFactor}</td>
<td>{device.totalPower}</td>
<td>
<div className="btn-group btn-group-sm">
<button
className="btn btn-outline-primary"
type="button"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
disabled={isSaving}
>
Verknüpfungen
</button>
<button
className="btn btn-outline-secondary"
type="button"
@@ -777,7 +994,7 @@ export default function ProjectDetailPage() {
))}
{!projectDevices.length ? (
<tr>
<td colSpan={7} className="text-center text-secondary py-4">
<td colSpan={10} className="text-center text-secondary py-4">
Noch keine Projektgeräte vorhanden.
</td>
</tr>
@@ -785,6 +1002,126 @@ export default function ProjectDetailPage() {
</tbody>
</table>
</div>
{syncPreview ? (
<div className="card-body border-top">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
<div>
<h3 className="h6 mb-1">Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}</h3>
<p className="text-secondary small mb-0">
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
Schließen
</button>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
{projectDeviceSyncFields.map((field) => (
<label className="form-check" key={field}>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncFields.includes(field)}
onChange={() => toggleSyncField(field)}
/>
<span className="form-check-label">
{projectDeviceSyncFieldLabels[field]}
{field === "displayName" ? " (lokal)" : ""}
</span>
</label>
))}
</div>
<div className="table-responsive border rounded mb-3">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Auswahl</th>
<th>Verteilung / Stromkreis</th>
<th>Gerätezeile</th>
<th>Abweichungen</th>
</tr>
</thead>
<tbody>
{syncPreview.rows.map((row) => (
<tr key={row.rowId}>
<td>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncRowIds.includes(row.rowId)}
onChange={() => toggleSyncRow(row.rowId)}
/>
</td>
<td>
<strong>{row.distributionBoardName}</strong>
<div className="small text-secondary">
{row.equipmentIdentifier}
{row.circuitDisplayName ? ` ${row.circuitDisplayName}` : ""}
</div>
</td>
<td>{row.rowDisplayName}</td>
<td>
<div className="d-flex flex-wrap gap-1">
{row.differences.map((difference) => (
<span
className={`badge ${difference.isOverridden ? "text-bg-warning" : "text-bg-light"}`}
key={difference.field}
title={difference.isOverridden ? "Lokal überschrieben" : undefined}
>
{projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "")} {" "}
{String(difference.sourceValue ?? "")}
</span>
))}
{row.differences.length === 0 ? (
<span className="text-success small">Aktuell</span>
) : null}
</div>
</td>
</tr>
))}
{syncPreview.rows.length === 0 ? (
<tr>
<td colSpan={4} className="text-center text-secondary py-3">
Keine verknüpften Circuit-First-Gerätezeilen vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="d-flex flex-wrap gap-2">
{projectDeviceUndo ? (
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void handleUndoProjectDeviceOperation()}
disabled={isSaving}
>
Letzte Aktion rückgängig
</button>
) : null}
<button
className="btn btn-primary"
type="button"
onClick={() => void handleSynchronizeProjectDeviceRows()}
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
>
Auswahl synchronisieren
</button>
<button
className="btn btn-outline-danger"
type="button"
onClick={() => void handleDisconnectProjectDeviceRows()}
disabled={isSaving || selectedSyncRowIds.length === 0}
>
Auswahl trennen
</button>
</div>
</div>
) : null}
</section>
<div className="mt-4">
@@ -0,0 +1,24 @@
ALTER TABLE `project_devices` ADD COLUMN `phase_type` text NOT NULL DEFAULT 'single_phase';
--> statement-breakpoint
UPDATE `project_devices`
SET `phase_type` = CASE WHEN `phase_count` = 3 THEN 'three_phase' ELSE 'single_phase' END;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `connection_kind` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cost_group` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `power_per_unit` real NOT NULL DEFAULT 0;
--> statement-breakpoint
UPDATE `project_devices` SET `power_per_unit` = `installed_power_per_unit_kw`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `simultaneity_factor` real NOT NULL DEFAULT 1;
--> statement-breakpoint
UPDATE `project_devices` SET `simultaneity_factor` = `demand_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cos_phi` real;
--> statement-breakpoint
UPDATE `project_devices` SET `cos_phi` = `power_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `remark` text;
--> statement-breakpoint
UPDATE `project_devices` SET `remark` = `note`;
@@ -0,0 +1 @@
ALTER TABLE `circuits` ADD COLUMN `control_requirement` text;
+14
View File
@@ -64,6 +64,20 @@
"when": 1777800000000,
"tag": "0008_circuit_first_model",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1784743200000,
"tag": "0009_project_device_circuit_fields",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1784746800000,
"tag": "0010_circuit_control_requirement",
"breakpoints": true
}
]
}
@@ -1,7 +1,52 @@
import crypto from "node:crypto";
import { asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import { db } from "../client.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export interface CircuitDeviceRowUpdateInput {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
return {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
};
}
export class CircuitDeviceRowRepository {
async findById(rowId: string) {
@@ -25,6 +70,65 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(circuitDeviceRows.sortOrder));
}
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
return db
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
equipmentIdentifier: circuits.equipmentIdentifier,
circuitDisplayName: circuits.displayName,
circuitListId: circuitLists.id,
circuitListName: circuitLists.name,
distributionBoardId: distributionBoards.id,
distributionBoardName: distributionBoards.name,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.innerJoin(distributionBoards, eq(circuitLists.distributionBoardId, distributionBoards.id))
.where(
and(
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId),
eq(circuitLists.projectId, projectId)
)
)
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
}
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
if (rowIds.length === 0) {
return [];
}
return db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: {
circuitId: string;
linkedProjectDeviceId?: string;
@@ -76,50 +180,72 @@ export class CircuitDeviceRowRepository {
async update(
rowId: string,
input: {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
input: CircuitDeviceRowUpdateInput
) {
await db
.update(circuitDeviceRows)
.set({
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType ?? null,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
level: input.level ?? null,
roomId: input.roomId ?? null,
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
overriddenFields: input.overriddenFields ?? null,
})
.set(toUpdateValues(input))
.where(eq(circuitDeviceRows.id, rowId));
}
updateLinkedRowsTransactional(
projectDeviceId: string,
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
) {
db.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
.run();
if (result.changes !== 1) {
throw new Error("A disconnected row changed before the operation could be undone.");
}
}
});
}
async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
@@ -30,6 +30,7 @@ export class CircuitRepository {
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
@@ -53,6 +54,7 @@ export class CircuitRepository {
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
@@ -76,6 +78,7 @@ export class CircuitRepository {
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
@@ -97,6 +100,7 @@ export class CircuitRepository {
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
@@ -2,14 +2,23 @@ import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import { projectDevices } from "../schema/project-devices.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CreateProjectDeviceInput,
UpdateProjectDeviceInput,
} from "../../shared/validation/project-device.schemas.js";
function withTotalPower(row: typeof projectDevices.$inferSelect) {
return {
...row,
totalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
};
}
export class ProjectDeviceRepository {
async listByProject(projectId: string) {
return db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
return rows.map(withTotalPower);
}
async create(projectId: string, input: CreateProjectDeviceInput) {
@@ -19,16 +28,27 @@ export class ProjectDeviceRepository {
projectId,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
});
return { id, projectId, ...input };
const created = await this.findById(projectId, id);
if (!created) {
throw new Error("Failed to create project device.");
}
return created;
}
async findById(projectId: string, projectDeviceId: string) {
@@ -39,7 +59,7 @@ export class ProjectDeviceRepository {
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
)
.limit(1);
return row ?? null;
return row ? withTotalPower(row) : null;
}
async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) {
@@ -48,14 +68,21 @@ export class ProjectDeviceRepository {
.set({
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
})
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
+1
View File
@@ -24,6 +24,7 @@ export const circuits = sqliteTable(
rcdAssignment: text("rcd_assignment"),
terminalDesignation: text("terminal_designation"),
voltage: real("voltage"),
controlRequirement: text("control_requirement"),
status: text("status"),
isReserve: integer("is_reserve").notNull().default(0),
remark: text("remark"),
+8
View File
@@ -8,8 +8,16 @@ export const projectDevices = sqliteTable("project_devices", {
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
displayName: text("display_name").notNull(),
phaseType: text("phase_type").notNull().default("single_phase"),
connectionKind: text("connection_kind"),
costGroup: text("cost_group"),
category: text("category"),
quantity: integer("quantity").notNull(),
powerPerUnit: real("power_per_unit").notNull().default(0),
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
cosPhi: real("cos_phi"),
remark: text("remark"),
// Legacy compatibility fields. Keep these synchronized until the Consumer editor is removed.
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
demandFactor: real("demand_factor").notNull(),
voltageV: real("voltage_v"),
+1
View File
@@ -38,6 +38,7 @@ export interface CircuitTreeCircuit {
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
+1
View File
@@ -14,6 +14,7 @@ export interface Circuit {
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
+30 -1
View File
@@ -14,6 +14,11 @@ import type {
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
import {
parseOverriddenFields,
serializeOverriddenFields,
} from "./project-device-sync.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
@@ -108,6 +113,7 @@ export class CircuitWriteService {
rcdAssignment: input.rcdAssignment,
terminalDesignation: input.terminalDesignation,
voltage: input.voltage,
controlRequirement: input.controlRequirement,
status: input.status,
isReserve: input.isReserve ?? true,
remark: input.remark,
@@ -142,6 +148,7 @@ export class CircuitWriteService {
rcdAssignment: input.rcdAssignment ?? current.rcdAssignment ?? undefined,
terminalDesignation: input.terminalDesignation ?? current.terminalDesignation ?? undefined,
voltage: input.voltage ?? current.voltage ?? undefined,
controlRequirement: input.controlRequirement ?? current.controlRequirement ?? undefined,
status: input.status ?? current.status ?? undefined,
isReserve: input.isReserve ?? Boolean(current.isReserve),
remark: input.remark ?? current.remark ?? undefined,
@@ -203,6 +210,7 @@ export class CircuitWriteService {
rcdAssignment: circuit.rcdAssignment ?? undefined,
terminalDesignation: circuit.terminalDesignation ?? undefined,
voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined,
isReserve: false,
remark: circuit.remark ?? undefined,
@@ -218,6 +226,21 @@ export class CircuitWriteService {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
const inputValues = input as Record<string, unknown>;
const currentValues = current as unknown as Record<string, unknown>;
for (const field of projectDeviceSyncFields) {
if (
Object.prototype.hasOwnProperty.call(input, field) &&
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
) {
overrides.add(field);
}
}
overriddenFields = serializeOverriddenFields(overrides);
}
await this.deviceRowRepository.update(rowId, {
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
name: input.name ?? current.name,
@@ -235,7 +258,7 @@ export class CircuitWriteService {
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
remark: input.remark ?? current.remark ?? undefined,
overriddenFields: input.overriddenFields ?? current.overriddenFields ?? undefined,
overriddenFields,
});
return this.deviceRowRepository.findById(rowId);
}
@@ -267,6 +290,7 @@ export class CircuitWriteService {
rcdAssignment: circuit.rcdAssignment ?? undefined,
terminalDesignation: circuit.terminalDesignation ?? undefined,
voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined,
isReserve: true,
remark: circuit.remark ?? undefined,
@@ -343,6 +367,7 @@ export class CircuitWriteService {
rcdAssignment: sourceCircuit.rcdAssignment ?? undefined,
terminalDesignation: sourceCircuit.terminalDesignation ?? undefined,
voltage: sourceCircuit.voltage ?? undefined,
controlRequirement: sourceCircuit.controlRequirement ?? undefined,
status: sourceCircuit.status ?? undefined,
isReserve: true,
remark: sourceCircuit.remark ?? undefined,
@@ -365,6 +390,7 @@ export class CircuitWriteService {
rcdAssignment: targetCircuit.rcdAssignment ?? undefined,
terminalDesignation: targetCircuit.terminalDesignation ?? undefined,
voltage: targetCircuit.voltage ?? undefined,
controlRequirement: targetCircuit.controlRequirement ?? undefined,
status: targetCircuit.status ?? undefined,
isReserve: false,
remark: targetCircuit.remark ?? undefined,
@@ -465,6 +491,7 @@ export class CircuitWriteService {
rcdAssignment: sourceCircuit.rcdAssignment ?? undefined,
terminalDesignation: sourceCircuit.terminalDesignation ?? undefined,
voltage: sourceCircuit.voltage ?? undefined,
controlRequirement: sourceCircuit.controlRequirement ?? undefined,
status: sourceCircuit.status ?? undefined,
isReserve: true,
remark: sourceCircuit.remark ?? undefined,
@@ -487,6 +514,7 @@ export class CircuitWriteService {
rcdAssignment: targetCircuit.rcdAssignment ?? undefined,
terminalDesignation: targetCircuit.terminalDesignation ?? undefined,
voltage: targetCircuit.voltage ?? undefined,
controlRequirement: targetCircuit.controlRequirement ?? undefined,
status: targetCircuit.status ?? undefined,
isReserve: false,
remark: targetCircuit.remark ?? undefined,
@@ -602,6 +630,7 @@ export class CircuitWriteService {
rcdAssignment: circuit.rcdAssignment ?? undefined,
terminalDesignation: circuit.terminalDesignation ?? undefined,
voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark ?? undefined,
@@ -0,0 +1,29 @@
export interface ProjectDevicePlacementSource {
category?: string | null;
phaseType: "single_phase" | "three_phase";
}
export interface ProjectDevicePlacementSection {
key: string;
}
export type DefaultCircuitSectionKey = "lighting" | "single_phase" | "three_phase";
// Lighting classification takes precedence over the electrical phase because
// lighting devices use their dedicated numbering section by default.
export function inferProjectDeviceSectionKey(
device: ProjectDevicePlacementSource
): DefaultCircuitSectionKey {
const category = (device.category ?? "").trim().toLowerCase();
if (category.includes("light") || category.includes("beleuchtung")) {
return "lighting";
}
return device.phaseType;
}
export function isProjectDevicePlacementValid(
device: ProjectDevicePlacementSource,
section: ProjectDevicePlacementSection
): boolean {
return section.key === inferProjectDeviceSectionKey(device);
}
@@ -0,0 +1,227 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../shared/constants/project-device-sync-fields.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick<
CircuitDeviceRowRepository,
| "listLinkedByProjectDevice"
| "listLinkStatesByIds"
| "updateLinkedRowsTransactional"
| "disconnectLinkedRowsTransactional"
| "reconnectRowsTransactional"
>;
};
export interface ProjectDeviceSyncRestoreRow {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((field): field is ProjectDeviceSyncField =>
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
);
} catch {
return [];
}
}
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
const fieldSet = new Set(fields);
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
return unique.length > 0 ? JSON.stringify(unique) : undefined;
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field];
}
function valuesEqual(left: unknown, right: unknown) {
return (left ?? null) === (right ?? null);
}
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
return projectDeviceSyncFields
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
.map((field) => ({
field,
currentValue: row[field] ?? null,
sourceValue: sourceValue(projectDevice, field) ?? null,
isOverridden: parseOverriddenFields(row.overriddenFields).includes(field),
}));
}
export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
constructor(deps?: Partial<SyncDependencies>) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
}
async getPreview(projectId: string, projectDeviceId: string) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const rows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
return {
projectDevice: {
id: projectDevice.id,
name: projectDevice.name,
displayName: projectDevice.displayName,
},
rows: rows.map((row) => ({
rowId: row.id,
circuitId: row.circuitId,
equipmentIdentifier: row.equipmentIdentifier,
circuitDisplayName: row.circuitDisplayName,
circuitListId: row.circuitListId,
circuitListName: row.circuitListName,
distributionBoardId: row.distributionBoardId,
distributionBoardName: row.distributionBoardName,
rowDisplayName: row.displayName,
overriddenFields: parseOverriddenFields(row.overriddenFields),
differences: buildDifferences(projectDevice, row),
})),
};
}
async synchronize(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)];
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
rowId: row.id,
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
overriddenFields: parseOverriddenFields(row.overriddenFields),
}));
const changes = [];
for (const row of selectedRows) {
const next = this.copyRow(row);
for (const field of selectedFields) {
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
}
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
(field) => !selectedFields.includes(field)
);
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
changes.push({ rowId: row.id, input: next });
}
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return {
preview: await this.getPreview(projectId, projectDeviceId),
undo: { rows: undoRows },
};
}
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id);
this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds);
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
}
async restore(
projectId: string,
projectDeviceId: string,
restoreRows: ProjectDeviceSyncRestoreRow[]
) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
const changes = selectedRows.map((row) => {
const restore = restoreById.get(row.id)!;
const next = this.copyRow(row);
for (const [field, value] of Object.entries(restore.values) as Array<
[ProjectDeviceSyncField, string | number | null]
>) {
Object.assign(next, { [field]: value ?? undefined });
}
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
return { rowId: row.id, input: next };
});
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId);
}
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
const uniqueRowIds = [...new Set(rowIds)];
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
throw new Error("One or more rows cannot be reconnected.");
}
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds);
return this.getPreview(projectId, projectDeviceId);
}
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
const uniqueRowIds = [...new Set(rowIds)];
const byId = new Map(linkedRows.map((row) => [row.id, row]));
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[];
if (selectedRows.length !== uniqueRowIds.length) {
throw new Error("One or more rows are not linked to this project device.");
}
return selectedRows;
}
private copyRow(row: LinkedRow) {
return {
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType ?? undefined,
connectionKind: row.connectionKind ?? undefined,
costGroup: row.costGroup ?? undefined,
category: row.category ?? undefined,
level: row.level ?? undefined,
roomId: row.roomId ?? undefined,
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined,
};
}
}
File diff suppressed because it is too large Load Diff
+69 -5
View File
@@ -91,8 +91,17 @@ export interface ProjectDeviceDto {
projectId: string;
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
totalPower: number;
cosPhi: number | null;
remark: string | null;
// Legacy aliases retained until the old Consumer editor is removed.
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV: number | null;
@@ -159,14 +168,66 @@ export interface CreateGlobalDeviceInput {
export interface CreateProjectDeviceInput {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind?: string;
costGroup?: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
voltageV?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
}
export interface ProjectDeviceSyncDifferenceDto {
field: ProjectDeviceSyncField;
currentValue: string | number | null;
sourceValue: string | number | null;
isOverridden: boolean;
}
export interface ProjectDeviceSyncRowDto {
rowId: string;
circuitId: string;
equipmentIdentifier: string;
circuitDisplayName: string | null;
circuitListId: string;
circuitListName: string;
distributionBoardId: string;
distributionBoardName: string;
rowDisplayName: string;
overriddenFields: ProjectDeviceSyncField[];
differences: ProjectDeviceSyncDifferenceDto[];
}
export interface ProjectDeviceSyncPreviewDto {
projectDevice: {
id: string;
name: string;
displayName: string;
};
rows: ProjectDeviceSyncRowDto[];
}
export interface ProjectDeviceSyncRestoreRowDto {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export interface ProjectDeviceSyncResultDto {
preview: ProjectDeviceSyncPreviewDto;
undo: {
rows: ProjectDeviceSyncRestoreRowDto[];
};
}
export interface ProjectDeviceDisconnectResultDto {
disconnectedRowIds: string[];
undo: {
rowIds: string[];
};
}
export interface CircuitTreeDeviceRowDto {
@@ -209,6 +270,7 @@ export interface CircuitTreeCircuitDto {
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
@@ -256,6 +318,7 @@ export interface CreateCircuitInputDto {
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve?: boolean;
remark?: string;
@@ -285,3 +348,4 @@ export interface CreateCircuitDeviceRowInputDto {
}
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+68
View File
@@ -10,6 +10,10 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceDisconnectResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDeviceSyncResultDto,
ProjectDto,
RoomDto,
UpdateConsumerInput,
@@ -19,6 +23,7 @@ import type {
CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -285,3 +290,66 @@ export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: str
method: "POST",
});
}
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/links`
);
}
export function synchronizeProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[],
fields: ProjectDeviceSyncField[]
) {
return request<ProjectDeviceSyncResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
{
method: "POST",
body: JSON.stringify({ rowIds, fields }),
}
);
}
export function disconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<ProjectDeviceDisconnectResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
}
);
}
export function restoreProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rows: ProjectDeviceSyncRestoreRowDto[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
{
method: "POST",
body: JSON.stringify({ rows }),
}
);
}
export function reconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
}
);
}
@@ -0,0 +1,79 @@
export type GridInsertionRowType =
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "reserveCircuit"
| "placeholder";
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
export interface GridInsertionSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
sectionId: string;
circuitId?: string;
deviceId?: string;
}
export type GridInsertionIntent =
| { kind: "circuit"; sectionId: string; afterCircuitId?: string }
| { kind: "device"; sectionId: string; circuitId: string; afterDeviceRowId?: string };
export function resolveGridInsertionIntent(
selection: GridInsertionSelection | null,
fallbackSectionId?: string
): GridInsertionIntent | null {
if (!selection) {
return fallbackSectionId ? { kind: "circuit", sectionId: fallbackSectionId } : null;
}
if (selection.rowType === "placeholder") {
return { kind: "circuit", sectionId: selection.sectionId };
}
const targetsDevice =
selection.rowType === "deviceRow" ||
((selection.rowType === "circuitCompact" || selection.rowType === "reserveCircuit") &&
selection.cellKind === "deviceField");
if (targetsDevice && selection.circuitId) {
return {
kind: "device",
sectionId: selection.sectionId,
circuitId: selection.circuitId,
afterDeviceRowId: selection.deviceId,
};
}
if (selection.circuitId) {
return {
kind: "circuit",
sectionId: selection.sectionId,
afterCircuitId: selection.circuitId,
};
}
return { kind: "circuit", sectionId: selection.sectionId };
}
export function getInsertionSortOrder(
orderedEntries: Array<{ id: string; sortOrder: number }>,
afterId?: string
): number {
if (orderedEntries.length === 0) {
return 10;
}
if (!afterId) {
return Math.max(...orderedEntries.map((entry) => entry.sortOrder)) + 10;
}
const entries = [...orderedEntries].sort((left, right) => left.sortOrder - right.sortOrder);
const index = entries.findIndex((entry) => entry.id === afterId);
if (index < 0) {
return entries[entries.length - 1].sortOrder + 10;
}
const current = entries[index].sortOrder;
const next = entries[index + 1]?.sortOrder;
return next === undefined ? current + 10 : current + (next - current) / 2;
}
+293
View File
@@ -0,0 +1,293 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
export type CellKey =
| "equipmentIdentifier"
| "displayName"
| "quantity"
| "powerPerUnit"
| "simultaneityFactor"
| "rowTotalPower"
| "circuitTotalPower"
| "protectionSummary"
| "cableSummary"
| "roomSummary"
| "remark"
| "technicalName"
| "connectionKind"
| "phaseType"
| "costGroup"
| "category"
| "level"
| "roomNumberSnapshot"
| "roomNameSnapshot"
| "cosPhi"
| "protectionType"
| "protectionRatedCurrent"
| "protectionCharacteristic"
| "cableType"
| "cableCrossSection"
| "cableLength"
| "rcdAssignment"
| "terminalDesignation"
| "voltage"
| "controlRequirement"
| "status"
| "isReserve";
export type RowType =
| "section"
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "reserveCircuit"
| "placeholder";
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
export type GridValue = string | number | boolean | undefined;
export interface ColumnDef {
key: CellKey;
label: string;
numeric?: boolean;
defaultVisible?: boolean;
locked?: boolean;
}
// BMK stays locked as the first column because circuit identity and circuit drag
// handles depend on an always-visible equipment identifier.
export const allColumns: ColumnDef[] = [
{ key: "equipmentIdentifier", label: "Betriebsmittelkennzeichen", defaultVisible: true, locked: true },
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
{ key: "powerPerUnit", label: "Leistung / Gerät", numeric: true, defaultVisible: true },
{ key: "simultaneityFactor", label: "Gleichzeitigkeit", numeric: true, defaultVisible: true },
{ key: "rowTotalPower", label: "Zeilensumme", numeric: true, defaultVisible: true },
{ key: "circuitTotalPower", label: "Stromkreissumme", numeric: true, defaultVisible: true },
{ key: "protectionSummary", label: "Schutz", defaultVisible: true },
{ key: "cableSummary", label: "Kabel", defaultVisible: true },
{ key: "roomSummary", label: "Raum", defaultVisible: true },
{ key: "remark", label: "Bemerkung", defaultVisible: true },
{ key: "technicalName", label: "Technischer Name" },
{ key: "connectionKind", label: "Anschlussart" },
{ key: "phaseType", label: "Phasenart" },
{ key: "costGroup", label: "Kostengruppe" },
{ key: "category", label: "Kategorie" },
{ key: "level", label: "Ebene" },
{ key: "roomNumberSnapshot", label: "Raumnummer" },
{ key: "roomNameSnapshot", label: "Raumname" },
{ key: "cosPhi", label: "cos φ", numeric: true },
{ key: "protectionType", label: "Schutzart" },
{ key: "protectionRatedCurrent", label: "Bemessungsstrom", numeric: true },
{ key: "protectionCharacteristic", label: "Charakteristik" },
{ key: "cableType", label: "Kabeltyp" },
{ key: "cableCrossSection", label: "Kabelquerschnitt" },
{ key: "cableLength", label: "Kabellänge", numeric: true },
{ key: "rcdAssignment", label: "RCD-Zuordnung" },
{ key: "terminalDesignation", label: "Klemmenbezeichnung" },
{ key: "voltage", label: "Spannung", numeric: true },
{ key: "controlRequirement", label: "Steuerungsanforderung" },
{ key: "status", label: "Status" },
{ key: "isReserve", label: "Reserve" },
];
export const defaultVisibleColumnKeys = allColumns
.filter((column) => column.defaultVisible)
.map((column) => column.key);
const deviceOnlyColumns = new Set<CellKey>([
"quantity",
"powerPerUnit",
"simultaneityFactor",
"rowTotalPower",
"roomSummary",
"technicalName",
"connectionKind",
"phaseType",
"costGroup",
"category",
"level",
"roomNumberSnapshot",
"roomNameSnapshot",
"cosPhi",
]);
export const circuitOnlyColumns = new Set<CellKey>([
"equipmentIdentifier",
"protectionSummary",
"cableSummary",
"circuitTotalPower",
"protectionType",
"protectionRatedCurrent",
"protectionCharacteristic",
"cableType",
"cableCrossSection",
"cableLength",
"rcdAssignment",
"terminalDesignation",
"voltage",
"controlRequirement",
"status",
"isReserve",
]);
export const deviceFieldKeys = new Set<CellKey>([
"displayName",
"technicalName",
"connectionKind",
"phaseType",
"costGroup",
"category",
"level",
"roomSummary",
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
]);
const circuitFieldKeys = new Set<CellKey>([
"equipmentIdentifier",
"displayName",
"protectionType",
"protectionRatedCurrent",
"protectionCharacteristic",
"protectionSummary",
"cableType",
"cableCrossSection",
"cableLength",
"cableSummary",
"rcdAssignment",
"terminalDesignation",
"voltage",
"controlRequirement",
"status",
"isReserve",
"remark",
]);
export function formatValue(value: GridValue): string {
if (value === undefined || value === null || value === "") {
return "-";
}
if (typeof value === "boolean") {
return value ? "Ja" : "Nein";
}
return String(value);
}
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
const trimmed = draft.trim();
if (trimmed === "") {
return undefined;
}
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid number in ${cellKey}`);
}
return parsed;
}
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
switch (key) {
case "technicalName": return device.name;
case "displayName": return device.displayName || device.name;
case "phaseType": return device.phaseType;
case "connectionKind": return device.connectionKind;
case "costGroup": return device.costGroup;
case "category": return device.category;
case "level": return device.level;
case "roomSummary": return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
case "roomNumberSnapshot": return device.roomNumberSnapshot;
case "roomNameSnapshot": return device.roomNameSnapshot;
case "quantity": return device.quantity;
case "powerPerUnit": return device.powerPerUnit;
case "simultaneityFactor": return device.simultaneityFactor;
case "cosPhi": return device.cosPhi;
case "rowTotalPower": return device.rowTotalPower;
case "remark": return device.remark;
default: return undefined;
}
}
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
switch (key) {
case "equipmentIdentifier": return circuit.equipmentIdentifier;
case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType": return circuit.protectionType;
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
case "protectionCharacteristic": return circuit.protectionCharacteristic;
case "protectionSummary": {
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
? `${circuit.protectionRatedCurrent}A`
: "";
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
}
case "cableSummary": {
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
}
case "cableType": return circuit.cableType;
case "cableCrossSection": return circuit.cableCrossSection;
case "cableLength": return circuit.cableLength;
case "rcdAssignment": return circuit.rcdAssignment;
case "terminalDesignation": return circuit.terminalDesignation;
case "voltage": return circuit.voltage;
case "controlRequirement": return circuit.controlRequirement;
case "status": return circuit.status;
case "isReserve": return circuit.isReserve;
case "remark": return circuit.remark;
default: return undefined;
}
}
export function normalizeFilterValue(value: GridValue): string {
return formatValue(value);
}
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
const firstRow = circuit.deviceRows[0];
if (circuitOnlyColumns.has(key)) {
return getCircuitValue(circuit, key);
}
if (deviceOnlyColumns.has(key)) {
return firstRow ? getDeviceValue(firstRow, key) : undefined;
}
if (key === "displayName") {
if (circuit.displayName) return circuit.displayName;
return firstRow ? getDeviceValue(firstRow, key) : undefined;
}
if (key === "remark") {
if (circuit.remark) return circuit.remark;
return firstRow ? getDeviceValue(firstRow, key) : undefined;
}
return getCircuitValue(circuit, key);
}
export function compareSortValues(left: GridValue, right: GridValue): number {
const leftValue = left === undefined || left === null ? "" : left;
const rightValue = right === undefined || right === null ? "" : right;
if (typeof leftValue === "number" && typeof rightValue === "number") {
return leftValue - rightValue;
}
return String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: "base", numeric: true });
}
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
if (rowType === "section") return "readonly";
if (rowType === "placeholder") {
if (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField";
return "readonly";
}
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
if (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField";
}
return "readonly";
}
@@ -0,0 +1,186 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../types.js";
import {
allColumns,
circuitOnlyColumns,
compareSortValues,
deviceFieldKeys,
getBlockSortValue,
getCellKind,
getCircuitValue,
getDeviceValue,
normalizeFilterValue,
} from "./circuit-grid-model";
import type {
CellKey,
CellKind,
ColumnDef,
GridValue,
RowType,
} from "./circuit-grid-model";
export type ColumnFilters = Partial<Record<CellKey, string[]>>;
export interface GridSortState {
key: CellKey;
direction: "asc" | "desc";
}
export interface VisibleGridCell {
cellKey: CellKey;
editable: boolean;
kind: CellKind;
value: GridValue;
}
export interface VisibleGridRow {
rowKey: string;
rowType: RowType;
sectionId: string;
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
cells: VisibleGridCell[];
}
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
const values = new Set<string>();
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
}
if (!circuitOnlyColumns.has(key)) {
for (const device of circuit.deviceRows) {
values.add(normalizeFilterValue(getDeviceValue(device, key)));
}
}
if (values.size === 0 && deviceFieldKeys.has(key)) {
values.add(normalizeFilterValue(undefined));
}
return values;
}
export function getDistinctFilterValues(
sections: readonly CircuitTreeSectionDto[],
columns: readonly ColumnDef[]
): Record<CellKey, string[]> {
const result = {} as Record<CellKey, string[]>;
for (const column of columns) {
const values = new Set<string>();
for (const section of sections) {
for (const circuit of section.circuits) {
for (const value of getCircuitBlockFilterValues(circuit, column.key)) {
values.add(value);
}
}
}
result[column.key] = [...values].sort((left, right) =>
left.localeCompare(right, undefined, { numeric: true })
);
}
return result;
}
export function filterAndSortCircuitSections(
sections: readonly CircuitTreeSectionDto[],
columnFilters: ColumnFilters,
sortState: GridSortState | null
): CircuitTreeSectionDto[] {
const activeFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0) as Array<
[CellKey, string[]]
>;
return sections
.map((section) => {
let circuits = section.circuits.filter((circuit) =>
activeFilters.every(([key, selectedValues]) => {
const blockValues = getCircuitBlockFilterValues(circuit, key);
return selectedValues.some((selectedValue) => blockValues.has(selectedValue));
})
);
if (sortState) {
// Circuit blocks are sorted as units; their device row membership/order is untouched.
circuits = [...circuits].sort((left, right) => {
const comparison = compareSortValues(
getBlockSortValue(left, sortState.key),
getBlockSortValue(right, sortState.key)
);
return sortState.direction === "asc" ? comparison : -comparison;
});
}
return { ...section, circuits };
})
.filter((section) => section.circuits.length > 0);
}
function makeVisibleGridRow(
rowType: RowType,
sectionId: string,
circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto
): VisibleGridRow {
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((column): VisibleGridCell => {
const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue;
if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
value = getDeviceValue(device, column.key);
} else if (kind === "circuitField" && circuit) {
value = getCircuitValue(circuit, column.key);
} else if (column.key === "circuitTotalPower" && circuit) {
value = circuit.circuitTotalPower;
} else if (column.key === "rowTotalPower" && device) {
value = device.rowTotalPower;
}
return { cellKey: column.key, editable, kind, value };
});
return { rowKey, rowType, sectionId, circuit, device, cells };
}
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
const rows: VisibleGridRow[] = [];
for (const section of sections) {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: allColumns.map((column) => ({
cellKey: column.key,
editable: false,
kind: "readonly",
value: undefined,
})),
});
for (const circuit of section.circuits) {
if (circuit.deviceRows.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else {
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) {
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
}
}
}
rows.push(makeVisibleGridRow("placeholder", section.id));
}
return rows;
}
+81
View File
@@ -0,0 +1,81 @@
import type {
GridInsertionCellKind,
GridInsertionRowType,
} from "./circuit-grid-insertion.js";
export interface GridDeleteSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
circuitId?: string;
deviceId?: string;
}
export type GridDeleteIntent =
| { kind: "circuit"; circuitId: string }
| { kind: "device"; deviceId: string };
export interface CircuitIdentifierRecord {
id: string;
equipmentIdentifier: string;
}
export function resolveGridDeleteIntent(selection: GridDeleteSelection | null): GridDeleteIntent | null {
if (!selection || selection.rowType === "placeholder") {
return null;
}
const targetsDevice =
selection.rowType === "deviceRow" ||
(selection.rowType === "circuitCompact" && selection.cellKind === "deviceField");
if (targetsDevice && selection.deviceId) {
return { kind: "device", deviceId: selection.deviceId };
}
if (selection.circuitId) {
return { kind: "circuit", circuitId: selection.circuitId };
}
return null;
}
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toUpperCase();
}
export function hasEquipmentIdentifierConflict(
circuits: CircuitIdentifierRecord[],
currentCircuitId: string,
candidate: string
): boolean {
const normalizedCandidate = normalizeEquipmentIdentifier(candidate);
if (!normalizedCandidate) {
return false;
}
return circuits.some(
(circuit) =>
circuit.id !== currentCircuitId &&
normalizeEquipmentIdentifier(circuit.equipmentIdentifier) === normalizedCandidate
);
}
export function findDuplicateEquipmentIdentifierCircuitIds(
circuits: CircuitIdentifierRecord[]
): string[] {
const idsByIdentifier = new Map<string, string[]>();
for (const circuit of circuits) {
const normalized = normalizeEquipmentIdentifier(circuit.equipmentIdentifier);
if (!normalized) {
continue;
}
const ids = idsByIdentifier.get(normalized) ?? [];
ids.push(circuit.id);
idsByIdentifier.set(normalized, ids);
}
return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat();
}
export function requiresCrossSectionMoveConfirmation(
sourceSectionIds: string[],
targetSectionId: string
): boolean {
return sourceSectionIds.some((sectionId) => sectionId !== targetSectionId);
}
@@ -109,6 +109,7 @@ export async function getCircuitTree(req: Request, res: Response) {
rcdAssignment: circuit.rcdAssignment ?? undefined,
terminalDesignation: circuit.terminalDesignation ?? undefined,
voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark ?? undefined,
@@ -69,12 +69,12 @@ export async function copyProjectDeviceToGlobal(req: Request, res: Response) {
displayName: source.displayName,
category: source.category ?? undefined,
quantity: source.quantity,
installedPowerPerUnitKw: source.installedPowerPerUnitKw,
demandFactor: source.demandFactor,
installedPowerPerUnitKw: source.powerPerUnit,
demandFactor: source.simultaneityFactor,
voltageV: source.voltageV ?? undefined,
phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined,
powerFactor: source.powerFactor ?? undefined,
note: source.note ?? undefined,
phaseCount: source.phaseType === "three_phase" ? 3 : 1,
powerFactor: source.cosPhi ?? undefined,
note: source.remark ?? undefined,
});
return res.status(201).json(created);
@@ -1,15 +1,19 @@
import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
import {
createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema,
reconnectProjectDeviceRowsSchema,
restoreProjectDeviceRowsSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceSchema,
} from "../../shared/validation/project-device.schemas.js";
const globalDeviceRepository = new GlobalDeviceRepository();
const consumerRepository = new ConsumerRepository();
const projectDeviceRepository = new ProjectDeviceRepository();
const projectDeviceSyncService = new ProjectDeviceSyncService();
export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params;
@@ -45,16 +49,8 @@ export async function updateProjectDevice(req: Request, res: Response) {
}
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
await consumerRepository.syncLinkedConsumersFromProjectDevice(projectId, projectDeviceId, {
displayName: parsed.data.displayName,
category: parsed.data.category,
quantity: parsed.data.quantity,
installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw,
demandFactor: parsed.data.demandFactor,
phaseCount: parsed.data.phaseCount,
powerFactor: parsed.data.powerFactor,
note: parsed.data.note,
});
// Linked rows are synchronized only through the explicit review flow.
// Updating a project device must never silently overwrite local circuit-list values.
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!row) {
return res.status(404).json({ error: "Project device not found" });
@@ -86,15 +82,101 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
const created = await projectDeviceRepository.create(projectId, {
name: source.name,
displayName: source.displayName,
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
category: source.category ?? undefined,
quantity: source.quantity,
installedPowerPerUnitKw: source.installedPowerPerUnitKw,
demandFactor: source.demandFactor,
powerPerUnit: source.installedPowerPerUnitKw,
simultaneityFactor: source.demandFactor,
cosPhi: source.powerFactor ?? undefined,
remark: source.note ?? undefined,
voltageV: source.voltageV ?? undefined,
phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined,
powerFactor: source.powerFactor ?? undefined,
note: source.note ?? undefined,
});
return res.status(201).json(created);
}
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
try {
const preview = await projectDeviceSyncService.getPreview(projectId, projectDeviceId);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to load sync preview." });
}
}
export async function synchronizeProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = synchronizeProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = await projectDeviceSyncService.synchronize(
projectId,
projectDeviceId,
parsed.data.rowIds,
parsed.data.fields
);
return res.json(result);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
}
}
export async function restoreProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
}
}
export async function disconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = disconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(result);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
}
}
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
}
}
@@ -3,7 +3,12 @@ import {
copyGlobalDeviceToProject,
createProjectDevice,
deleteProjectDevice,
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listProjectDevicesByProject,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows,
updateProjectDevice,
} from "../controllers/project-device.controller.js";
@@ -12,5 +17,10 @@ export const projectDeviceRouter = Router();
projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject);
projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
@@ -0,0 +1,15 @@
export const projectDeviceSyncFields = [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
] as const;
export type ProjectDeviceSyncField = (typeof projectDeviceSyncFields)[number];
+1
View File
@@ -14,6 +14,7 @@ export const createCircuitSchema = z.object({
rcdAssignment: z.string().optional(),
terminalDesignation: z.string().optional(),
voltage: z.number().positive().optional(),
controlRequirement: z.string().optional(),
status: z.string().optional(),
isReserve: z.boolean().optional(),
remark: z.string().optional(),
@@ -1,19 +1,60 @@
import { z } from "zod";
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: z.string().optional(),
costGroup: z.string().optional(),
category: z.string().optional(),
quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().optional(),
// Transitional metadata used when importing from the legacy global-device library.
voltageV: z.number().positive().optional(),
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(),
});
export const updateProjectDeviceSchema = createProjectDeviceSchema;
export const synchronizeProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
});
export const disconnectProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1),
});
const projectDeviceRestoreValuesSchema = z.object({
name: z.string().optional(),
displayName: z.string().optional(),
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
connectionKind: z.string().nullable().optional(),
costGroup: z.string().nullable().optional(),
category: z.string().nullable().optional(),
quantity: z.number().min(0).optional(),
powerPerUnit: z.number().min(0).optional(),
simultaneityFactor: z.number().min(0).max(1).optional(),
cosPhi: z.number().min(0).max(1).nullable().optional(),
remark: z.string().nullable().optional(),
});
export const restoreProjectDeviceRowsSchema = z.object({
rows: z
.array(
z.object({
rowId: z.string().min(1),
values: projectDeviceRestoreValuesSchema,
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
})
)
.min(1),
});
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getInsertionSortOrder,
resolveGridInsertionIntent,
} from "../src/frontend/utils/circuit-grid-insertion.js";
describe("circuit grid insertion", () => {
it("inserts a circuit after circuit-level selections", () => {
assert.deepEqual(
resolveGridInsertionIntent(
{
rowType: "circuitCompact",
cellKind: "circuitField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-1",
},
"fallback"
),
{ kind: "circuit", sectionId: "section-1", afterCircuitId: "circuit-1" }
);
});
it("inserts a device after device-level selections in compact and expanded circuits", () => {
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "circuitCompact",
cellKind: "deviceField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-1",
}),
{
kind: "device",
sectionId: "section-1",
circuitId: "circuit-1",
afterDeviceRowId: "row-1",
}
);
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "deviceRow",
cellKind: "deviceField",
sectionId: "section-1",
circuitId: "circuit-1",
deviceId: "row-2",
}),
{
kind: "device",
sectionId: "section-1",
circuitId: "circuit-1",
afterDeviceRowId: "row-2",
}
);
});
it("uses the active section for placeholders and missing selections", () => {
assert.deepEqual(
resolveGridInsertionIntent({
rowType: "placeholder",
cellKind: "deviceField",
sectionId: "section-1",
}),
{ kind: "circuit", sectionId: "section-1" }
);
assert.deepEqual(resolveGridInsertionIntent(null, "section-2"), {
kind: "circuit",
sectionId: "section-2",
});
});
it("creates stable sort values between neighbours or at the end", () => {
const entries = [
{ id: "a", sortOrder: 10 },
{ id: "b", sortOrder: 20 },
{ id: "c", sortOrder: 30 },
];
assert.equal(getInsertionSortOrder(entries, "a"), 15);
assert.equal(getInsertionSortOrder(entries, "c"), 40);
assert.equal(getInsertionSortOrder(entries), 40);
assert.equal(getInsertionSortOrder([], "missing"), 10);
});
});
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
allColumns,
getBlockSortValue,
getCellKind,
getCircuitValue,
getDeviceValue,
parseNumeric,
} from "../src/frontend/utils/circuit-grid-model.js";
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
const device: CircuitTreeDeviceRowDto = {
id: "row-1",
sortOrder: 10,
name: "Technical light",
displayName: "Office light",
phaseType: "single_phase",
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Office",
quantity: 2,
powerPerUnit: 0.05,
simultaneityFactor: 0.8,
rowTotalPower: 0.08,
remark: "Device remark",
};
const circuit: CircuitTreeCircuitDto = {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Lighting circuit",
sortOrder: 10,
protectionType: "MCB",
protectionRatedCurrent: 16,
protectionCharacteristic: "B",
cableType: "NYM-J",
cableCrossSection: "1.5 mm²",
cableLength: 20,
voltage: 230,
controlRequirement: "DALI",
isReserve: false,
remark: "Circuit remark",
circuitTotalPower: 0.08,
deviceRows: [device],
};
describe("circuit grid model", () => {
it("keeps the equipment identifier locked as the first column", () => {
assert.equal(allColumns[0].key, "equipmentIdentifier");
assert.equal(allColumns[0].locked, true);
});
it("maps shared fields to the correct level for every row shape", () => {
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField");
assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField");
assert.equal(getCellKind("placeholder", "displayName"), "deviceField");
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
});
it("projects circuit and device values without mixing ownership", () => {
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
assert.equal(getCircuitValue(circuit, "voltage"), 230);
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
});
it("uses circuit display values for block sorting and falls back to the first device", () => {
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
assert.equal(getBlockSortValue(circuit, "quantity"), 2);
});
it("parses numeric drafts and rejects invalid values", () => {
assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Invalid number/);
});
});
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildVisibleGridRows,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../src/frontend/utils/circuit-grid-projection.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function makeDevice(
id: string,
displayName: string,
roomNumberSnapshot: string,
sortOrder: number
): CircuitTreeDeviceRowDto {
return {
id,
sortOrder,
name: displayName,
displayName,
roomNumberSnapshot,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
rowTotalPower: 0.1,
};
}
function makeCircuit(
id: string,
equipmentIdentifier: string,
displayName: string,
deviceRows: CircuitTreeDeviceRowDto[],
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier,
displayName,
sortOrder,
isReserve: deviceRows.length === 0,
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
deviceRows,
};
}
const multiDeviceCircuit = makeCircuit(
"circuit-multi",
"-1F2",
"Office lighting",
[makeDevice("device-office", "Office light", "1.01", 10), makeDevice("device-store", "Store light", "1.02", 20)],
20
);
const compactCircuit = makeCircuit(
"circuit-compact",
"-1F1",
"Corridor lighting",
[makeDevice("device-corridor", "Corridor light", "1.03", 10)],
10
);
const reserveCircuit = makeCircuit("circuit-reserve", "-1F3", "Reserve", [], 30);
const sections: CircuitTreeSectionDto[] = [
{
id: "section-1",
key: "lighting",
displayName: "Lighting",
prefix: "-1F",
sortOrder: 10,
circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit],
},
];
describe("circuit grid projection", () => {
it("keeps every device row in a circuit block when one device matches a filter", () => {
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
assert.deepEqual(result.map((section) => section.circuits.map((circuit) => circuit.id)), [["circuit-multi"]]);
assert.deepEqual(result[0].circuits[0].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
});
it("combines column filters across the complete circuit block", () => {
const result = filterAndSortCircuitSections(
sections,
{ equipmentIdentifier: ["-1F2"], roomNumberSnapshot: ["1.02"] },
null
);
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-multi"]);
});
it("treats missing device values as missing without matching populated circuits", () => {
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["-"] }, null);
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-reserve"]);
});
it("sorts complete circuit blocks without changing device row order", () => {
const result = filterAndSortCircuitSections(sections, {}, { key: "equipmentIdentifier", direction: "desc" });
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), [
"circuit-reserve",
"circuit-multi",
"circuit-compact",
]);
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
});
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRows(sections);
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitCompact",
"circuitSummary",
"deviceRow",
"deviceRow",
"reserveCircuit",
"placeholder",
]);
assert.equal(rows.at(-1)?.rowKey, "placeholder:section-1");
assert.equal(
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
"-frei-"
);
});
it("collects filter options from both circuit and device values", () => {
const values = getDistinctFilterValues(sections, [
{ key: "displayName", label: "Display name" },
{ key: "roomNumberSnapshot", label: "Room number" },
]);
assert.ok(values.displayName.includes("Office lighting"));
assert.ok(values.displayName.includes("Store light"));
assert.ok(values.roomNumberSnapshot.includes("1.03"));
});
});
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent,
} from "../src/frontend/utils/circuit-grid-safety.js";
describe("circuit grid safety", () => {
it("deletes only the device for device-level selections", () => {
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "circuitCompact",
cellKind: "deviceField",
circuitId: "circuit-1",
deviceId: "device-1",
}),
{ kind: "device", deviceId: "device-1" }
);
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "deviceRow",
cellKind: "deviceField",
circuitId: "circuit-1",
deviceId: "device-2",
}),
{ kind: "device", deviceId: "device-2" }
);
});
it("deletes the whole circuit for circuit-level selections", () => {
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "circuitCompact",
cellKind: "circuitField",
circuitId: "circuit-1",
deviceId: "device-1",
}),
{ kind: "circuit", circuitId: "circuit-1" }
);
assert.deepEqual(
resolveGridDeleteIntent({
rowType: "reserveCircuit",
cellKind: "deviceField",
circuitId: "circuit-2",
}),
{ kind: "circuit", circuitId: "circuit-2" }
);
});
it("does nothing on the free placeholder", () => {
assert.equal(
resolveGridDeleteIntent({ rowType: "placeholder", cellKind: "deviceField" }),
null
);
});
it("detects normalized BMK conflicts while excluding the edited circuit", () => {
const circuits = [
{ id: "a", equipmentIdentifier: "-2F1" },
{ id: "b", equipmentIdentifier: "-2F2" },
];
assert.equal(hasEquipmentIdentifierConflict(circuits, "b", " -2f1 "), true);
assert.equal(hasEquipmentIdentifierConflict(circuits, "a", "-2f1"), false);
assert.equal(hasEquipmentIdentifierConflict(circuits, "b", "-2F3"), false);
});
it("returns every circuit participating in a normalized duplicate", () => {
assert.deepEqual(
findDuplicateEquipmentIdentifierCircuitIds([
{ id: "a", equipmentIdentifier: "-2F1" },
{ id: "b", equipmentIdentifier: " -2f1 " },
{ id: "c", equipmentIdentifier: "-2F2" },
]).sort(),
["a", "b"]
);
});
it("requires confirmation when any moved device crosses a section boundary", () => {
assert.equal(requiresCrossSectionMoveConfirmation(["single"], "single"), false);
assert.equal(requiresCrossSectionMoveConfirmation(["single", "single"], "single"), false);
assert.equal(requiresCrossSectionMoveConfirmation(["single"], "three"), true);
assert.equal(requiresCrossSectionMoveConfirmation(["single", "three"], "three"), true);
});
});
+80
View File
@@ -3,8 +3,58 @@ import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
import { db } from "../src/db/client.js";
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
describe("circuit write service rules", () => {
it("accepts future sizing inputs without calculating sizing suggestions", () => {
const parsed = createCircuitSchema.safeParse({
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
voltage: 230,
controlRequirement: "DALI",
});
assert.equal(parsed.success, true);
});
it("passes voltage and control requirements through circuit updates", async () => {
let updatePayload: Record<string, unknown> = {};
const circuit = {
id: "c1",
circuitListId: "l1",
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
voltage: 230,
controlRequirement: "none",
};
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async findById() {
return circuit as never;
},
async existsByEquipmentIdentifier() {
return false;
},
async update(_id: string, payload: Record<string, unknown>) {
updatePayload = payload;
},
} as never,
});
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
assert.equal(updatePayload.voltage, 400);
assert.equal(updatePayload.controlRequirement, "DALI");
});
it("rejects duplicate equipment identifiers in same circuit list", async () => {
const service = new CircuitWriteService({
circuitListRepository: {
@@ -599,4 +649,34 @@ describe("circuit write service rules", () => {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let savedOverrides: string | undefined;
const current = {
id: "r1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Local name",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 10,
overriddenFields: null,
};
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return current as never;
},
async update(_rowId: string, input: { overriddenFields?: string }) {
savedOverrides = input.overriddenFields;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
});
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
} from "../src/domain/services/project-device-placement.service.js";
describe("project device placement", () => {
it("routes lighting categories to the lighting section before phase classification", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Lighting", phaseType: "three_phase" }),
"lighting"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Beleuchtung", phaseType: "single_phase" }),
"lighting"
);
});
it("routes non-lighting devices by phase type", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Socket", phaseType: "single_phase" }),
"single_phase"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Motor", phaseType: "three_phase" }),
"three_phase"
);
});
it("accepts only the inferred default section", () => {
const device = { category: "Motor", phaseType: "three_phase" as const };
assert.equal(isProjectDevicePlacementValid(device, { key: "three_phase" }), true);
assert.equal(isProjectDevicePlacementValid(device, { key: "single_phase" }), false);
assert.equal(isProjectDevicePlacementValid(device, { key: "unassigned" }), false);
});
});
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createProjectDeviceSchema } from "../src/shared/validation/project-device.schemas.js";
describe("project device circuit-first schema", () => {
it("accepts circuit device fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "E-Line Pro",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "Lighting",
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
});
assert.equal(result.success, true);
});
it("requires the circuit-first power and phase fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Legacy device",
displayName: "Legacy device",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
phaseCount: 1,
});
assert.equal(result.success, false);
});
});
+213
View File
@@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { db } from "../src/db/client.js";
function projectDevice() {
return {
id: "pd1",
projectId: "p1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "Lighting",
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
totalPower: 0.192,
cosPhi: 0.95,
remark: "DALI",
};
}
function linkedRow() {
return {
id: "row1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
legacyConsumerId: null,
sortOrder: 10,
name: "Old luminaire",
displayName: "Local display name",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "Lighting",
level: null,
roomId: null,
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Office",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: JSON.stringify(["displayName", "quantity"]),
equipmentIdentifier: "-1F1",
circuitDisplayName: "Office",
circuitListId: "list1",
circuitListName: "UV-01 Stromkreisliste",
distributionBoardId: "db1",
distributionBoardName: "UV-01",
};
}
function createService() {
const rows = [linkedRow()];
const updates: Array<Record<string, unknown>> = [];
let transactionCalls = 0;
const service = new ProjectDeviceSyncService({
projectDeviceRepository: {
async findById() {
return projectDevice() as never;
},
},
deviceRowRepository: {
async listLinkedByProjectDevice() {
return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never;
},
async listLinkStatesByIds() {
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
},
updateLinkedRowsTransactional(_projectDeviceId, changes) {
transactionCalls += 1;
for (const change of changes) {
updates.push({ rowId: change.rowId, ...change.input });
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
}
},
disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
const row = rows.find((entry) => entry.id === rowId)!;
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
Object.assign(row, { linkedProjectDeviceId: null });
}
},
reconnectRowsTransactional(projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
}
},
} as never,
});
return { service, rows, updates, transactionCalls: () => transactionCalls };
}
describe("project device synchronization", () => {
it("shows field differences and local overrides without changing rows", async () => {
const { service, updates } = createService();
const preview = await service.getPreview("p1", "pd1");
assert.equal(updates.length, 0);
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
assert.equal(
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden,
true
);
});
it("synchronizes only explicitly selected fields", async () => {
const { service, rows, transactionCalls } = createService();
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
assert.equal(rows[0].quantity, 6);
assert.equal(rows[0].powerPerUnit, 0.04);
assert.equal(rows[0].displayName, "Local display name");
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
assert.equal(transactionCalls(), 1);
});
it("rejects rows that are not linked to the selected project device", async () => {
const { service } = createService();
await assert.rejects(
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
/not linked/
);
});
it("disconnects selected rows without changing local values", async () => {
const { service, updates } = createService();
await service.disconnect("p1", "pd1", ["row1"]);
assert.equal(updates[0].linkedProjectDeviceId, undefined);
assert.equal(updates[0].displayName, "Local display name");
assert.equal(updates[0].quantity, 2);
});
it("restores synchronized values and override markers", async () => {
const { service, rows } = createService();
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
await service.restore("p1", "pd1", result.undo.rows);
assert.equal(rows[0].quantity, 2);
assert.equal(rows[0].powerPerUnit, 0.03);
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
});
it("reconnects rows only while they remain disconnected", async () => {
const { service, rows } = createService();
const result = await service.disconnect("p1", "pd1", ["row1"]);
await service.reconnect("p1", "pd1", result.undo.rowIds);
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
});
it("executes multi-row updates inside one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let transactionCalls = 0;
let updateCalls = 0;
let returnedPromise = false;
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => {
transactionCalls += 1;
const fakeTx = {
update() {
return {
set() {
return {
where() {
return {
run() {
updateCalls += 1;
return { changes: 1 };
},
};
},
};
},
};
},
};
const result = callback(fakeTx);
returnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
};
try {
const input = {
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Office lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
};
repository.updateLinkedRowsTransactional("pd1", [
{ rowId: "r1", input },
{ rowId: "r2", input },
]);
assert.equal(transactionCalls, 1);
assert.equal(updateCalls, 2);
assert.equal(returnedPromise, false);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
});