feat(schematic): Echte Junction-Verbindungen (Abzweigungen)

- Neue DB-Felder: junction_connection_id, junction_x, junction_y
- Klick auf Leitung im Zeichenmodus erstellt echte Abzweigung
- Orthogonale Pfadberechnung (nur rechtwinklig)
- Separater Dialog für Abzweigungen mit übernommenen Werten
- Migration für v9.2.0 hinzugefügt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-04 12:16:18 +01:00
parent 276abe3f06
commit 1221f49c5b
5 changed files with 274 additions and 39 deletions

View file

@ -161,6 +161,10 @@ switch ($action) {
$connection->position_y = GETPOSTINT('position_y');
$connection->path_data = GETPOST('path_data', 'nohtml');
$connection->bundled_terminals = GETPOST('bundled_terminals', 'alphanohtml');
// Junction fields for branching from existing connections
$connection->junction_connection_id = GETPOSTINT('junction_connection_id') ?: null;
$connection->junction_x = GETPOST('junction_x', 'alpha') ? floatval(GETPOST('junction_x', 'alpha')) : null;
$connection->junction_y = GETPOST('junction_y', 'alpha') ? floatval(GETPOST('junction_y', 'alpha')) : null;
$result = $connection->create($user);
if ($result > 0) {

View file

@ -46,6 +46,12 @@ class EquipmentConnection extends CommonObject
public $fk_carrier;
public $position_y = 0;
public $path_data; // SVG path for manually drawn connections
// Junction fields - branching from existing connections
public $junction_connection_id; // ID of connection to branch from
public $junction_x; // X coordinate of junction point
public $junction_y; // Y coordinate of junction point
public $note_private;
public $status = 1;
@ -91,6 +97,7 @@ class EquipmentConnection extends CommonObject
$sql .= " connection_type, color, output_label,";
$sql .= " medium_type, medium_spec, medium_length,";
$sql .= " is_rail, rail_start_te, rail_end_te, rail_phases, excluded_te, fk_carrier, position_y, path_data,";
$sql .= " junction_connection_id, junction_x, junction_y,";
$sql .= " note_private, status, date_creation, fk_user_creat";
$sql .= ") VALUES (";
$sql .= ((int) $conf->entity);
@ -115,6 +122,9 @@ class EquipmentConnection extends CommonObject
$sql .= ", ".($this->fk_carrier > 0 ? ((int) $this->fk_carrier) : "NULL");
$sql .= ", ".((int) $this->position_y);
$sql .= ", ".($this->path_data ? "'".$this->db->escape($this->path_data)."'" : "NULL");
$sql .= ", ".($this->junction_connection_id > 0 ? ((int) $this->junction_connection_id) : "NULL");
$sql .= ", ".($this->junction_x !== null ? floatval($this->junction_x) : "NULL");
$sql .= ", ".($this->junction_y !== null ? floatval($this->junction_y) : "NULL");
$sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL");
$sql .= ", ".((int) $this->status);
$sql .= ", '".$this->db->idate($now)."'";
@ -184,6 +194,9 @@ class EquipmentConnection extends CommonObject
$this->fk_carrier = $obj->fk_carrier;
$this->position_y = $obj->position_y;
$this->path_data = isset($obj->path_data) ? $obj->path_data : null;
$this->junction_connection_id = isset($obj->junction_connection_id) ? $obj->junction_connection_id : null;
$this->junction_x = isset($obj->junction_x) ? $obj->junction_x : null;
$this->junction_y = isset($obj->junction_y) ? $obj->junction_y : null;
$this->note_private = $obj->note_private;
$this->status = $obj->status;
$this->date_creation = $this->db->jdate($obj->date_creation);
@ -337,6 +350,9 @@ class EquipmentConnection extends CommonObject
$conn->fk_carrier = $obj->fk_carrier;
$conn->position_y = $obj->position_y;
$conn->path_data = isset($obj->path_data) ? $obj->path_data : null;
$conn->junction_connection_id = isset($obj->junction_connection_id) ? $obj->junction_connection_id : null;
$conn->junction_x = isset($obj->junction_x) ? $obj->junction_x : null;
$conn->junction_y = isset($obj->junction_y) ? $obj->junction_y : null;
$conn->status = $obj->status;
$conn->source_label = $obj->source_label;

View file

@ -76,7 +76,7 @@ class modKundenKarte extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '9.1';
$this->version = '9.2';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -659,6 +659,9 @@ class modKundenKarte extends DolibarrModules
// v8.6.0: has_product Flag für Typen
$this->migrate_v860_has_product();
// v9.2.0: Junction-Verbindungen (Abzweigungen auf Leitungen)
$this->migrate_v920_junction_connections();
}
/**
@ -1048,6 +1051,29 @@ class modKundenKarte extends DolibarrModules
}
}
/**
* Migration v9.2.0: Junction-Verbindungen
* Ermöglicht Abzweigungen von bestehenden Leitungen
*/
private function migrate_v920_junction_connections()
{
$table = MAIN_DB_PREFIX."kundenkarte_equipment_connection";
// junction_connection_id - die Verbindung von der abgezweigt wird
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'junction_connection_id'");
if (!$resql || $this->db->num_rows($resql) == 0) {
$this->db->query("ALTER TABLE ".$table." ADD COLUMN junction_connection_id int(11) DEFAULT NULL AFTER fk_target");
$this->db->query("ALTER TABLE ".$table." ADD INDEX idx_junction_conn (junction_connection_id)");
}
// junction_x, junction_y - Koordinaten des Abzweigpunkts
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'junction_x'");
if (!$resql || $this->db->num_rows($resql) == 0) {
$this->db->query("ALTER TABLE ".$table." ADD COLUMN junction_x decimal(10,2) DEFAULT NULL AFTER junction_connection_id");
$this->db->query("ALTER TABLE ".$table." ADD COLUMN junction_y decimal(10,2) DEFAULT NULL AFTER junction_x");
}
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -7776,28 +7776,45 @@
e.stopPropagation();
// If we're in wire draw mode with a source terminal selected,
// create connection from that terminal to the clicked wire's target
// create TRUE junction connection to the clicked wire
if (self.wireDrawMode && self.wireDrawSourceEq && connId) {
var conn = self.connections.find(function(c) { return c.id == connId; });
if (conn && conn.fk_target) {
// Create connection from selected terminal to wire's target equipment
var sourceEqId = self.wireDrawSourceEq;
var sourceTermId = self.wireDrawSourceTerm;
var targetEqId = conn.fk_target;
var targetTermId = conn.target_terminal_id || 'input';
if (conn) {
// Calculate junction point on the wire (snapped to grid)
var svg = self.svgElement;
var pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
var svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
// Build path: source terminal → existing points → target terminal
var pathPoints = self.wireDrawPoints.slice(); // Copy existing points
// Snap to grid
var gridSize = self.GRID_SIZE;
var junctionX = Math.round(svgPt.x / gridSize) * gridSize;
var junctionY = Math.round(svgPt.y / gridSize) * gridSize;
// Add target terminal position
var targetEq = self.equipment.find(function(eq) { return eq.id == targetEqId; });
if (targetEq) {
var terminals = self.getTerminals(targetEq);
var targetPos = self.getTerminalPosition(targetEq, targetTermId, terminals);
if (targetPos) {
pathPoints.push({x: targetPos.x, y: targetPos.y});
// Build orthogonal path from source terminal to junction point
var pathPoints = self.wireDrawPoints.slice();
if (pathPoints.length === 0) {
// Get source terminal position
var srcEq = self.equipment.find(function(eq) { return eq.id == self.wireDrawSourceEq; });
if (srcEq) {
var srcTerminals = self.getTerminals(srcEq);
var srcPos = self.getTerminalPosition(srcEq, self.wireDrawSourceTerm, srcTerminals);
if (srcPos) {
pathPoints.push({x: srcPos.x, y: srcPos.y});
}
}
}
// Add orthogonal bend point if needed (go vertical first, then horizontal)
if (pathPoints.length > 0) {
var lastPt = pathPoints[pathPoints.length - 1];
// Add intermediate point for orthogonal routing
if (lastPt.x !== junctionX && lastPt.y !== junctionY) {
pathPoints.push({x: lastPt.x, y: junctionY});
}
}
pathPoints.push({x: junctionX, y: junctionY});
// Build path string
var pathData = '';
@ -7805,14 +7822,17 @@
pathData += (i === 0 ? 'M' : 'L') + ' ' + pathPoints[i].x + ' ' + pathPoints[i].y + ' ';
}
// Store connection values to inherit
// Store junction info and show dialog
self._inheritFromConnection = conn;
self._pendingPathData = pathData.trim() || null;
self._pendingPathData = pathData.trim();
self._pendingJunction = {
connection_id: conn.id,
x: junctionX,
y: junctionY
};
// Clear source terminal but keep draw mode active
self.cleanupWireDrawState(true);
self.showConnectionLabelDialog(sourceEqId, sourceTermId, targetEqId, targetTermId);
self.showJunctionDialog(self.wireDrawSourceEq, self.wireDrawSourceTerm, conn);
return;
}
}
@ -7842,34 +7862,49 @@
e.preventDefault();
e.stopPropagation();
// If in wire draw mode with source terminal, connect to output's source
// If in wire draw mode, create junction to this output
if (self.wireDrawMode && self.wireDrawSourceEq && connId) {
var conn = self.connections.find(function(c) { return c.id == connId; });
if (conn && conn.fk_source) {
var sourceEqId = self.wireDrawSourceEq;
var sourceTermId = self.wireDrawSourceTerm;
var targetEqId = conn.fk_source;
var targetTermId = conn.source_terminal_id || 'output';
if (conn) {
// Calculate junction point
var svg = self.svgElement;
var pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
var svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
// Build path with existing points
var gridSize = self.GRID_SIZE;
var junctionX = Math.round(svgPt.x / gridSize) * gridSize;
var junctionY = Math.round(svgPt.y / gridSize) * gridSize;
// Build orthogonal path
var pathPoints = self.wireDrawPoints.slice();
var targetEq = self.equipment.find(function(eq) { return eq.id == targetEqId; });
if (targetEq) {
var terminals = self.getTerminals(targetEq);
var targetPos = self.getTerminalPosition(targetEq, targetTermId, terminals);
if (targetPos) {
pathPoints.push({x: targetPos.x, y: targetPos.y});
if (pathPoints.length === 0) {
var srcEq = self.equipment.find(function(eq) { return eq.id == self.wireDrawSourceEq; });
if (srcEq) {
var srcTerminals = self.getTerminals(srcEq);
var srcPos = self.getTerminalPosition(srcEq, self.wireDrawSourceTerm, srcTerminals);
if (srcPos) pathPoints.push({x: srcPos.x, y: srcPos.y});
}
}
if (pathPoints.length > 0) {
var lastPt = pathPoints[pathPoints.length - 1];
if (lastPt.x !== junctionX && lastPt.y !== junctionY) {
pathPoints.push({x: lastPt.x, y: junctionY});
}
}
pathPoints.push({x: junctionX, y: junctionY});
var pathData = '';
for (var i = 0; i < pathPoints.length; i++) {
pathData += (i === 0 ? 'M' : 'L') + ' ' + pathPoints[i].x + ' ' + pathPoints[i].y + ' ';
}
self._inheritFromConnection = conn;
self._pendingPathData = pathData.trim() || null;
self._pendingPathData = pathData.trim();
self._pendingJunction = { connection_id: conn.id, x: junctionX, y: junctionY };
self.cleanupWireDrawState(true);
self.showConnectionLabelDialog(sourceEqId, sourceTermId, targetEqId, targetTermId);
self.showJunctionDialog(self.wireDrawSourceEq, self.wireDrawSourceTerm, conn);
return;
}
}
@ -10746,6 +10781,160 @@
setTimeout(function() { $('#conn-label').focus(); }, 100);
},
// Dialog for junction connections (terminal to wire)
showJunctionDialog: function(sourceEqId, sourceTermId, targetConn) {
var self = this;
var sourceEq = this.equipment.find(function(e) { return e.id == sourceEqId; });
// Inherit values from target connection
var defaultType = targetConn.connection_type || 'L1N';
var defaultColor = targetConn.color || this.COLORS.connection;
var defaultMedium = targetConn.medium_type || '';
var defaultLength = targetConn.medium_length || '';
var html = '<div id="schematic-conn-dialog" class="kundenkarte-modal visible">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3>Abzweigung erstellen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div style="margin-bottom:15px;padding:10px;background:#2a2a2a;border-radius:4px;">';
html += '<strong style="color:#3498db;">' + this.escapeHtml((sourceEq ? sourceEq.label || sourceEq.type_label_short : 'Gerät')) + '</strong>';
html += ' <i class="fa fa-arrow-right" style="color:#888;margin:0 10px;"></i> ';
html += '<strong style="color:#f39c12;"><i class="fa fa-code-fork"></i> Abzweigung</strong>';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Bezeichnung / Stromkreis</label>';
html += '<input type="text" id="conn-label" class="flat" style="width:100%;padding:8px;" placeholder="z.B. Küche Steckdosen">';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Typ</label>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
var typePresets = ['L1', 'L2', 'L3', 'N', 'PE', 'L1N', '3P'];
typePresets.forEach(function(t) {
var color = self.PHASE_COLORS[t] || self.COLORS.connection;
var isSelected = (t === defaultType);
var outlineStyle = isSelected ? 'outline:2px solid #fff;' : '';
html += '<button type="button" class="conn-type-btn" data-type="' + t + '" data-color="' + color + '" ';
html += 'style="padding:5px 12px;background:' + color + ';color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:12px;' + outlineStyle + '">';
html += t + '</button>';
});
html += '</div>';
html += '<input type="hidden" id="conn-type" value="' + self.escapeHtml(defaultType) + '">';
html += '<input type="hidden" id="conn-color" value="' + self.escapeHtml(defaultColor) + '">';
html += '</div>';
html += '<div class="form-group">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Kabel (optional)</label>';
html += '<div style="display:flex;gap:8px;">';
html += '<input type="text" id="conn-medium" class="flat" style="flex:1;padding:8px;" placeholder="z.B. NYM-J 3x1.5" value="' + self.escapeHtml(defaultMedium) + '">';
html += '<input type="text" id="conn-length" class="flat" style="width:80px;padding:8px;" placeholder="Länge" value="' + self.escapeHtml(defaultLength) + '">';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-check"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
// Type preset buttons
$('.conn-type-btn').on('click', function() {
$('.conn-type-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('#conn-type').val($(this).data('type'));
$('#conn-color').val($(this).data('color'));
});
// Save junction
$('#conn-save').on('click', function() {
var label = $('#conn-label').val();
var connType = $('#conn-type').val();
var color = $('#conn-color').val();
var medium = $('#conn-medium').val();
var length = $('#conn-length').val();
var pathData = self._pendingPathData || null;
var junction = self._pendingJunction || null;
self._pendingPathData = null;
self._pendingJunction = null;
self.createJunctionConnection(sourceEqId, sourceTermId, {
label: label,
type: connType,
color: color,
medium: medium,
length: length,
pathData: pathData,
junction: junction
});
$('#schematic-conn-dialog').remove();
});
// Cancel
$('#conn-cancel, .kundenkarte-modal-close').on('click', function() {
$('#schematic-conn-dialog').remove();
self._pendingPathData = null;
self._pendingJunction = null;
});
setTimeout(function() { $('#conn-label').focus(); }, 100);
},
// Create junction connection (branches from existing wire)
createJunctionConnection: function(sourceEqId, sourceTermId, options) {
var self = this;
options = options || {};
var data = {
action: 'create',
fk_source: sourceEqId,
source_terminal_id: sourceTermId,
fk_target: '', // No target equipment for junction
target_terminal_id: '',
connection_type: options.type || 'L1N',
color: options.color || self.COLORS.connection,
output_label: options.label || '',
medium_type: options.medium || '',
medium_length: options.length || '',
path_data: options.pathData || '',
token: $('input[name="token"]').val()
};
// Add junction info
if (options.junction) {
data.junction_connection_id = options.junction.connection_id;
data.junction_x = options.junction.x;
data.junction_y = options.junction.y;
}
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Abzweigung erstellt!', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
},
error: function() {
self.showMessage('Verbindungsfehler', 'error');
}
});
},
updateConnectionPreview: function(e) {
if (!this.selectedTerminal) return;

4
sw.js
View file

@ -3,8 +3,8 @@
* Offline-First für Schaltschrank-Dokumentation
*/
const CACHE_NAME = 'kundenkarte-pwa-v9.2';
const OFFLINE_CACHE = 'kundenkarte-offline-v9.2';
const CACHE_NAME = 'kundenkarte-pwa-v9.3';
const OFFLINE_CACHE = 'kundenkarte-offline-v9.3';
// Statische Assets die immer gecached werden (ohne Query-String)
const STATIC_ASSETS = [