diff --git a/AGENTS.md b/AGENTS.md
index d771e59..d438bae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -339,6 +339,11 @@ external model state atomically; its inverse powers persistent Undo/Redo
without parsing workbook bytes. Apply retransmits and reparses the XLSX,
verifies its preview SHA-256 and creates no circuit, equipment identifier or
CircuitDeviceRow.
+R6 adds one compact project-wide external-object GET without pagination or
+batch payloads. The Revit page filters and sorts it in the client and renders a
+virtualized window. `external-object.update-planning` applies multi-object
+board, ProjectDevice and planning-value changes as one persistent command;
+linked objects are excluded because they follow their CircuitDeviceRow board.
The target configuration has no independent version counter; `expectedRevision`
invalidates stale plans, and each import batch stores a configuration copy.
@@ -592,7 +597,7 @@ Critical target rules:
selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on
- 2026-09-30; R3 through R5 are complete and R6 is the next package.
+ 2026-09-30; R3 through R6 are complete and R7 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
diff --git a/README.md b/README.md
index b965df8..d4aeae4 100644
--- a/README.md
+++ b/README.md
@@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
-Worksharing-ID als externem Schlüssel ersetzt. R0 bis R5 sind abgeschlossen;
+Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen;
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten
-Erstimport. Objekttabelle, Editorzuordnung, Rückgabe und Folgeimport folgen in
-R6 bis R10.
+Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen.
+Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
## Unterstützter Arbeitsablauf
diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md
index f4d1c7c..9b43c4f 100644
--- a/docs/spec/revit-excel-phase-14-plan.md
+++ b/docs/spec/revit-excel-phase-14-plan.md
@@ -14,7 +14,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
-| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
+| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
@@ -142,6 +142,14 @@ Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen.
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
Objekte folgen unverändert ihrer Row.
+Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert
+eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite
+sucht, filtert und sortiert vollständig im Client und rendert nur den
+sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler,
+ProjectDevice, Leistung und GZF in einem persistenten
+`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese
+Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
+
## R7 – Stromkreislisteneditor
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx
index bf9b39f..fc8b85e 100644
--- a/src/app/projects/[projectId]/revit/page.tsx
+++ b/src/app/projects/[projectId]/revit/page.tsx
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
+import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
@@ -43,6 +44,7 @@ export default function RevitPage() {
Projekt · Revit
{project?.name ?? "Revit-Anbindung"}
Linear-XLSX austauschen und Revit-Objekte verwalten
{error ?
{error}
: null}
XLSX-Konfiguration
{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}
{snapshot ? "Konfiguriert" : "Offen"}
Importstatus
{importStatus?.imported ? <>
{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}
Quelle: {importStatus.sourceName}
> :
Noch kein Linear-XLSX-Import vorhanden.
}
+ {importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
;
diff --git a/src/db/repositories/external-object-planning-project-command.repository.ts b/src/db/repositories/external-object-planning-project-command.repository.ts
new file mode 100644
index 0000000..e6b3b1f
--- /dev/null
+++ b/src/db/repositories/external-object-planning-project-command.repository.ts
@@ -0,0 +1,29 @@
+import { and, eq } from "drizzle-orm";
+import { assertExternalObjectPlanningUpdateProjectCommand, invertExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
+import type { ExternalObjectPlanningProjectCommandStore } from "../../domain/ports/external-object-planning-project-command.store.js";
+import type { AppDatabase } from "../database-context.js";
+import { distributionBoards } from "../schema/distribution-boards.js";
+import { externalModelObjects } from "../schema/external-model-objects.js";
+import { projectDevices } from "../schema/project-devices.js";
+import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
+import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
+
+export class ExternalObjectPlanningProjectCommandRepository implements ExternalObjectPlanningProjectCommandStore {
+ constructor(private readonly database: AppDatabase) {}
+ execute(input: Parameters[0]) {
+ assertExternalObjectPlanningUpdateProjectCommand(input.command);
+ return executeProjectCommandTransaction(this.database, input, (tx) => {
+ const boardIds = new Set(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all().map((row) => row.id));
+ const deviceIds = new Set(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all().map((row) => row.id));
+ for (const { expected, target } of input.command.payload.objects) {
+ const current = tx.select().from(externalModelObjects).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).get();
+ if (!current || !snapshotsEqual(toExternalObjectSnapshot(current), expected)) throw new Error("Ein Revit-Objekt wurde zwischenzeitlich geändert.");
+ if (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) throw new Error("Der Verteiler gehört zu einem anderen Projekt.");
+ if (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)) throw new Error("Das Projektgerät gehört zu einem anderen Projekt.");
+ const updated = tx.update(externalModelObjects).set({ planningValues: target.planningValues, overriddenFields: target.overriddenFields, distributionBoardId: target.distributionBoardId, linkedProjectDeviceId: target.linkedProjectDeviceId }).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).run();
+ if (updated.changes !== 1) throw new Error("Revit-Objekt konnte nicht geändert werden.");
+ }
+ return invertExternalObjectPlanningUpdateProjectCommand(input.command);
+ });
+ }
+}
diff --git a/src/domain/models/external-object-planning-project-command.model.ts b/src/domain/models/external-object-planning-project-command.model.ts
new file mode 100644
index 0000000..f31d126
--- /dev/null
+++ b/src/domain/models/external-object-planning-project-command.model.ts
@@ -0,0 +1,30 @@
+import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
+import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
+import type { SerializedProjectCommand } from "./project-command.model.js";
+
+export const externalObjectPlanningUpdateCommandType = "external-object.update-planning" as const;
+export interface ExternalObjectPlanningUpdateProjectCommand extends SerializedProjectCommand<{ objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }> }> {
+ schemaVersion: 1;
+ type: typeof externalObjectPlanningUpdateCommandType;
+}
+export function createExternalObjectPlanningUpdateProjectCommand(objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>): ExternalObjectPlanningUpdateProjectCommand {
+ const command: ExternalObjectPlanningUpdateProjectCommand = { schemaVersion: 1, type: externalObjectPlanningUpdateCommandType, payload: { objects } };
+ assertExternalObjectPlanningUpdateProjectCommand(command); return command;
+}
+export function invertExternalObjectPlanningUpdateProjectCommand(command: ExternalObjectPlanningUpdateProjectCommand) { return createExternalObjectPlanningUpdateProjectCommand(command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected }))); }
+export function assertExternalObjectPlanningUpdateProjectCommand(command: SerializedProjectCommand): asserts command is ExternalObjectPlanningUpdateProjectCommand {
+ if (command.schemaVersion !== 1 || command.type !== externalObjectPlanningUpdateCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Revit-Objektplanungs-Command.");
+ const ids = new Set();
+ for (const transition of command.payload.objects) {
+ if (!isRecord(transition)) throw new Error("Ungültiger Revit-Objektübergang.");
+ const expected = parseObject(transition.expected); const target = parseObject(transition.target);
+ if (expected.id !== target.id || ids.has(expected.id)) throw new Error("Doppelte oder abweichende Revit-Objekt-ID.");
+ ids.add(expected.id);
+ if (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte werden über ihre Gerätezeile geplant.");
+ const left = { ...expected, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
+ const right = { ...target, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
+ if (JSON.stringify(left) !== JSON.stringify(right) || JSON.stringify(expected) === JSON.stringify(target)) throw new Error("Der Objektplanungs-Command ändert unzulässige oder keine Felder.");
+ }
+}
+function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
+function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }
diff --git a/src/domain/ports/external-object-planning-project-command.store.ts b/src/domain/ports/external-object-planning-project-command.store.ts
new file mode 100644
index 0000000..19c610b
--- /dev/null
+++ b/src/domain/ports/external-object-planning-project-command.store.ts
@@ -0,0 +1,3 @@
+import type { ExternalObjectPlanningUpdateProjectCommand } from "../models/external-object-planning-project-command.model.js";
+import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
+export interface ExternalObjectPlanningProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectPlanningUpdateProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectPlanningUpdateProjectCommand }; }
diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts
index 8e4bf36..ca99c5f 100644
--- a/src/domain/services/project-command.service.ts
+++ b/src/domain/services/project-command.service.ts
@@ -182,6 +182,8 @@ import {
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
+import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
+import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
@@ -248,7 +250,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
- private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore
+ private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
+ private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
) {}
executeUser(
@@ -647,6 +650,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
+ case externalObjectPlanningUpdateCommandType: {
+ assertExternalObjectPlanningUpdateProjectCommand(input.command);
+ if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
+ return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
+ }
case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) {
diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx
new file mode 100644
index 0000000..c82d9c7
--- /dev/null
+++ b/src/frontend/components/revit-object-table.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import type { ExternalProjectObjectsDto } from "../types";
+import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
+
+const rowHeight = 44;
+const viewportHeight = 520;
+
+export function RevitObjectTable({ projectId, onRevision }: { projectId: string; onRevision: (revision: number) => void }) {
+ const [data, setData] = useState(null);
+ const [search, setSearch] = useState("");
+ const [boardFilter, setBoardFilter] = useState("all");
+ const [assignmentFilter, setAssignmentFilter] = useState("all");
+ const [sort, setSort] = useState<"family" | "room" | "worksharing">("family");
+ const [selected, setSelected] = useState>(new Set());
+ const [scrollTop, setScrollTop] = useState(0);
+ const [bulkBoard, setBulkBoard] = useState("");
+ const [bulkDevice, setBulkDevice] = useState("");
+ const [power, setPower] = useState("");
+ const [gzf, setGzf] = useState("");
+ const [planningField, setPlanningField] = useState("");
+ const [planningValue, setPlanningValue] = useState("");
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
+ useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const rows = useMemo(() => {
+ if (!data) return [];
+ const needle = search.trim().toLocaleLowerCase("de-DE");
+ return data.objects.filter((object) => {
+ if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false;
+ if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false;
+ if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false;
+ return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle));
+ }).sort((a, b) => {
+ const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId;
+ const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId;
+ return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId);
+ });
+ }, [data, search, boardFilter, assignmentFilter, sort]);
+ const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
+ const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
+ const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
+
+ async function applyBulk() {
+ if (!data || selected.size === 0) return;
+ const patch: Record = {};
+ if (bulkBoard !== "") patch.distributionBoardId = bulkBoard === "none" ? null : bulkBoard;
+ if (bulkDevice !== "") patch.linkedProjectDeviceId = bulkDevice === "none" ? null : bulkDevice;
+ if (power !== "") patch.powerPerUnitW = Number(power.replace(",", "."));
+ if (gzf !== "") patch.simultaneityFactor = Number(gzf.replace(",", "."));
+ if (planningField) {
+ patch[planningField] = ["effectiveQuantity", "cosPhi"].includes(planningField)
+ ? Number(planningValue.replace(",", "."))
+ : planningValue === "" ? null : planningValue;
+ }
+ if (Object.keys(patch).length === 0) { setError("Mindestens eine Sammeländerung auswählen."); return; }
+ setSaving(true); setError(null);
+ try { const result = await updateExternalProjectObjects(projectId, data.currentRevision, [...selected], patch); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
+ catch (value) { setError(value instanceof Error ? value.message : "Sammeländerung fehlgeschlagen."); }
+ finally { setSaving(false); }
+ }
+
+ if (!data) return
Revit-Objekte werden geladen …
;
+ return
Revit-Objekte
{rows.length} von {data.objects.length} · {selected.size} ausgewählt