diff --git a/Layout/default/VueViews/Vue.php b/Layout/default/VueViews/Vue.php index 5869d53b1..23f7bd02e 100644 --- a/Layout/default/VueViews/Vue.php +++ b/Layout/default/VueViews/Vue.php @@ -23,6 +23,7 @@ $additionalCSS = [ 'plugins/vue/tt-components/css/tt-table.css', 'plugins/vue/tt-components/css/tt-tooltip.css', 'plugins/vue/tt-components/css/tt-loader.css', + 'plugins/vue/tt-components/css/tt-file-gallery.css', 'plugins/vue/tt-components/css/tt-position-manager.css', ]; diff --git a/Layout/default/footer.php b/Layout/default/footer.php index f39026bcd..2fa965d51 100644 --- a/Layout/default/footer.php +++ b/Layout/default/footer.php @@ -22,11 +22,13 @@ - diff --git a/application/File/FileController.php b/application/File/FileController.php index 705600ab7..158913a55 100644 --- a/application/File/FileController.php +++ b/application/File/FileController.php @@ -92,27 +92,24 @@ class FileController extends mfBaseController { $id = $this->request->id; $size = $this->request->size; - if (!is_numeric($id) || $id < 1) { - http_response_code(400); - self::returnJson(["error" => "Invalid File ID"]); - return; - } + if (!is_numeric($id) || $id < 1) self::sendError("Invalid File ID"); $file = new File($id); - if (!$file->id) { - http_response_code(404); - self::returnJson(["error" => "File record not found"]); - return; - } + if (!$file->id) self::sendError("File record not found"); $originalPath = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}"; - if (!is_readable($originalPath)) { - http_response_code(404); - self::returnJson(["error" => "Physical file not found"]); - return; - } + if (!is_readable($originalPath)) self::sendError("Physical file not found"); + $imageInfo = @getimagesize($originalPath); + + if ($imageInfo === false && mime_content_type($originalPath) === 'application/pdf') { + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="' . ($file->orig_filename ?: $file->store_filename) . '"'); + readfile($originalPath); + exit; + } + if ($imageInfo === false) { $this->downloadAction(); return; @@ -129,18 +126,13 @@ class FileController extends mfBaseController { $cacheDir = TEMP_DIR . "/thumbnails"; @mkdir($cacheDir, 0775, true); - $cachedPath = "{$cacheDir}/{$id}_{$size}." . pathinfo($originalPath, PATHINFO_EXTENSION); if (!file_exists($cachedPath)) { $command = "convert " . escapeshellarg($originalPath) . " -resize " . escapeshellarg($sizeDimensions[$size]) . " " . escapeshellarg($cachedPath); exec($command, $output, $return_var); - if ($return_var !== 0) { - http_response_code(500); - self::returnJson(["error" => "Failed to create thumbnail."]); - return; - } + if ($return_var !== 0) self::sendError("Failed to create thumbnail."); } header('Content-Type: ' . $imageInfo['mime']); @@ -148,5 +140,4 @@ class FileController extends mfBaseController { readfile($cachedPath); exit; } - } \ No newline at end of file diff --git a/application/RMLWorkorder/RMLWorkorderController.php b/application/RMLWorkorder/RMLWorkorderController.php deleted file mode 100644 index 22e68b388..000000000 --- a/application/RMLWorkorder/RMLWorkorderController.php +++ /dev/null @@ -1,267 +0,0 @@ - 'id', 'text' => 'Auftrag-Nr.'], - ['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]], - ['key' => 'companyName', 'text' => 'Zuständige Firma', 'modal' => false, 'table' => ['filter' => 'search']], - ['key' => 'status', 'text' => 'Status', 'modal' => ['items' => [ - ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], - ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], - ['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'], - ['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'], - ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'], - ]], 'table' => ['filter' => 'iconSelect']], - ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']], - ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']], - ['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]], - ]; - - protected array $additionalJSVariables = ['RML_ADMIN' => '0', 'COMPANY_ID' => '0']; - - protected function prepareCrudConfig() { - // Assume 'RMLAdmin' is a permission. - if ($this->user->can('RMLAdmin')) { - $this->additionalJSVariables['RML_ADMIN'] = '1'; - } else { - // If not an admin, find the user's associated company ID - $company = RMLWorkorderCompanyModel::getAll(['addressId' => $this->user->address_id], 1); - if ($company) { - $this->additionalJSVariables['COMPANY_ID'] = $company[0]->id; - } else { - // If user is not an RML admin and not linked to a company, they see nothing. - $this->sendError('Access Denied. You are not associated with a registered RML company.', 403); - } - } - } - - protected function getAction() - { - // First, automatically create workorders for any new preorders with status 220. - // In a production environment, this might be a separate cron job. - $this->createWorkordersFromPreorders(); - - $json = json_decode(file_get_contents('php://input'), true); - $pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10]; - $filters = $json['filters'] ?? []; - $order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC']; - - // If user is a company, filter by their companyId - if ($this->user->can('RMLAdmin') === false) { - $company = RMLWorkorderCompanyModel::getAll(['addressId' => $this->user->address_id], 1); - if($company) { - $filters['companyId'] = $company[0]->id; - } - } - - $workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order); - $totalCount = RMLWorkorderModel::count($filters); - - // Enhance rows with data from other tables - $rows = []; - foreach($workorders as $workorder) { - $row = (array)$workorder; - - $preorder = new Preorder($workorder->preorderId); // Placeholder for actual Preorder retrieval - $anschlussadresse = ''; - if ($preorder->building_id) { - $anschlussadresse = "{$preorder->building->street}
{$preorder->building->zip} {$preorder->building->city}"; - } elseif ($preorder->adb_hausnummer_id) { - $anschlussadresse = "{$preorder->adb_hausnummer->strasse->name} {$preorder->adb_hausnummer->hausnummer}"; - if ($preorder->adb_hausnummer->stiege) { - $anschlussadresse .= "/{$preorder->adb_hausnummer->stiege}"; - } - if ($preorder->adb_wohneinheit_id && (string)$preorder->adb_wohneinheit) { - $anschlussadresse .= "
{$preorder->adb_wohneinheit}"; - } - $anschlussadresse .= "
{$preorder->adb_hausnummer->plz->plz} {$preorder->adb_hausnummer->ortschaft->name}"; - } - - $kunde = ($preorder->company) ? $preorder->company : "{$preorder->firstname} {$preorder->lastname}"; - $kunde .= "
{$preorder->street}"; - if ($preorder->housenumber) { - $kunde .= " {$preorder->housenumber}"; - } - $kunde .= "
{$preorder->zip} {$preorder->city}"; - - $kontakt = ($preorder->phone) ? "{$preorder->phone}
" : ''; - $kontakt .= ($preorder->email) ? $preorder->email : ''; - - $row['preorderInfo'] = "Anschlussadresse: {$anschlussadresse}
" . - "Kunde: {$kunde}
" . - "Kontakt: {$kontakt}
" . - "OAID: {$preorder->oaid}"; - - // Get Company Name - if($workorder->companyId) { - $company = RMLWorkorderCompanyModel::get($workorder->companyId); - $row['companyName'] = $company->name ?? 'N/A'; - } else { - $row['companyName'] = 'Nicht zugewiesen'; - } - - $rows[] = $row; - } - - $pagination = [ - 'page' => $pagination['page'], - 'per_page' => $pagination['per_page'], - 'filtered_available' => $totalCount, - 'total_rows' => $totalCount, - ]; - - self::returnJson([ - 'rows' => $rows, - 'pagination' => $pagination - ]); - } - - private function createWorkordersFromPreorders() { - // Fetch all active preorders where the status code is 220 - $newPreorders = PreorderModel::searchActive(['status_code' => 220]); - - // If no new preorders are found, there's nothing to do - if (empty($newPreorders)) { - return; - } - - // Iterate through each preorder that needs a workorder - foreach ($newPreorders as $preorder) { - // Check if a workorder for this preorder already exists to prevent duplicates - $existingWorkorder = RMLWorkorderModel::getFirst(['preorderId' => $preorder->id]); - - // If no workorder exists, create a new one - if (!$existingWorkorder) { - RMLWorkorderModel::create([ - 'preorderId' => $preorder->id, - 'status' => 'new', - 'create' => time(), - 'createBy' => $this->user->id // The logged-in user creating the record - ]); - } - } - } - - protected function assignWorkorderAction() { - if (!$this->user->can('RMLAdmin')) self::sendError("Permission denied.", 403); - - $post = json_decode(file_get_contents('php://input'), true); - if (empty($post['workorderId']) || empty($post['companyId'])) { - self::sendError("Required fields are missing."); - } - - if (!$rmlWorkorder = RMLWorkorderModel::get($post['workorderId'])) self::sendError("Workorder not found."); - - RMLWorkorderModel::update( - array_merge((array) $rmlWorkorder, [ - 'id' => $post['workorderId'], - 'companyId' => $post['companyId'], - 'status' => 'assigned', - 'assignmentDate' => time(), - 'deadlineDate' => strtotime('+6 weeks') - ]) - ); - - self::returnJson(['success' => true, 'message' => 'Auftrag erfolgreich zugewiesen.']); - } - - protected function scheduleAppointmentAction() { - $post = json_decode(file_get_contents('php://input'), true); - if (empty($post['workorderId']) || empty($post['appointmentDate'])) { - self::sendError("Required fields are missing."); - } - - RMLWorkorderModel::update([ - 'id' => $post['workorderId'], - 'appointmentDate' => $post['appointmentDate'], - 'status' => 'scheduled' - ]); - - self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']); - } - - protected function uploadDocumentationAction() - { - $file = $_FILES['file'] ?? null; - if (!$file || $file['error'] !== UPLOAD_ERR_OK) { - self::returnJson(['error' => 'File upload failed']); - return; - } - - $workorderId = $_POST['workorderId'] ?? null; - $description = $_POST['description'] ?? ''; - $documentType = $_POST['documentType'] ?? 'general'; - - if(!$workorderId) { - self::returnJson(['error' => 'Workorder ID is missing.']); - return; - } - - try { - $uploaded = mfUpload::handleFormUpload("file", false, "/RMLWorkorder"); - - RMLWorkorderDocumentationModel::create([ - 'workorderId' => $workorderId, - 'fileId' => $uploaded->id, - 'description' => $description, - 'documentType' => $documentType, - 'create' => time(), - 'createBy' => $this->user->id - ]); - - // Set status to 'documented' if it was 'scheduled' or 'assigned' - $workorder = RMLWorkorderModel::get($workorderId); - if(in_array($workorder->status, ['assigned', 'scheduled'])) { - RMLWorkorderModel::update(['id' => $workorderId, 'status' => 'documented']); - } - - self::returnJson(['success' => true, 'fileId' => $uploaded->id, 'fileName' => $file['name']]); - } catch (Exception $e) { - self::returnJson(['error' => 'Upload error: ' . $e->getMessage()]); - } - } - - protected function getDocumentationAction() { - if(empty($this->request->workorderId)) self::sendError("Workorder ID missing."); - - $docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId]); - // Enhance with file names - foreach($docs as $doc) { - $file = new File($doc->fileId); - $doc->fileName = $file->filename; - } - self::returnJson($docs); - } - - protected function completeWorkorderAction() { - $post = json_decode(file_get_contents('php://input'), true); - if(empty($post['workorderId'])) self::sendError("Workorder ID missing."); - - $workorder = RMLWorkorderModel::get($post['workorderId']); - if(!$workorder) self::sendError("Workorder not found."); - - // Update Preorder status to 245 - // PreorderModel::update(['id' => $workorder->preorderId, 'status_code' => 245]); - - // Update Workorder status - RMLWorkorderModel::update([ - 'id' => $workorder->id, - 'status' => 'completed' - ]); - - self::returnJson(['success' => true, 'message' => 'Auftrag abgeschlossen. Preorder wurde aktualisiert.']); - } - - // Action to get companies for the assignment modal - protected function getCompaniesAction() { - if(!$this->user->can('RMLAdmin')) self::sendError("Permission denied.", 403); - $companies = RMLWorkorderCompanyModel::getAll(); - $items = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies); - self::returnJson($items); - } -} \ No newline at end of file diff --git a/application/RMLWorkorderAdmin/RMLWorkorderAdminController.php b/application/RMLWorkorderAdmin/RMLWorkorderAdminController.php new file mode 100644 index 000000000..e4a87014e --- /dev/null +++ b/application/RMLWorkorderAdmin/RMLWorkorderAdminController.php @@ -0,0 +1,157 @@ + 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]], + ['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => 'search']], + ['key' => 'companyName', 'text' => 'Zuständige Firma', 'modal' => false, 'table' => ['filter' => 'search']], + ['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], + ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], + ['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'], + ['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'], + ]]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']], + ['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected function indexAction() + { + $this->createWorkordersFromPreorders(); + Helper::renderVue($this, 'RMLWorkorderAdmin', $this->headerTitle, [ + "CRUD_CONFIG" => $this->getCrudConfig(), + "TABLE_URL" => $this::getUrl("RMLWorkorderAdmin/get"), + ]); + } + + protected function getAction() + { + $json = json_decode(file_get_contents('php://input'), true); + $pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $json['filters'] ?? []; + $order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC']; + + // Custom filter logic for preorderInfo + if (!empty($filters['preorderInfo'])) { + $searchTerm = $filters['preorderInfo']; + unset($filters['preorderInfo']); + + // This is a simplified search. A more robust implementation might involve a full-text search or a more complex query. + $preorders = PreorderModel::getAll(['firstname|lastname|company|oaid' => $searchTerm]); + $preorderIds = array_map(fn($p) => $p->id, $preorders); + + if (!empty($preorderIds)) { + $filters['preorderId'] = $preorderIds; + } else { + // No preorders found, so no workorders will be found + $filters['id'] = -1; + } + } + + $workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order); + $totalCount = RMLWorkorderModel::count($filters); + + $rows = []; + foreach($workorders as $workorder) { + $row = (array)$workorder; + + $preorder = new Preorder($workorder->preorderId); + $anschlussadresse = 'N/A'; + if ($preorder->adb_hausnummer_id) { + $hn = $preorder->adb_hausnummer; + $anschlussadresse = "{$hn->strasse->name} {$hn->hausnummer}"; + if ($hn->stiege) $anschlussadresse .= "/{$hn->stiege}"; + if ($preorder->adb_wohneinheit_id) $anschlussadresse .= " / WE: {$preorder->adb_wohneinheit->bezeichner}"; + $anschlussadresse .= ", {$hn->plz->plz} {$hn->ortschaft->name}"; + } + + $kunde = ($preorder->company) ?: "{$preorder->firstname} {$preorder->lastname}"; + + $row['preorderInfo'] = "Kunde: {$kunde}
" . + "Anschluss: {$anschlussadresse}
" . + "OAID: {$preorder->oaid}"; + + if($workorder->companyId) { + $company = RMLWorkorderCompanyModel::get($workorder->companyId); + $row['companyName'] = $company->name ?? 'N/A'; + } else { + $row['companyName'] = 'Nicht zugewiesen'; + } + + $rows[] = $row; + } + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + private function createWorkordersFromPreorders() { + $newPreorders = PreorderModel::searchActive(['status_code' => 220]); + if (empty($newPreorders)) return; + + foreach ($newPreorders as $preorder) { + if (!RMLWorkorderModel::getFirst(['preorderId' => $preorder->id])) { + RMLWorkorderModel::create([ + 'preorderId' => $preorder->id, + 'status' => 'new', + 'create' => time(), + 'createBy' => $this->user->id + ]); + } + } + } + + protected function assignWorkorderAction() { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderId']) || empty($post['companyId'])) self::sendError("Required fields are missing."); + + $workorder = RMLWorkorderModel::get($post['workorderId']); + if (!$workorder) self::sendError("Workorder not found."); + + $workorder->companyId = $post['companyId']; + $workorder->status = 'assigned'; + $workorder->assignmentDate = time(); + $workorder->deadlineDate = strtotime('+6 weeks'); + + RMLWorkorderModel::update((array)$workorder); + + self::returnJson(['success' => true, 'message' => 'Auftrag erfolgreich zugewiesen.']); + } + + protected function getDocumentationAction() { + if(empty($this->request->workorderId)) self::sendError("Workorder ID missing."); + + $docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'DESC']); + $users = UserModel::search(['employee' => true]); + $userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []); + + foreach($docs as $doc) { + $file = new File($doc->fileId); + $doc->fileName = $file->orig_filename ?? $file->filename; + $doc->userName = $userMap[$doc->createBy] ?? 'Unbekannt'; + } + self::returnJson($docs); + } + + protected function getCompaniesAction() { + $companies = RMLWorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']); + $items = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies); + self::returnJson($items); + } +} \ No newline at end of file diff --git a/application/RMLWorkorderCompany/RMLWorkorderCompanyController.php b/application/RMLWorkorderCompany/RMLWorkorderCompanyController.php new file mode 100644 index 000000000..3829864ea --- /dev/null +++ b/application/RMLWorkorderCompany/RMLWorkorderCompanyController.php @@ -0,0 +1,244 @@ + 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]], + ['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => 'search']], + ['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [ + ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], + ['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'], + ['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'], + ['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'], + ['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'], + ]]], + ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']], + ['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']], + ['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected array $additionalJSVariables = ['COMPANY_ID' => '0']; + + protected function prepareCrudConfig() { + $company = RMLWorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if ($company) { + $this->additionalJSVariables['COMPANY_ID'] = $company->id; + } else { + $this->sendError('Access Denied. You are not associated with a registered RML company.', 403); + } + } + + protected function indexAction() + { + Helper::renderVue($this, 'RMLWorkorderCompany', $this->headerTitle, [ + "CRUD_CONFIG" => $this->getCrudConfig(), + "TABLE_URL" => $this::getUrl("RMLWorkorderCompany/get"), + "COMPANY_ID" => $this->additionalJSVariables['COMPANY_ID'], + ]); + } + + protected function getAction() + { + $json = json_decode(file_get_contents('php://input'), true); + $pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10]; + $filters = $json['filters'] ?? []; + $order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC']; + + $company = RMLWorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]); + if(!$company) self::sendError("Company not found for user.", 403); + $filters['companyId'] = $company->id; + + if (!empty($filters['preorderInfo'])) { + $searchTerm = $filters['preorderInfo']; + + //todo: fix this preordermodel search shit + $preorders = PreorderModel::getAll(['firstname|lastname|company|oaid' => $searchTerm]); + $preorderIds = array_map(fn($p) => $p->id, $preorders); + + if (!empty($preorderIds)) { + $filters['preorderId'] = $preorderIds; + } else { + $filters['id'] = -1; + } + } + unset($filters['preorderInfo']); + // only show workorders that are assigned to the company and have the status assigned or scheduled + $filters['status'] = ['assigned', 'scheduled']; + $filters['companyId'] = $company->id; + + + $workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order); + $totalCount = RMLWorkorderModel::count($filters); + + $rows = []; + foreach($workorders as $workorder) { + $row = (array)$workorder; + $row['preorderInfo'] = $this->getPreorderInfoText($workorder->preorderId); + $rows[] = $row; + } + + self::returnJson([ + 'rows' => $rows, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total_rows' => $totalCount, + 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount + ] + ]); + } + + public function getWorkorderByIdAction() { + $id = $this->request->id; + if(!$id) self::sendError("ID missing"); + + $workorder = RMLWorkorderModel::get($id); + if(!$workorder) self::sendError("Workorder not found"); + + $workorder->preorderInfo = $this->getPreorderInfoText($workorder->preorderId); + + self::returnJson((array) $workorder); + } + + private function getPreorderInfoText($preorderId) { + $preorder = new Preorder($preorderId); + $anschlussadresse = 'N/A'; + if ($preorder->adb_hausnummer_id) { + $hn = $preorder->adb_hausnummer; + $anschlussadresse = "{$hn->strasse->name} {$hn->hausnummer}"; + if ($hn->stiege) $anschlussadresse .= "/{$hn->stiege}"; + if ($preorder->adb_wohneinheit_id) $anschlussadresse .= " / WE: {$preorder->adb_wohneinheit->bezeichner}"; + $anschlussadresse .= ", {$hn->plz->plz} {$hn->ortschaft->name}"; + } + + $kunde = ($preorder->company) ?: "{$preorder->firstname} {$preorder->lastname}"; + + return "Kunde: {$kunde}
" . + "Anschluss: {$anschlussadresse}
" . + "Kontakt: {$preorder->phone} / {$preorder->email}
" . + "OAID: {$preorder->oaid}"; + } + + protected function scheduleAppointmentAction() { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['workorderId']) || empty($post['appointmentDate'])) self::sendError("Required fields are missing."); + + $workorder = RMLWorkorderModel::get($post['workorderId']); + if(!$workorder) self::sendError("Workorder not found"); + + $workorder->appointmentDate = $post['appointmentDate']; + $workorder->status = 'scheduled'; + RMLWorkorderModel::update((array)$workorder); + + self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']); + } + + protected function uploadDocumentationAction() + { + if (empty($_FILES['files']) || empty($_POST['workorderId'])) { + self::returnJson(['error' => 'Required data is missing.']); + return; + } + + $workorderId = $_POST['workorderId']; + $description = $_POST['description'] ?? ''; + $documentType = $_POST['documentType'] ?? 'general'; + $files = $_FILES['files']; + $uploadCount = 0; + + foreach ($files['name'] as $index => $name) { + if ($files['error'][$index] === UPLOAD_ERR_OK) { + $_FILES['file'] = [ + 'name' => $files['name'][$index], + 'type' => $files['type'][$index], + 'tmp_name' => $files['tmp_name'][$index], + 'error' => $files['error'][$index], + 'size' => $files['size'][$index] + ]; + + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/RMLWorkorder"); + RMLWorkorderDocumentationModel::create([ + 'workorderId' => $workorderId, + 'fileId' => $uploaded->id, + 'description' => $description, + 'documentType' => $documentType, + 'create' => time(), + 'createBy' => $this->user->id + ]); + $uploadCount++; + } catch (Exception $e) { + var_dump($e->getMessage());exit; + // Log error but continue with other files + error_log("File upload failed for $name: " . $e->getMessage()); + } + } + } + + self::returnJson(['success' => true, 'message' => "$uploadCount Datei(en) erfolgreich hochgeladen."]); + } + + protected function getDocumentationAction() { + if(empty($this->request->workorderId)) self::sendError("Workorder ID missing."); + + // Order by creation date to ensure consistent numbering (_1, _2, etc.) + $docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'ASC']); + + $responseDocs = []; + $typeCounts = []; + + $translationMap = [ + 'photo_before' => 'Foto vorher', + 'photo_during' => 'Foto währenddessen', + 'photo_after' => 'Foto nachher', + 'measurement_protocol' => 'Messprotokoll', + 'customer_signature' => 'Kundenunterschrift', + ]; + + foreach($docs as $doc) { + $file = new File($doc->fileId); + + // Increment counter for the specific document type + $documentTypeKey = $doc->documentType; + if (!isset($typeCounts[$documentTypeKey])) { + $typeCounts[$documentTypeKey] = 1; + } else { + $typeCounts[$documentTypeKey]++; + } + + // Construct the new filename using the original key + $originalFilename = $file->orig_filename ?? $file->filename; + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey; + $newFilename = "{$translatedType} {$typeCounts[$documentTypeKey]}." . strtolower($extension); + + // Get the translated text, with a fallback to the original key + + // Build the response object with 'id' mapped from 'fileId' and the translated type + $responseDocs[] = [ + 'id' => $doc->fileId, + 'fileName' => $newFilename, + 'documentType' => $documentTypeKey, + 'mimetype' => $file->mimetype, + ]; + } + self::returnJson($responseDocs); + } + protected function completeWorkorderAction() { + $post = json_decode(file_get_contents('php://input'), true); + if(empty($post['workorderId'])) self::sendError("Workorder ID missing."); + + $workorder = RMLWorkorderModel::get($post['workorderId']); + if(!$workorder) self::sendError("Workorder not found."); + + $workorder->status = 'documented'; + RMLWorkorderModel::update((array)$workorder); + + self::returnJson(['success' => true, 'message' => 'Auftrag abgeschlossen.']); + } +} \ No newline at end of file diff --git a/db/migrations/20250723204000_CreateRmlWorkorderTables.php b/db/migrations/20250723204000_CreateRmlWorkorderTables.php new file mode 100644 index 000000000..99d4cfe42 --- /dev/null +++ b/db/migrations/20250723204000_CreateRmlWorkorderTables.php @@ -0,0 +1,59 @@ +getEnvironment() == "thetool") { + $this->execute('DROP TABLE IF EXISTS `RMLWorkorderDocumentation`'); + $this->execute('DROP TABLE IF EXISTS `RMLWorkorderCompany`'); + $this->execute('DROP TABLE IF EXISTS `RMLWorkorder`'); + + $workorder = $this->table('RMLWorkorder', ['id' => false, 'primary_key' => ['id']]); + $workorder->addColumn('id', 'integer', ['identity' => true, 'signed' => true]) + ->addColumn('preorderId', 'integer', ['null' => false]) + ->addColumn('companyId', 'integer', ['null' => true]) + ->addColumn('status', 'string', ['limit' => 50, 'null' => false, 'default' => 'new']) + ->addColumn('assignmentDate', 'integer', ['null' => true]) + ->addColumn('deadlineDate', 'integer', ['null' => true]) + ->addColumn('appointmentDate', 'integer', ['null' => true]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['preorderId'], ['name' => 'preorderId_idx']) + ->addIndex(['companyId'], ['name' => 'companyId_idx']) + ->addIndex(['status'], ['name' => 'status_idx']) + ->create(); + + $company = $this->table('RMLWorkorderCompany', ['id' => false, 'primary_key' => ['id']]); + $company->addColumn('id', 'integer', ['identity' => true, 'signed' => true]) + ->addColumn('addressId', 'integer', ['null' => false]) + ->addColumn('name', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->create(); + + $documentation = $this->table('RMLWorkorderDocumentation', ['id' => false, 'primary_key' => ['id']]); + $documentation->addColumn('id', 'integer', ['identity' => true, 'signed' => true]) + ->addColumn('workorderId', 'integer', ['null' => false]) + ->addColumn('fileId', 'integer', ['null' => false]) + ->addColumn('description', 'text', ['null' => true]) + ->addColumn('documentType', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('create', 'integer', ['null' => false]) + ->addColumn('createBy', 'integer', ['null' => false]) + ->addIndex(['workorderId'], ['name' => 'workorderId_idx']) + ->create(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table('RMLWorkorderDocumentation')->drop()->save(); + $this->table('RMLWorkorderCompany')->drop()->save(); + $this->table('RMLWorkorder')->drop()->save(); + } + } +} \ No newline at end of file diff --git a/lib/mvcfronk/mfBase/mfBaseController.php b/lib/mvcfronk/mfBase/mfBaseController.php index 6def4f80c..a9cece530 100644 --- a/lib/mvcfronk/mfBase/mfBaseController.php +++ b/lib/mvcfronk/mfBase/mfBaseController.php @@ -393,7 +393,7 @@ class mfBaseController public static function sendError(string $message): void { http_response_code(500); - self::returnJson(['success' => false, 'message' => $message]); + self::returnJson(['success' => false, 'message' => $message, 'error' => $message]); exit; } diff --git a/public/bundler.php b/public/bundler.php index 80c904b65..ba195ce24 100644 --- a/public/bundler.php +++ b/public/bundler.php @@ -51,6 +51,7 @@ $jsFiles = [ "plugins/vue/tt-components/tt-position-manager.js", "plugins/vue/tt-components/tt-tooltip.js", "plugins/vue/tt-components/tt-map.js", + "plugins/vue/tt-components/tt-file-gallery.js", ]; diff --git a/public/index.php b/public/index.php index 04591c2ed..2d0040714 100755 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,15 @@ */ // phpinfo(); exit; define('mfUI',"web"); +// set max mem to 4GB +ini_set('memory_limit', '4096M'); + +//display errors +ini_set('display_errors', 1); +ini_set('display_startup_errors', 1); +ini_set('html_errors', 1); + + if(file_exists("../config/config.php")) { require("../config/config.php"); diff --git a/public/js/pages/RMLWorkorder/RMLWorkorder.js b/public/js/pages/RMLWorkorder/RMLWorkorder.js deleted file mode 100644 index 0d7792235..000000000 --- a/public/js/pages/RMLWorkorder/RMLWorkorder.js +++ /dev/null @@ -1,423 +0,0 @@ -// RMLWorkorder.js - -// ================================================================================= -// Main Component - Switches between Admin and Company View -// ================================================================================= -Vue.component('r-m-l-workorder', { - template: ` -
- - -
- `, - data() { return { window: window } } -}); - - -// ================================================================================= -// RML Admin View -// ================================================================================= -Vue.component('rml-workorder-admin-view', { - template: ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - `, - data() { - return { - assignModalWorkorderId: null, - docsModalWorkorderId: null, - crudConfig: { - ...window.TT_CONFIG.CRUD_CONFIG, - additionalActions: [ - { - "key": "assign", - "title": "Firma zuweisen", - "class": "fas fa-user-plus text-primary", - "condition": (row) => row.status === 'new', - }, - { - "key": "view_docs", - "title": "Dokumentation ansehen", - "class": "fas fa-folder-open text-info", - "condition": (row) => ['documented', 'completed'].includes(row.status), - }, - ] - } - } - }, - methods: { - getStatusColumn(status) { - const column = this.crudConfig.columns.find(c => c.key === 'status'); - return column.table.filterOptions.find(opt => opt.value === status) || {}; - }, - formatDate(timestamp) { - if (!timestamp) return '–'; - return window.moment.unix(timestamp).format('DD.MM.YYYY'); - } - } -}); - -// ================================================================================= -// RML Company View -// ================================================================================= -Vue.component('rml-workorder-company-view', { - template: ` - - - - - - - - - - - - - - - - `, - data() { - return { - scheduleModalWorkorderId: null, - documentModalWorkorder: null, - crudConfig: { - ...window.TT_CONFIG.CRUD_CONFIG, - additionalActions: [ - { - "key": "schedule", - "title": "Termin festlegen", - "class": "fas fa-calendar-plus text-primary", - "condition": (row) => row.status === 'assigned', - }, - { - "key": "document", - "title": "Dokumentieren & Abschließen", - "class": "fas fa-camera text-success", - "condition": (row) => ['assigned', 'scheduled', 'documented'].includes(row.status), - }, - ] - } - } - }, - methods: { - getStatusColumn(status) { - const column = this.crudConfig.columns.find(c => c.key === 'status'); - return column.table.filterOptions.find(opt => opt.value === status) || {}; - }, - formatDate(timestamp) { - if (!timestamp) return '–'; - return window.moment.unix(timestamp).format('DD.MM.YYYY'); - } - } -}); - - -// ================================================================================= -// Modals and Helper Components -// ================================================================================= - -// Traffic Light Component -Vue.component('traffic-light', { - props: ['deadline', 'status'], - computed: { - lightColor() { - if (this.status === 'completed') return '#cccccc'; // Grey for completed - const now = moment(); - const deadlineDate = moment.unix(this.deadline); - if (!deadlineDate.isValid()) return '#cccccc'; // Grey for invalid date - - if (deadlineDate.isBefore(now)) return '#dc3545'; // Red for overdue - if (deadlineDate.isBefore(now.clone().add(1, 'weeks'))) return '#dc3545'; // Red - if (deadlineDate.isBefore(now.clone().add(3, 'weeks'))) return '#ffc107'; // Yellow - return '#28a745'; // Green - } - }, - template: `` -}); - -// Modal for RML Admin to assign a company -Vue.component('assign-company-modal', { - props: ['workorderId'], - template: ` - - - - `, - data() { return { companies: [], selectedCompanyId: null } }, - async mounted() { - const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getCompanies`); - this.companies = response.data; - }, - methods: { - async submit() { - if (!this.selectedCompanyId) return window.notify('error', 'Bitte eine Firma auswählen.'); - const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/assignWorkorder`, { - workorderId: this.workorderId, - companyId: this.selectedCompanyId - }); - if(response.data.success) { - window.notify('success', response.data.message); - this.$emit('close'); - } else { - window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); - } - } - } -}); - -// Modal for Company to schedule an appointment -Vue.component('schedule-appointment-modal', { - props: ['workorderId'], - template: ` - - - - `, - data() { return { appointmentDate: null } }, - methods: { - async submit() { - if (!this.appointmentDate) return window.notify('error', 'Bitte ein Datum auswählen.'); - const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/scheduleAppointment`, { - workorderId: this.workorderId, - appointmentDate: this.appointmentDate - }); - if(response.data.success) { - window.notify('success', response.data.message); - this.$emit('close'); - } else { - window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); - } - } - } -}); - - -// Documentation Upload Modal for Companies -Vue.component('documentation-modal', { - props: ['workorder'], - template: ` - -
-
Benötigte Dokumente
- -
- -
-
-
Neues Dokument hochladen
- - -
- -
- -
-
- -
-
- - - - -
- `, - data() { - return { - uploading: false, - viewerKey: 0, - uploadedFiles: [], - uploadData: { - file: null, - documentType: 'photo_before', - description: '' - }, - requiredDocTypes: [ - { value: 'photo_before', text: 'Foto: Zustand vorher' }, - { value: 'photo_during', text: 'Foto: Während der Arbeit' }, - { value: 'photo_after', text: 'Foto: Zustand nachher' }, - { value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' }, - { value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' }, - ] - } - }, - computed: { - canComplete() { - // Check if at least one of each required document type is uploaded. - return this.requiredDocTypes.every(docType => this.isUploaded(docType.value)); - } - }, - methods: { - isUploaded(docType) { - return this.uploadedFiles.some(file => file.documentType === docType); - }, - handleFileUpload(event) { - this.uploadData.file = event.target.files[0]; - }, - async uploadFile() { - if(!this.uploadData.file) return window.notify('error', 'Bitte eine Datei auswählen.'); - this.uploading = true; - - const formData = new FormData(); - formData.append('file', this.uploadData.file); - formData.append('workorderId', this.workorder.id); - formData.append('documentType', this.uploadData.documentType); - formData.append('description', this.uploadData.description); - - const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/uploadDocumentation`, formData); - if(response.data.success) { - window.notify('success', `Datei "${response.data.fileName}" wurde hochgeladen.`); - this.$refs.fileInput.value = ''; // Clear file input - this.uploadData.file = null; - this.uploadData.description = ''; - this.viewerKey++; // Refresh the viewer - } else { - window.notify('error', response.data.error || 'Upload fehlgeschlagen.'); - } - this.uploading = false; - }, - async completeWorkorder() { - if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen? Diese Aktion kann nicht rückgängig gemacht werden.')) return; - const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/completeWorkorder`, { workorderId: this.workorder.id }); - if(response.data.success) { - window.notify('success', response.data.message); - this.$emit('close'); - } else { - window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); - } - } - }, - async mounted() { - const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDocumentation`, { params: { workorderId: this.workorder.id }}); - this.uploadedFiles = response.data; - } -}); - - -// Read-only viewer for documentation, used by both Admins and Companies -Vue.component('documentation-viewer-modal', { - props: ['workorderId'], - template: ` -
-
-
Hochgeladene Dokumente
-
-
- -
-
- Keine Dokumente vorhanden. -
- -
- `, - data() { - return { loading: false, docs: [] } - }, - methods: { - async fetchDocs() { - this.loading = true; - const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDocumentation`, { params: { workorderId: this.workorderId }}); - this.docs = response.data; - this.loading = false; - }, - formatDate(timestamp) { - return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm'); - }, - getDocTypeText(type) { - const types = [ - { value: 'photo_before', text: 'Foto: Zustand vorher' }, - { value: 'photo_during', text: 'Foto: Während der Arbeit' }, - { value: 'photo_after', text: 'Foto: Zustand nachher' }, - { value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' }, - { value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' }, - ]; - return types.find(t => t.value === type)?.text || type; - } - }, - mounted() { - this.fetchDocs(); - } -}); \ No newline at end of file diff --git a/public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js b/public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js new file mode 100644 index 000000000..c3b5891e6 --- /dev/null +++ b/public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js @@ -0,0 +1,183 @@ +// RMLWorkorderAdmin.js +Vue.component('r-m-l-workorder-admin', { + template: ` + + + + + + + + + + + + + + + + + `, + data() { + return { + assignModalWorkorderId: null, + docsModalWorkorderId: null, + crudConfig: { + ...window.TT_CONFIG.CRUD_CONFIG, + additionalActions: [ + { + "key": "assign", + "title": "Firma zuweisen", + "class": "fas fa-user-plus text-primary", + "condition": (row) => row.status === 'new', + }, + { + "key": "view_docs", + "title": "Dokumentation ansehen", + "class": "fas fa-folder-open text-info", + "condition": (row) => ['documented', 'completed'].includes(row.status), + }, + ] + } + } + }, + methods: { + getStatusColumn(status) { + const column = this.crudConfig.columns.find(c => c.key === 'status'); + return column.table.filterOptions.find(opt => opt.value === status) || {}; + }, + formatDate(timestamp) { + if (!timestamp) return '–'; + return window.moment.unix(timestamp).format('DD.MM.YYYY'); + } + } +}); + +Vue.component('traffic-light', { + props: ['deadline', 'status'], + computed: { + lightInfo() { + if (['completed', 'new'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' }; + const now = moment(); + const deadlineDate = moment.unix(this.deadline); + if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' }; + + if (deadlineDate.isBefore(now)) return { color: '#dc3545', title: 'Deadline überschritten' }; + const daysLeft = deadlineDate.diff(now, 'days'); + if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' }; + if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' }; + return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' }; + } + }, + template: `` +}); + +Vue.component('assign-company-modal', { + props: ['workorderId'], + template: ` + + + + `, + data() { return { companies: [], selectedCompanyId: null } }, + async mounted() { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getCompanies`); + this.companies = response.data; + }, + methods: { + async submit() { + if (!this.selectedCompanyId) return window.notify('error', 'Bitte eine Firma auswählen.'); + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/assignWorkorder`, { + workorderId: this.workorderId, + companyId: this.selectedCompanyId + }); + if(response.data.success) { + window.notify('success', response.data.message); + this.$emit('close'); + } else { + window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + } + } +}); + +Vue.component('documentation-viewer-modal', { + props: ['workorderId'], + template: ` + +
+
+
Hochgeladene Dokumente
+
+
+
Keine Dokumente vorhanden.
+ +
+
+ `, + data() { + return { loading: false, docs: [] } + }, + methods: { + async fetchDocs() { + this.loading = true; + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getDocumentation`, { params: { workorderId: this.workorderId }}); + this.docs = response.data; + this.loading = false; + }, + formatDate(timestamp) { + return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm'); + }, + getDocTypeText(type) { + const types = [ + { value: 'photo_before', text: 'Foto: Zustand vorher' }, + { value: 'photo_during', text: 'Foto: Während der Arbeit' }, + { value: 'photo_after', text: 'Foto: Zustand nachher' }, + { value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' }, + { value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' }, + ]; + return types.find(t => t.value === type)?.text || type; + } + }, + mounted() { + this.fetchDocs(); + } +}); \ No newline at end of file diff --git a/public/js/pages/RMLWorkorderCompany/RMLWorkorderCompany.js b/public/js/pages/RMLWorkorderCompany/RMLWorkorderCompany.js new file mode 100644 index 000000000..b2928cbf2 --- /dev/null +++ b/public/js/pages/RMLWorkorderCompany/RMLWorkorderCompany.js @@ -0,0 +1,283 @@ +// RMLWorkorderCompany.js + +Vue.component('r-m-l-workorder-company', { + template: ` + + + + + + + + + + + + + + + + + `, + data() { + return { + scheduleModalWorkorderId: null, + crudConfig: { + ...window.TT_CONFIG.CRUD_CONFIG, + expandable: true, + additionalActions: [ + { + "key": "schedule", + "title": "Termin festlegen", + "class": "fas fa-calendar-plus text-primary", + "condition": (row) => row.status === 'assigned', + } + ] + } + } + }, + methods: { + getStatusColumn(status) { + const column = this.crudConfig.columns.find(c => c.key === 'status'); + return column.table.filterOptions.find(opt => opt.value === status) || {}; + }, + formatDate(timestamp) { + if (!timestamp) return '–'; + return window.moment.unix(timestamp).format('DD.MM.YYYY'); + } + } +}); + +Vue.component('traffic-light', { + props: ['deadline', 'status'], + computed: { + lightInfo() { + if (['completed', 'new'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' }; + const now = moment(); + const deadlineDate = moment.unix(this.deadline); + if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' }; + + if (deadlineDate.isBefore(now)) return { color: '#dc3545', title: 'Deadline überschritten' }; + const daysLeft = deadlineDate.diff(now, 'days'); + if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' }; + if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' }; + return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' }; + } + }, + template: `` +}); + +Vue.component('schedule-appointment-modal', { + props: ['workorderId'], + template: ` + + + + `, + data() { return { appointmentDate: null } }, + methods: { + async submit() { + if (!this.appointmentDate) return window.notify('error', 'Bitte ein Datum auswählen.'); + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/scheduleAppointment`, { + workorderId: this.workorderId, + appointmentDate: this.appointmentDate + }); + if(response.data.success) { + window.notify('success', response.data.message); + this.$emit('close'); + } else { + window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + } + } +}); + +Vue.component('documentation-manager', { + props: ['workorderId'], + template: ` +
+
+
+
+
+
+
Benötigte Dokumente
+
    +
  • + + {{ docType.text }} +
  • +
