Compare commits
20 Commits
689cfd3c65
...
7257deacdc
| Author | SHA1 | Date | |
|---|---|---|---|
| 7257deacdc | |||
| 17cdeb6174 | |||
| 8df37fecae | |||
| fafccca50f | |||
| d1615bcfee | |||
| 01aff46b4f | |||
| d0b8239085 | |||
| c991f493da | |||
| 00cb07f848 | |||
| 5dc3ab90be | |||
| 6c45c97ffd | |||
| 009152b406 | |||
| b3a7ff79da | |||
| f8802fecdb | |||
| ea61772e62 | |||
| a6b83285eb | |||
| fd62bf2e15 | |||
| b2f5ce77a5 | |||
| 6f8b292b6b | |||
| 9d07ed9856 |
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.next
|
||||
data
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.next/
|
||||
data/*.db
|
||||
data/backups/*.db
|
||||
.codex/*.log
|
||||
|
||||
+12
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface CircuitTreeCircuit {
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
status?: string;
|
||||
isReserve: boolean;
|
||||
remark?: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Circuit {
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
status?: string;
|
||||
isReserve: boolean;
|
||||
remark?: string;
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user