add aha blatt parsing
This commit is contained in:
@@ -244,7 +244,8 @@ class WorkorderHandler extends MobileAppBaseHandler {
|
|||||||
|
|
||||||
$technicalData = null;
|
$technicalData = null;
|
||||||
if ($tenantConfigData && !empty($tenantConfigData['showTechnicalData'])) {
|
if ($tenantConfigData && !empty($tenantConfigData['showTechnicalData'])) {
|
||||||
$technicalData = $this->getTechnicalData($id);
|
RimoWorkorder::autoParseForWorkorder($id);
|
||||||
|
$technicalData = WorkorderModel::getTechnicalData($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::returnJson([
|
self::returnJson([
|
||||||
@@ -900,51 +901,6 @@ class WorkorderHandler extends MobileAppBaseHandler {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get technical data (patchposition and AHA Blatt) for a workorder
|
|
||||||
*/
|
|
||||||
private function getTechnicalData($workorderId) {
|
|
||||||
$workorder = WorkorderModel::get($workorderId);
|
|
||||||
if (!$workorder || !$workorder->preorderId) return null;
|
|
||||||
|
|
||||||
$preorder = new Preorder($workorder->preorderId);
|
|
||||||
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
|
|
||||||
|
|
||||||
$wohneinheit = $preorder->adb_wohneinheit;
|
|
||||||
if (!$wohneinheit) return null;
|
|
||||||
|
|
||||||
$defaultCluster = '';
|
|
||||||
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
|
|
||||||
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$patchposition = [
|
|
||||||
'equipmentName' => $wohneinheit->getPatchEqString(),
|
|
||||||
'equipmentPort' => $wohneinheit->patch_port,
|
|
||||||
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
|
|
||||||
'shelf' => $wohneinheit->patch_shelf,
|
|
||||||
'module' => $wohneinheit->patch_module,
|
|
||||||
];
|
|
||||||
|
|
||||||
$rimoWorkorders = [];
|
|
||||||
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
|
|
||||||
foreach ($wohneinheit->rimo_workorders as $wo) {
|
|
||||||
$rimoWorkorders[] = [
|
|
||||||
'id' => $wo->id,
|
|
||||||
'rimoName' => $wo->rimo_name,
|
|
||||||
'rimoId' => $wo->rimo_id,
|
|
||||||
'rimoStatus' => $wo->rimo_status,
|
|
||||||
'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'patchposition' => $patchposition,
|
|
||||||
'rimoWorkorders' => $rimoWorkorders,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single workorder with full joined data (same structure as getCompanyWorkorders)
|
* Get single workorder with full joined data (same structure as getCompanyWorkorders)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Smalot\PdfParser\Parser;
|
||||||
|
|
||||||
class RimoWorkorder extends mfBaseModel {
|
class RimoWorkorder extends mfBaseModel {
|
||||||
private $adb_wohneinheit;
|
private $adb_wohneinheit;
|
||||||
private $termination;
|
private $termination;
|
||||||
@@ -56,6 +58,98 @@ class RimoWorkorder extends mfBaseModel {
|
|||||||
|
|
||||||
return $ah;
|
return $ah;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function parseAha(): array {
|
||||||
|
if (!$this->id || !$this->adb_wohneinheit_id) return ['success' => false, 'message' => 'Missing ID'];
|
||||||
|
$preorder = PreorderModel::getFirstActive(["adb_wohneinheit_id" => $this->adb_wohneinheit_id]);
|
||||||
|
if (!$preorder?->id) return ['success' => false, 'message' => 'No active Preorder'];
|
||||||
|
$workorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]);
|
||||||
|
if (!$workorder || !($pdf = $this->getAha())) return ['success' => false, 'message' => 'No Workorder or PDF'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dropkabel = $this->parseDropkabelFromPdf($pdf);
|
||||||
|
$map = $this->extractMapFromPdf($pdf);
|
||||||
|
$meta = json_decode($workorder->metadata ?: '{}', true) ?: [];
|
||||||
|
$mapFileId = null;
|
||||||
|
|
||||||
|
if ($map) {
|
||||||
|
if ($oldId = ($meta['dropcable']['map_file_id'] ?? null)) {
|
||||||
|
$old = new File($oldId); if ($old->id) try { $old->delete(); } catch (Exception $e) {}
|
||||||
|
}
|
||||||
|
$fn = 'aha_lageplan_' . $this->id . '_' . time() . '.png';
|
||||||
|
$file = FileModel::create(['name' => 'AHA Lageplan ' . $this->rimo_name, 'filename' => $fn,
|
||||||
|
'store_filename' => $fn, 'orig_filename' => 'AHA_Lageplan_' . $this->rimo_name . '.png',
|
||||||
|
'mimetype' => 'image/png', 'subfolder' => 'aha_maps']);
|
||||||
|
if ($file->save()) {
|
||||||
|
$dir = MFUPLOAD_FILE_SAVE_PATH . '/aha_maps';
|
||||||
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
|
if (file_put_contents("$dir/$fn", $map)) $mapFileId = $file->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$meta['dropcable'] = ['rimo_workorder_id' => $this->id, 'rimo_name' => $this->rimo_name,
|
||||||
|
'parsed_at' => time(), 'entries' => $dropkabel, 'map_file_id' => $mapFileId];
|
||||||
|
$workorder->metadata = json_encode($meta);
|
||||||
|
WorkorderModel::update((array)$workorder);
|
||||||
|
return ['success' => true, 'dropkabel_count' => count($dropkabel), 'has_map' => (bool)$mapFileId];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->log->error(__METHOD__ . ": " . $e->getMessage());
|
||||||
|
return ['success' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function autoParseForWorkorder(int $workorderId): void {
|
||||||
|
$wo = WorkorderModel::get($workorderId);
|
||||||
|
if (!$wo) return;
|
||||||
|
$meta = json_decode($wo->metadata ?? '{}', true);
|
||||||
|
if (empty($meta['dropcable']['parsed_at']) && $wo->preorderId) {
|
||||||
|
$pre = new Preorder($wo->preorderId);
|
||||||
|
$rimos = $pre->adb_wohneinheit_id ? RimoWorkorderModel::search(['adb_wohneinheit_id' => $pre->adb_wohneinheit_id]) : [];
|
||||||
|
if (!empty($rimos[0])) (new self($rimos[0]->id))->parseAha();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDropkabelFromPdf(string $pdf): array {
|
||||||
|
$result = [];
|
||||||
|
$text = (new Parser())->parseContent($pdf)->getPages()[0]?->getText() ?? '';
|
||||||
|
if (!preg_match('/Dropkabel:\s*\n(.+?)(?:Lage:|$)/s', $text, $m)) return $result;
|
||||||
|
$started = false;
|
||||||
|
foreach (explode("\n", $m[1]) as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (!$line) continue;
|
||||||
|
if (stripos($line, 'ID') !== false && stripos($line, 'Type') !== false) { $started = true; continue; }
|
||||||
|
if ($started && preg_match('/^(F-[A-Z0-9\(\)-]+K\d+)\s+(.+)$/i', $line, $p)) {
|
||||||
|
$rest = $p[2]; $status = '';
|
||||||
|
foreach (['Planfreigabe', 'Plan released', 'Grobplanung', 'Executed', 'Ausgeführt'] as $s)
|
||||||
|
if (preg_match('/\b' . preg_quote($s, '/') . '\s*$/i', $rest)) {
|
||||||
|
$status = $s; $rest = trim(preg_replace('/\b' . preg_quote($s, '/') . '\s*$/i', '', $rest)); break;
|
||||||
|
}
|
||||||
|
$lp = $li = '';
|
||||||
|
if (preg_match_all('/(\d+)\s*m\b/', $rest, $lens, PREG_SET_ORDER)) {
|
||||||
|
$lp = ($lens[0][1] ?? '') . ' m'; $li = ($lens[1][1] ?? '') . ' m';
|
||||||
|
$rest = preg_replace('/\d+\s*m\b/', '', $rest);
|
||||||
|
}
|
||||||
|
$result[] = ['cable_id' => trim($p[1]), 'type' => trim(preg_replace('/\s+/', ' ', $rest)),
|
||||||
|
'laenge_plan' => $lp, 'laenge_ist' => $li, 'status' => $status];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMapFromPdf(string $pdf): ?string {
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'aha_'); file_put_contents($tmp, $pdf);
|
||||||
|
$out = tempnam(sys_get_temp_dir(), 'aha_img_'); unlink($out);
|
||||||
|
exec(sprintf('pdftoppm -png -f 1 -l 1 -r 150 %s %s 2>&1', escapeshellarg($tmp), escapeshellarg($out)), $_, $ret);
|
||||||
|
@unlink($tmp);
|
||||||
|
$outFile = file_exists("$out-1.png") ? "$out-1.png" : "$out.png";
|
||||||
|
if ($ret !== 0 || !file_exists($outFile)) return null;
|
||||||
|
$img = @imagecreatefromstring(file_get_contents($outFile)); @unlink($outFile);
|
||||||
|
if (!$img) return null;
|
||||||
|
$h = imagesy($img); $cropY = (int)($h * 0.42); $cropH = (int)($h * 0.84) - $cropY;
|
||||||
|
$cropped = imagecrop($img, ['x' => 60, 'y' => $cropY, 'width' => imagesx($img) - 90, 'height' => $cropH]);
|
||||||
|
imagedestroy($img); if (!$cropped) return null;
|
||||||
|
ob_start(); imagepng($cropped, null, 6); $content = ob_get_clean(); imagedestroy($cropped);
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
public function getProperty($name) {
|
public function getProperty($name) {
|
||||||
if($this->$name == null) {
|
if($this->$name == null) {
|
||||||
|
|||||||
@@ -44,4 +44,24 @@ class RimoWorkorderController extends mfBaseController {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function parseAhaAction() {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$post = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $post['id'] ?? $this->request->id ?? null;
|
||||||
|
|
||||||
|
if (!$id || $id < 1) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Invalid workorder id.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wo = new RimoWorkorder($id);
|
||||||
|
if (!$wo->id) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'RimoWorkorder nicht gefunden.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($wo->parseAha());
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ class WorkorderModel extends TTCrudBaseModel
|
|||||||
public ?string $additionalInfo;
|
public ?string $additionalInfo;
|
||||||
public ?string $cableLength;
|
public ?string $cableLength;
|
||||||
public ?string $cableType;
|
public ?string $cableType;
|
||||||
|
public ?string $metadata;
|
||||||
public int $create;
|
public int $create;
|
||||||
public int $createBy;
|
public int $createBy;
|
||||||
|
|
||||||
@@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel
|
|||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
return $result ? $result->fetch_assoc()['count'] : 0;
|
return $result ? $result->fetch_assoc()['count'] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getTechnicalData(int $workorderId): ?array {
|
||||||
|
$workorder = self::get($workorderId);
|
||||||
|
if (!$workorder || !$workorder->preorderId) return null;
|
||||||
|
|
||||||
|
$preorder = new Preorder($workorder->preorderId);
|
||||||
|
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
|
||||||
|
|
||||||
|
$wohneinheit = $preorder->adb_wohneinheit;
|
||||||
|
if (!$wohneinheit) return null;
|
||||||
|
|
||||||
|
$defaultCluster = '';
|
||||||
|
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
|
||||||
|
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$patchposition = [
|
||||||
|
'equipmentName' => $wohneinheit->getPatchEqString(),
|
||||||
|
'equipmentPort' => $wohneinheit->patch_port,
|
||||||
|
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
|
||||||
|
'shelf' => $wohneinheit->patch_shelf,
|
||||||
|
'module' => $wohneinheit->patch_module,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get dropcable data from metadata
|
||||||
|
$dropkabelData = [];
|
||||||
|
$ahaParsed = null;
|
||||||
|
$mapFile = null;
|
||||||
|
if (!empty($workorder->metadata)) {
|
||||||
|
$metadata = json_decode($workorder->metadata, true);
|
||||||
|
if (!empty($metadata['dropcable'])) {
|
||||||
|
$ahaParsed = $metadata['dropcable']['parsed_at'] ?? null;
|
||||||
|
$dropkabelData = $metadata['dropcable']['entries'] ?? [];
|
||||||
|
if ($mapFileId = $metadata['dropcable']['map_file_id'] ?? null) {
|
||||||
|
$file = new File($mapFileId);
|
||||||
|
if ($file->id) {
|
||||||
|
$mapFile = ['id' => $file->id, 'name' => $file->name, 'download_url' => '/File/show?id=' . $file->id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rimoWorkorders = [];
|
||||||
|
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
|
||||||
|
foreach ($wohneinheit->rimo_workorders as $wo) {
|
||||||
|
$rimoWorkorders[] = [
|
||||||
|
'id' => $wo->id, 'rimoName' => $wo->rimo_name, 'rimoId' => $wo->rimo_id,
|
||||||
|
'rimoStatus' => $wo->rimo_status, 'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'patchposition' => $patchposition,
|
||||||
|
'rimoWorkorders' => $rimoWorkorders,
|
||||||
|
'dropcable' => ['parsed_at' => $ahaParsed, 'entries' => $dropkabelData, 'map_file' => $mapFile],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud
|
|||||||
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
|
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-parse AHA if enabled and not yet parsed
|
||||||
|
if ($tenantConfig?->showTechnicalData) {
|
||||||
|
RimoWorkorder::autoParseForWorkorder((int)$this->request->workorderId);
|
||||||
|
}
|
||||||
|
|
||||||
$responseDocs = [];
|
$responseDocs = [];
|
||||||
$typeCounts = [];
|
$typeCounts = [];
|
||||||
@@ -145,45 +149,7 @@ class WorkorderBaseController extends TTCrud
|
|||||||
* Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
|
* Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
|
||||||
*/
|
*/
|
||||||
protected function getTechnicalData(int $workorderId): ?array {
|
protected function getTechnicalData(int $workorderId): ?array {
|
||||||
$workorder = WorkorderModel::get($workorderId);
|
return WorkorderModel::getTechnicalData($workorderId);
|
||||||
if (!$workorder || !$workorder->preorderId) return null;
|
|
||||||
|
|
||||||
$preorder = new Preorder($workorder->preorderId);
|
|
||||||
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
|
|
||||||
|
|
||||||
$wohneinheit = $preorder->adb_wohneinheit;
|
|
||||||
if (!$wohneinheit) return null;
|
|
||||||
|
|
||||||
$defaultCluster = '';
|
|
||||||
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
|
|
||||||
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$patchposition = [
|
|
||||||
'equipmentName' => $wohneinheit->getPatchEqString(),
|
|
||||||
'equipmentPort' => $wohneinheit->patch_port,
|
|
||||||
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
|
|
||||||
'shelf' => $wohneinheit->patch_shelf,
|
|
||||||
'module' => $wohneinheit->patch_module,
|
|
||||||
];
|
|
||||||
|
|
||||||
$rimoWorkorders = [];
|
|
||||||
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
|
|
||||||
foreach ($wohneinheit->rimo_workorders as $wo) {
|
|
||||||
$rimoWorkorders[] = [
|
|
||||||
'id' => $wo->id,
|
|
||||||
'rimoName' => $wo->rimo_name,
|
|
||||||
'rimoId' => $wo->rimo_id,
|
|
||||||
'rimoStatus' => $wo->rimo_status,
|
|
||||||
'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'patchposition' => $patchposition,
|
|
||||||
'rimoWorkorders' => $rimoWorkorders,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//region BACKGROUND TASKS
|
//region BACKGROUND TASKS
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"stomp-php/stomp-php": "^5",
|
"stomp-php/stomp-php": "^5",
|
||||||
"phpmailer/phpmailer": "^6.9",
|
"phpmailer/phpmailer": "^6.9",
|
||||||
"pear2/net_routeros": "dev-develop@dev",
|
"pear2/net_routeros": "dev-develop@dev",
|
||||||
"matthiasmullie/minify": "^1.3"
|
"matthiasmullie/minify": "^1.3",
|
||||||
|
"smalot/pdfparser": "^2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
db/migrations/20260119170000_add_metadata_to_workorder.php
Normal file
30
db/migrations/20260119170000_add_metadata_to_workorder.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class AddMetadataToWorkorder extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
$table = $this->table("Workorder");
|
||||||
|
$table
|
||||||
|
->addColumn("metadata", "json", [
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'after' => 'cableType'
|
||||||
|
])
|
||||||
|
->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if ($this->getEnvironment() == "thetool") {
|
||||||
|
$this->table("Workorder")
|
||||||
|
->removeColumn("metadata")
|
||||||
|
->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,38 +275,59 @@ Vue.component('workorder-details-manager', {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showTechnicalData && technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)" class="card mb-3">
|
<div v-if="showTechnicalData && technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)" class="card mb-3">
|
||||||
<div class="card-header bg-purple text-white">
|
<div class="card-header bg-purple text-white d-flex justify-content-between align-items-center py-2">
|
||||||
<h5 class="mb-0"><i class="fas fa-microchip mr-2"></i>Technische Daten</h5>
|
<span><i class="fas fa-microchip mr-2"></i>Technische Daten</span>
|
||||||
|
<small v-if="technicalData.dropcable?.parsed_at" class="opacity-75">
|
||||||
|
<i class="fas fa-check mr-1"></i>{{ formatDate(technicalData.dropcable.parsed_at) }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body py-2">
|
||||||
<div class="row">
|
<div class="row small">
|
||||||
<div class="col-md-6" v-if="technicalData.patchposition?.equipmentName">
|
<div class="col-md-6 mb-2" v-if="technicalData.patchposition?.equipmentName">
|
||||||
<h6>Patchposition</h6>
|
<div class="d-flex">
|
||||||
<table class="table table-sm table-striped mb-0">
|
<span class="text-muted mr-2"><i class="fas fa-ethernet"></i></span>
|
||||||
<tr>
|
<div>
|
||||||
<th class="border-top-0">Equipment Name:</th>
|
<span class="text-monospace font-weight-bold">{{ technicalData.patchposition.equipmentName }}</span>
|
||||||
<td class="border-top-0 text-monospace">{{ technicalData.patchposition.equipmentName }}</td>
|
<span v-if="technicalData.patchposition.equipmentPort" class="text-muted ml-1">Port {{ technicalData.patchposition.equipmentPort }}</span>
|
||||||
</tr>
|
|
||||||
<tr v-if="technicalData.patchposition.equipmentPort">
|
|
||||||
<th>Equipment Port:</th>
|
|
||||||
<td class="text-monospace">{{ technicalData.patchposition.equipmentPort }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6" v-if="technicalData.rimoWorkorders?.length">
|
|
||||||
<h6>AHA Blätter</h6>
|
|
||||||
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="mb-2">
|
|
||||||
<div class="d-flex align-items-center justify-content-between border rounded p-2">
|
|
||||||
<div>
|
|
||||||
<strong>{{ wo.rimoName }}</strong>
|
|
||||||
<small class="text-muted ml-2">{{ wo.rimoStatus }}</small>
|
|
||||||
</div>
|
|
||||||
<a :href="wo.downloadUrl" target="_blank" class="btn btn-sm btn-outline-primary">
|
|
||||||
<i class="fas fa-file-pdf mr-1"></i> AHA Blatt
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 mb-2" v-if="technicalData.rimoWorkorders?.length">
|
||||||
|
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="d-flex align-items-center justify-content-between">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="text-muted mr-2"><i class="fas fa-file-alt"></i></span>
|
||||||
|
<span class="font-weight-bold">{{ wo.rimoName }}</span>
|
||||||
|
<span class="badge badge-light ml-2">{{ wo.rimoStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a :href="wo.downloadUrl" target="_blank" class="btn btn-outline-secondary" title="AHA PDF"><i class="fas fa-file-pdf"></i></a>
|
||||||
|
<button @click="parseAha(wo)" class="btn btn-outline-secondary" :disabled="parsingAhaId === wo.id" :title="technicalData.dropcable?.parsed_at ? 'Aktualisieren' : 'Daten laden'">
|
||||||
|
<i :class="parsingAhaId === wo.id ? 'fas fa-spinner fa-spin' : 'fas fa-sync-alt'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="technicalData.dropcable?.entries?.length" class="mt-2">
|
||||||
|
<table class="table table-sm table-bordered mb-0 small">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr><th>Kabel-ID</th><th>Typ</th><th class="text-center">PLAN</th><th class="text-center">IST</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(dk, idx) in technicalData.dropcable.entries" :key="idx">
|
||||||
|
<td class="text-monospace">{{ dk.cable_id }}</td>
|
||||||
|
<td class="text-truncate" style="max-width:200px" :title="dk.type">{{ dk.type }}</td>
|
||||||
|
<td class="text-center">{{ dk.laenge_plan || '-' }}</td>
|
||||||
|
<td class="text-center">{{ dk.laenge_ist || '-' }}</td>
|
||||||
|
<td><span class="badge" :class="dk.status === 'Planfreigabe' ? 'badge-success' : 'badge-secondary'">{{ dk.status || '-' }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="technicalData.dropcable?.map_file" class="mt-2 text-center">
|
||||||
|
<a :href="technicalData.dropcable.map_file.download_url" target="_blank" class="d-block">
|
||||||
|
<img :src="technicalData.dropcable.map_file.download_url" class="img-fluid border rounded shadow-sm" style="max-height:300px;cursor:zoom-in" :alt="'Lageplan'" @error="$event.target.parentElement.style.display='none'">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,6 +389,7 @@ Vue.component('workorder-details-manager', {
|
|||||||
// Technical data
|
// Technical data
|
||||||
showTechnicalData: false,
|
showTechnicalData: false,
|
||||||
technicalData: null,
|
technicalData: null,
|
||||||
|
parsingAhaId: null,
|
||||||
// Admin state
|
// Admin state
|
||||||
selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false,
|
selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false,
|
||||||
}),
|
}),
|
||||||
@@ -417,6 +439,8 @@ Vue.component('workorder-details-manager', {
|
|||||||
// FIX: Ensure docs and journals are always arrays
|
// FIX: Ensure docs and journals are always arrays
|
||||||
this.docs = docsJournalsRes.data.docs || [];
|
this.docs = docsJournalsRes.data.docs || [];
|
||||||
this.journals = docsJournalsRes.data.journals || [];
|
this.journals = docsJournalsRes.data.journals || [];
|
||||||
|
// Reload tenant config to get updated technical data (AHA may have been auto-parsed)
|
||||||
|
if (this.showTechnicalData) this.loadTenantConfig();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.notify('error', 'Details konnten nicht geladen werden.');
|
window.notify('error', 'Details konnten nicht geladen werden.');
|
||||||
this.docs = []; // Ensure it's an array on error
|
this.docs = []; // Ensure it's an array on error
|
||||||
@@ -576,6 +600,24 @@ Vue.component('workorder-details-manager', {
|
|||||||
this.showAcceptModal = false;
|
this.showAcceptModal = false;
|
||||||
},
|
},
|
||||||
getInterventionLabel(type) { return this.interventionTypes.find(t => t.value === type)?.text || type; },
|
getInterventionLabel(type) { return this.interventionTypes.find(t => t.value === type)?.text || type; },
|
||||||
|
async parseAha(wo) {
|
||||||
|
this.parsingAhaId = wo.id;
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RimoWorkorder/parseAha`, { id: wo.id });
|
||||||
|
if (data.success) {
|
||||||
|
window.notify('success', `AHA-Daten geladen: ${data.dropkabel_count} Dropkabel${data.has_map ? ', Lageplan vorhanden' : ''}`);
|
||||||
|
// Reload technical data to show the parsed data
|
||||||
|
await this.loadTenantConfig();
|
||||||
|
} else {
|
||||||
|
window.notify('error', data.message || 'Fehler beim Laden der AHA-Daten');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.notify('error', 'Netzwerkfehler beim Laden der AHA-Daten');
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
this.parsingAhaId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
async revertDocumentedStatus() {
|
async revertDocumentedStatus() {
|
||||||
// Optional: Add loading state if needed
|
// Optional: Add loading state if needed
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export default {
|
|||||||
const showPdfViewer = ref(false);
|
const showPdfViewer = ref(false);
|
||||||
const pdfViewerUrl = ref('');
|
const pdfViewerUrl = ref('');
|
||||||
const pdfViewerTitle = ref('');
|
const pdfViewerTitle = ref('');
|
||||||
|
const showImageViewer = ref(false);
|
||||||
|
const imageViewerUrl = ref('');
|
||||||
|
|
||||||
// Upload state
|
// Upload state
|
||||||
const uploadDocType = ref('');
|
const uploadDocType = ref('');
|
||||||
@@ -631,6 +633,16 @@ export default {
|
|||||||
showPdfViewer.value = true;
|
showPdfViewer.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Open image in fullscreen viewer with zoom
|
||||||
|
const openImageViewer = (url) => {
|
||||||
|
imageViewerUrl.value = url;
|
||||||
|
showImageViewer.value = true;
|
||||||
|
};
|
||||||
|
const closeImageViewer = () => {
|
||||||
|
showImageViewer.value = false;
|
||||||
|
imageViewerUrl.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchWorkorders();
|
fetchWorkorders();
|
||||||
@@ -697,6 +709,10 @@ export default {
|
|||||||
callCustomer,
|
callCustomer,
|
||||||
handleComplete,
|
handleComplete,
|
||||||
openPdfViewer,
|
openPdfViewer,
|
||||||
|
openImageViewer,
|
||||||
|
closeImageViewer,
|
||||||
|
showImageViewer,
|
||||||
|
imageViewerUrl,
|
||||||
handleTouchStart,
|
handleTouchStart,
|
||||||
handleTouchMove,
|
handleTouchMove,
|
||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
@@ -945,38 +961,95 @@ export default {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="expandedCards.technical" class="px-4 pb-4 space-y-3">
|
<div v-if="expandedCards.technical" class="px-4 pb-4 space-y-3">
|
||||||
<!-- Patchposition -->
|
<!-- 1. Patchposition -->
|
||||||
<div v-if="technicalData.patchposition?.equipmentName" class="space-y-2">
|
<div v-if="technicalData.patchposition?.equipmentName" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||||||
<div class="text-sm font-medium text-slate-500 dark:text-slate-400">Patchposition</div>
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-1">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div class="flex justify-between">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
<span class="text-slate-500 dark:text-slate-400 text-sm">Equipment Name:</span>
|
</svg>
|
||||||
<span class="font-mono text-slate-900 dark:text-white">{{ technicalData.patchposition.equipmentName }}</span>
|
<span class="text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase tracking-wide">Patchposition</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="technicalData.patchposition.equipmentPort" class="flex justify-between">
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
<span class="text-slate-500 dark:text-slate-400 text-sm">Equipment Port:</span>
|
<div class="text-xs"><span class="text-slate-400">Equipment:</span> <span class="font-mono font-semibold text-slate-800 dark:text-white">{{ technicalData.patchposition.equipmentName }}</span></div>
|
||||||
<span class="font-mono text-slate-900 dark:text-white">{{ technicalData.patchposition.equipmentPort }}</span>
|
<div v-if="technicalData.patchposition.equipmentPort" class="text-xs"><span class="text-slate-400">Port:</span> <span class="font-mono font-semibold text-slate-800 dark:text-white">{{ technicalData.patchposition.equipmentPort }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="technicalData.patchposition.cluster || technicalData.patchposition.shelf || technicalData.patchposition.module" class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-slate-200 dark:border-slate-600">
|
||||||
|
<div v-if="technicalData.patchposition.cluster" class="text-xs"><span class="text-slate-400">Cluster:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.cluster }}</span></div>
|
||||||
|
<div v-if="technicalData.patchposition.shelf" class="text-xs"><span class="text-slate-400">Shelf:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.shelf }}</span></div>
|
||||||
|
<div v-if="technicalData.patchposition.module" class="text-xs"><span class="text-slate-400">Module:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.module }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Dropkabel -->
|
||||||
|
<div v-if="technicalData.dropcable?.entries?.length" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-semibold text-sky-600 dark:text-sky-400 uppercase tracking-wide">Dropkabel</span>
|
||||||
|
<span class="ml-auto text-[10px] bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-400 px-1.5 py-0.5 rounded-full font-medium">{{ technicalData.dropcable.entries.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-slate-200 dark:divide-slate-600">
|
||||||
|
<div v-for="(dk, idx) in technicalData.dropcable.entries" :key="idx" class="py-2 first:pt-0 last:pb-0">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<span class="font-mono text-xs font-semibold text-slate-800 dark:text-white">{{ dk.cable_id }}</span>
|
||||||
|
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
:class="dk.status === 'Planfreigabe' || dk.status === 'Plan released' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : dk.status === 'Executed' || dk.status === 'Ausgeführt' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' : 'bg-slate-200 text-slate-600 dark:bg-slate-600 dark:text-slate-300'">{{ dk.status || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div v-if="dk.type" class="text-slate-500 dark:text-slate-400 truncate max-w-[200px]" :title="dk.type">{{ dk.type }}</div>
|
||||||
|
<div class="ml-auto flex gap-3">
|
||||||
|
<span><span class="text-slate-400">Plan:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ dk.laenge_plan || '-' }}</span></span>
|
||||||
|
<span><span class="text-slate-400">Ist:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ dk.laenge_ist || '-' }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AHA Blätter -->
|
<!-- 3. Lageplan -->
|
||||||
<div v-if="technicalData.rimoWorkorders?.length" class="space-y-2">
|
<div v-if="technicalData.dropcable?.map_file" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||||||
<div class="text-sm font-medium text-slate-500 dark:text-slate-400">AHA Blätter</div>
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 flex items-center justify-between">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/>
|
||||||
<div>
|
</svg>
|
||||||
<div class="font-medium text-slate-900 dark:text-white">{{ wo.rimoName }}</div>
|
<span class="text-xs font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide">Lageplan</span>
|
||||||
<div class="text-xs text-slate-500 dark:text-slate-400">Status: {{ wo.rimoStatus }}</div>
|
</div>
|
||||||
|
<button @click="openImageViewer(technicalData.dropcable.map_file.download_url)" class="w-full active:scale-[0.99] transition">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-1 border border-slate-200 dark:border-slate-600">
|
||||||
|
<img :src="technicalData.dropcable.map_file.download_url" class="w-full h-44 object-contain rounded" alt="Lageplan" @error="$event.target.closest('.bg-slate-50').style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
<button @click="openPdfViewer(wo.downloadUrl, wo.rimoName)"
|
<div class="flex items-center justify-center gap-1 mt-2 text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-lg text-sm font-medium active:scale-95 transition">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
AHA
|
Antippen zum Vergrößern
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. AHA Blatt -->
|
||||||
|
<div v-if="technicalData.rimoWorkorders?.length" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">AHA Blatt</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-slate-200 dark:divide-slate-600">
|
||||||
|
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-xs font-medium text-slate-800 dark:text-white truncate">{{ wo.rimoName }}</div>
|
||||||
|
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ wo.rimoStatus }}</div>
|
||||||
|
</div>
|
||||||
|
<button @click="openPdfViewer(wo.downloadUrl, wo.rimoName)"
|
||||||
|
class="flex-shrink-0 flex items-center gap-1 px-2.5 py-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-[11px] font-semibold active:scale-95 transition">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1483,6 +1556,37 @@ export default {
|
|||||||
</transition>
|
</transition>
|
||||||
</teleport>
|
</teleport>
|
||||||
|
|
||||||
|
<!-- Image Viewer Modal with Zoom -->
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showImageViewer" class="fixed inset-0 z-50 flex flex-col bg-black/95" @click.self="closeImageViewer">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 flex-shrink-0" style="padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Lageplan</h3>
|
||||||
|
<button @click="closeImageViewer" class="p-2 -mr-2 text-white/70 hover:text-white">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Image Content with pinch zoom -->
|
||||||
|
<div class="flex-1 overflow-auto flex items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
:src="imageViewerUrl"
|
||||||
|
class="max-w-none select-none"
|
||||||
|
style="touch-action: pinch-zoom; max-height: calc(100vh - 150px); width: auto;"
|
||||||
|
alt="Lageplan"
|
||||||
|
@dblclick="$event.target.style.transform = $event.target.style.transform === 'scale(2)' ? 'scale(1)' : 'scale(2)'"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- Footer hint -->
|
||||||
|
<div class="flex-shrink-0 px-4 py-3 text-center" style="padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));">
|
||||||
|
<p class="text-white/50 text-xs">Pinch zum Zoomen • Doppeltippen für 2x</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
Reference in New Issue
Block a user