+
+ + + Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen. + +
+ Auftrag bereits abgeschlossen. +
+
+
+
+ +
+
+
+
Neues Dokument hochladen
+ + +
+ +
+ +
+
+ +
+
+ + +
+
+
+ `, + data() { + return { + loadingWorkorder: true, + workorder: null, + uploading: false, + completing: false, + uploadedFiles: [], + uploadData: { + files: [], + documentType: 'photo_before', + description: '' + }, + requiredDocTypes: [ + { value: 'photo_before', text: 'Foto: Zustand vorher' }, + { value: 'photo_during', text: 'Foto: Während der Arbeit' }, + { value: 'photo_after', text: 'Foto: Zustand nachher' }, + { value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' }, + { value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' }, + ] + } + }, + computed: { + canComplete() { + return this.requiredDocTypes.every(docType => this.isUploaded(docType.value)); + } + }, + methods: { + async loadWorkorder() { + this.loadingWorkorder = true; + try { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getWorkorderById`, { params: { id: this.workorderId }}); + this.workorder = response.data; + } catch(e) { + window.notify('error', 'Arbeitsauftragsdetails konnten nicht geladen werden.'); + } + this.loadingWorkorder = false; + }, + isUploaded(docType) { + return this.uploadedFiles.some(file => file.documentType === docType); + }, + async fetchDocs() { + try { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getDocumentation`, { params: { workorderId: this.workorderId }}); + this.uploadedFiles = response.data; + } catch(e) { + window.notify('error', 'Dokumente konnten nicht geladen werden.'); + } + }, + handleFileUpload(event) { + this.uploadData.files = event.target.files; + }, + async uploadFiles() { + if(!this.uploadData.files || this.uploadData.files.length === 0) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.'); + this.uploading = true; + + const formData = new FormData(); + formData.append('workorderId', this.workorder.id); + formData.append('documentType', this.uploadData.documentType); + formData.append('description', this.uploadData.description); + for (const file of this.uploadData.files) { + formData.append('files[]', file); + } + + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/uploadDocumentation`, formData); + if(response.data.success) { + window.notify('success', response.data.message); + this.$refs.fileInput.value = ''; + this.uploadData.files = []; + this.uploadData.description = ''; + await this.fetchDocs(); + await this.loadWorkorder(); // Reload to get updated status + } else { + window.notify('error', response.data.error || 'Upload fehlgeschlagen.'); + } + } catch(e) { + window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.'); + } + this.uploading = false; + }, + async completeWorkorder() { + if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen? Diese Aktion kann nicht rückgängig gemacht werden.')) return; + this.completing = true; + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/completeWorkorder`, { workorderId: this.workorder.id }); + if(response.data.success) { + window.notify('success', response.data.message); + this.$emit('workorder-completed'); + } else { + window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch(e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + this.completing = false; + }, + getDocTypeText(type) { + const found = this.requiredDocTypes.find(t => t.value === type); + return found ? found.text : type; + } + }, + async mounted() { + await this.loadWorkorder(); + await this.fetchDocs(); + } +}); \ No newline at end of file diff --git a/public/plugins/vue/tt-components/css/tt-file-gallery.css b/public/plugins/vue/tt-components/css/tt-file-gallery.css new file mode 100644 index 000000000..d0632e44c --- /dev/null +++ b/public/plugins/vue/tt-components/css/tt-file-gallery.css @@ -0,0 +1,181 @@ +.tt-file-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; +} + +.tt-file-gallery-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + cursor: pointer; +} + +.tt-file-gallery-thumbnail { + width: 100%; + height: 100px; + object-fit: cover; + border-radius: 0.25rem; + border: 1px solid #dee2e6; + transition: transform 0.2s; +} + +.tt-file-gallery-item:hover .tt-file-gallery-thumbnail { + transform: scale(1.05); +} + +.tt-file-gallery-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 40px; /* Adjust to not cover filename */ + background: rgba(0, 0, 0, 0.4); + color: white; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + border-radius: 0.25rem; +} + +.tt-file-gallery-item:hover .tt-file-gallery-overlay { + opacity: 1; +} + +.tt-file-gallery-icon-container { + width: 100%; + height: 100px; + display: flex; + justify-content: center; + align-items: center; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + background-color: #f8f9fa; + text-decoration: none; + color: inherit; +} + +.tt-file-gallery-filename { + font-size: 0.8rem; + margin-top: 0.5rem; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 4px; +} + +/* --- Fullscreen Viewer Styles --- */ + +.tt-fullscreen-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 9999; + outline: none; +} + +.tt-fullscreen-toolbar { + position: absolute; + top: 0; + right: 0; + padding: 15px; + display: flex; + gap: 15px; + z-index: 10001; +} + +.tt-fullscreen-btn { + background: rgba(0, 0, 0, 0.3); + border: none; + color: white; + font-size: 1.5rem; + cursor: pointer; + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + transition: background-color 0.2s; +} + +.tt-fullscreen-btn:hover { + background: rgba(0, 0, 0, 0.6); +} + +.tt-fullscreen-content { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.tt-fullscreen-image-wrapper { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; /* Important for panning */ +} + +.tt-fullscreen-image { + max-width: 95vw; + max-height: 95vh; + object-fit: contain; + will-change: transform; /* Performance hint for browser */ +} + +.tt-fullscreen-pdf { + width: calc(100vw - 40px); + height: calc(100vh - 40px); + max-width: 1600px; /* Optional: max width for very large screens */ + border: none; + background-color: white; +} + +.tt-fullscreen-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.3); + border: none; + color: white; + font-size: 2rem; + cursor: pointer; + padding: 10px; + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + transition: background-color 0.2s; +} + +.tt-fullscreen-nav-btn:hover { + background: rgba(0, 0, 0, 0.6); +} + +.tt-fullscreen-nav-btn.left { + left: 15px; +} + +.tt-fullscreen-nav-btn.right { + right: 15px; +} \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-file-gallery.js b/public/plugins/vue/tt-components/tt-file-gallery.js new file mode 100644 index 000000000..0a4e677f6 --- /dev/null +++ b/public/plugins/vue/tt-components/tt-file-gallery.js @@ -0,0 +1,233 @@ +Vue.component('tt-file-gallery', { + props: { + files: { type: Array, default: () => [] } + }, + data() { + return { + fullscreenItem: null, // Holds the file being viewed + currentImageIndex: 0, + + // Zoom & Pan state + zoom: 1, + pan: { x: 0, y: 0 }, + isPanning: false, + panStart: { x: 0, y: 0 }, + lastPinchDist: 0, + } + }, + computed: { + imageFiles() { + return this.files.filter(this.isImage); + }, + isViewingImage() { + return this.fullscreenItem && this.isImage(this.fullscreenItem); + }, + imageTransformStyle() { + // Apply CSS transform for zoom and pan + const { x, y } = this.pan; + return { + transform: `translate(${x}px, ${y}px) scale(${this.zoom})`, + cursor: this.isPanning ? 'grabbing' : 'grab', + transition: this.isPanning ? 'none' : 'transform 0.2s', + }; + }, + fullscreenDownloadUrl() { + if (!this.fullscreenItem) return '#'; + return `/File/download?id=${this.fullscreenItem.id}`; + } + }, + methods: { + // File type checks + isImage(file) { + return file.mimetype && file.mimetype.startsWith('image/'); + }, + isPdf(file) { + return file.mimetype === 'application/pdf'; + }, + + // Get icon for non-image/pdf files + getFileIcon(file) { + const extension = file.fileName?.split('.').pop().toLowerCase(); + switch (extension) { + case 'doc': + case 'docx': return 'fas fa-file-word text-primary'; + case 'xls': + case 'xlsx': return 'fas fa-file-excel text-success'; + case 'zip': + case 'rar': return 'fas fa-file-archive text-warning'; + default: return 'fas fa-file text-secondary'; + } + }, + + // Viewer controls + openViewer(file) { + this.fullscreenItem = file; + if (this.isImage(file)) { + this.currentImageIndex = this.imageFiles.findIndex(img => img.id === file.id); + } + this.resetZoomAndPan(); + this.$nextTick(() => { this.$refs.viewer?.focus(); }); + }, + closeViewer() { + this.fullscreenItem = null; + }, + navigateImage(direction) { + const newIndex = this.currentImageIndex + direction; + if (newIndex >= 0 && newIndex < this.imageFiles.length) { + this.currentImageIndex = newIndex; + this.fullscreenItem = this.imageFiles[newIndex]; + this.resetZoomAndPan(); + } + }, + + // Event handlers for keyboard and clicks + handleKeyDown(event) { + if (!this.fullscreenItem) return; + switch (event.key) { + case 'Escape': this.closeViewer(); break; + case 'ArrowLeft': this.isViewingImage && this.navigateImage(-1); break; + case 'ArrowRight': this.isViewingImage && this.navigateImage(1); break; + } + }, + + // --- Zoom and Pan Methods --- + resetZoomAndPan() { + this.zoom = 1; + this.pan = { x: 0, y: 0 }; + this.isPanning = false; + }, + + // Mouse Wheel Zoom + handleWheel(e) { + if (!this.isViewingImage) return; + e.preventDefault(); + const scaleFactor = 0.2; + const newZoom = this.zoom - (e.deltaY > 0 ? scaleFactor : -scaleFactor); + this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom between 1x and 5x + }, + + // Mouse Drag to Pan + onPanStart(e) { + if (this.zoom <= 1) return; + e.preventDefault(); + this.isPanning = true; + this.panStart.x = e.clientX - this.pan.x; + this.panStart.y = e.clientY - this.pan.y; + }, + onPanMove(e) { + if (!this.isPanning) return; + this.pan.x = e.clientX - this.panStart.x; + this.pan.y = e.clientY - this.panStart.y; + }, + onPanEnd() { + this.isPanning = false; + }, + + // Touch Events for Mobile (Pinch-to-Zoom & Pan) + onTouchStart(e) { + if (this.zoom <= 1 && e.touches.length === 1) return; + e.preventDefault(); + if (e.touches.length === 1) { // Pan + this.isPanning = true; + this.panStart.x = e.touches[0].clientX - this.pan.x; + this.panStart.y = e.touches[0].clientY - this.pan.y; + } else if (e.touches.length === 2) { // Zoom + this.lastPinchDist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + } + }, + onTouchMove(e) { + if (!this.isPanning && e.touches.length !== 2) return; + e.preventDefault(); + if (e.touches.length === 1 && this.isPanning) { // Pan + this.pan.x = e.touches[0].clientX - this.panStart.x; + this.pan.y = e.touches[0].clientY - this.panStart.y; + } else if (e.touches.length === 2) { // Zoom + const pinchDist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + const scaleFactor = 0.01; + const newZoom = this.zoom + (pinchDist - this.lastPinchDist) * scaleFactor; + this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom + this.lastPinchDist = pinchDist; + } + }, + onTouchEnd(e) { + this.isPanning = false; + if (e.touches.length < 2) { + this.lastPinchDist = 0; + } + } + }, + watch: { + fullscreenItem(newItem) { + // Prevent body scroll when viewer is open + document.body.style.overflow = newItem ? 'hidden' : ''; + } + }, + template: ` +
+
Hochgeladene Dokumente
+
Keine Dokumente vorhanden.
+
+ +
+ +
+
+ + + + +
+ +
+ + +
+ + +
+
+ ` +}); \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-table-crud.js b/public/plugins/vue/tt-components/tt-table-crud.js index 8fca7e71b..07e04a9d0 100644 --- a/public/plugins/vue/tt-components/tt-table-crud.js +++ b/public/plugins/vue/tt-components/tt-table-crud.js @@ -181,7 +181,7 @@ Vue.component('tt-table-crud', { key: this.crudConfig.key, tableHeader: this.crudConfig.tableHeader, headers: this.crudConfig.columns.filter(column => column.table !== false).map(column => { - return {text: column.text, key: column.key, ...column.table, filterOptions: column?.modal?.items, priority: column.priority} + return {text: column.text, key: column.key, ...column.table, filterOptions: column?.table?.filterOptions ?? column?.modal?.items ?? [], priority: column.priority} }) } }, modalConfig() { diff --git a/public/plugins/vue/tt-components/tt-table.js b/public/plugins/vue/tt-components/tt-table.js index ecfede3fe..40ee7d451 100644 --- a/public/plugins/vue/tt-components/tt-table.js +++ b/public/plugins/vue/tt-components/tt-table.js @@ -201,9 +201,9 @@ Vue.component('tt-table', { .isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]) .format('DD.MM.YYYY HH:mm')) : '' }} - + + + {{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }} @@ -236,9 +236,9 @@ Vue.component('tt-table', { }} {{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }} {{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }} - + + + {{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}