new version of rmlworkorder
This commit is contained in:
@@ -23,6 +23,7 @@ $additionalCSS = [
|
|||||||
'plugins/vue/tt-components/css/tt-table.css',
|
'plugins/vue/tt-components/css/tt-table.css',
|
||||||
'plugins/vue/tt-components/css/tt-tooltip.css',
|
'plugins/vue/tt-components/css/tt-tooltip.css',
|
||||||
'plugins/vue/tt-components/css/tt-loader.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',
|
'plugins/vue/tt-components/css/tt-position-manager.css',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,13 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(".selectpicker").selectpicker({
|
if ($(".selectpicker").length) {
|
||||||
iconBase: "fas",
|
$(".selectpicker").selectpicker({
|
||||||
tickIcon: "check",
|
iconBase: "fas",
|
||||||
sanitize: false
|
tickIcon: "check",
|
||||||
});
|
sanitize: false
|
||||||
|
});
|
||||||
|
}
|
||||||
$('.navbar-toggle').on('click', function (event) {
|
$('.navbar-toggle').on('click', function (event) {
|
||||||
console.log('cracy');
|
console.log('cracy');
|
||||||
$(this).toggleClass('open');
|
$(this).toggleClass('open');
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/jquery.min.js"></script>
|
<script type="text/javascript" src="<?=self::getResourcePath()?>js/jquery.min.js"></script>
|
||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js" defer></script>
|
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js" defer></script>
|
||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js" defer></script>
|
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js" defer></script>
|
||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>assets/js/bootstrap-select.min.js" defer></script>
|
|
||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js" defer></script>
|
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js" defer></script>
|
||||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js" defer></script>
|
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js" defer></script>
|
||||||
|
|
||||||
|
|||||||
@@ -92,27 +92,24 @@ class FileController extends mfBaseController {
|
|||||||
$id = $this->request->id;
|
$id = $this->request->id;
|
||||||
$size = $this->request->size;
|
$size = $this->request->size;
|
||||||
|
|
||||||
if (!is_numeric($id) || $id < 1) {
|
if (!is_numeric($id) || $id < 1) self::sendError("Invalid File ID");
|
||||||
http_response_code(400);
|
|
||||||
self::returnJson(["error" => "Invalid File ID"]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = new File($id);
|
$file = new File($id);
|
||||||
if (!$file->id) {
|
if (!$file->id) self::sendError("File record not found");
|
||||||
http_response_code(404);
|
|
||||||
self::returnJson(["error" => "File record not found"]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$originalPath = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}";
|
$originalPath = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}";
|
||||||
if (!is_readable($originalPath)) {
|
if (!is_readable($originalPath)) self::sendError("Physical file not found");
|
||||||
http_response_code(404);
|
|
||||||
self::returnJson(["error" => "Physical file not found"]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$imageInfo = @getimagesize($originalPath);
|
$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) {
|
if ($imageInfo === false) {
|
||||||
$this->downloadAction();
|
$this->downloadAction();
|
||||||
return;
|
return;
|
||||||
@@ -129,18 +126,13 @@ class FileController extends mfBaseController {
|
|||||||
|
|
||||||
$cacheDir = TEMP_DIR . "/thumbnails";
|
$cacheDir = TEMP_DIR . "/thumbnails";
|
||||||
@mkdir($cacheDir, 0775, true);
|
@mkdir($cacheDir, 0775, true);
|
||||||
|
|
||||||
$cachedPath = "{$cacheDir}/{$id}_{$size}." . pathinfo($originalPath, PATHINFO_EXTENSION);
|
$cachedPath = "{$cacheDir}/{$id}_{$size}." . pathinfo($originalPath, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
if (!file_exists($cachedPath)) {
|
if (!file_exists($cachedPath)) {
|
||||||
$command = "convert " . escapeshellarg($originalPath) . " -resize " . escapeshellarg($sizeDimensions[$size]) . " " . escapeshellarg($cachedPath);
|
$command = "convert " . escapeshellarg($originalPath) . " -resize " . escapeshellarg($sizeDimensions[$size]) . " " . escapeshellarg($cachedPath);
|
||||||
exec($command, $output, $return_var);
|
exec($command, $output, $return_var);
|
||||||
|
|
||||||
if ($return_var !== 0) {
|
if ($return_var !== 0) self::sendError("Failed to create thumbnail.");
|
||||||
http_response_code(500);
|
|
||||||
self::returnJson(["error" => "Failed to create thumbnail."]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: ' . $imageInfo['mime']);
|
header('Content-Type: ' . $imageInfo['mime']);
|
||||||
@@ -148,5 +140,4 @@ class FileController extends mfBaseController {
|
|||||||
readfile($cachedPath);
|
readfile($cachedPath);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
<?php
|
|
||||||
// RMLWorkorderController.php
|
|
||||||
|
|
||||||
class RMLWorkorderController extends TTCrud
|
|
||||||
{
|
|
||||||
protected string $headerTitle = 'RML Arbeitsaufträge';
|
|
||||||
protected bool $createText = false; // Workorders are created automatically
|
|
||||||
|
|
||||||
protected array $columns = [
|
|
||||||
['key' => '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}<br />{$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 .= "<br />{$preorder->adb_wohneinheit}";
|
|
||||||
}
|
|
||||||
$anschlussadresse .= "<br />{$preorder->adb_hausnummer->plz->plz} {$preorder->adb_hausnummer->ortschaft->name}";
|
|
||||||
}
|
|
||||||
|
|
||||||
$kunde = ($preorder->company) ? $preorder->company : "{$preorder->firstname} {$preorder->lastname}";
|
|
||||||
$kunde .= "<br />{$preorder->street}";
|
|
||||||
if ($preorder->housenumber) {
|
|
||||||
$kunde .= " {$preorder->housenumber}";
|
|
||||||
}
|
|
||||||
$kunde .= "<br />{$preorder->zip} {$preorder->city}";
|
|
||||||
|
|
||||||
$kontakt = ($preorder->phone) ? "{$preorder->phone}<br />" : '';
|
|
||||||
$kontakt .= ($preorder->email) ? $preorder->email : '';
|
|
||||||
|
|
||||||
$row['preorderInfo'] = "Anschlussadresse: {$anschlussadresse}<br />" .
|
|
||||||
"Kunde: {$kunde}<br />" .
|
|
||||||
"Kontakt: {$kontakt}<br />" .
|
|
||||||
"OAID: <span class='text-pink'>{$preorder->oaid}</span>";
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
// RMLWorkorderAdminController.php
|
||||||
|
|
||||||
|
class RMLWorkorderAdminController extends TTCrud
|
||||||
|
{
|
||||||
|
protected string $headerTitle = 'RML Arbeitsaufträge (Admin)';
|
||||||
|
protected bool $createText = false;
|
||||||
|
protected array $permissionCheck = ['RMLAdmin'];
|
||||||
|
|
||||||
|
protected array $columns = [
|
||||||
|
['key' => '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'] = "<strong>Kunde:</strong> {$kunde}<br>" .
|
||||||
|
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
|
||||||
|
"<strong>OAID:</strong> <span class='text-pink'>{$preorder->oaid}</span>";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
// RMLWorkorderCompanyController.php
|
||||||
|
|
||||||
|
class RMLWorkorderCompanyController extends TTCrud
|
||||||
|
{
|
||||||
|
protected string $headerTitle = 'Meine Arbeitsaufträge';
|
||||||
|
protected bool $createText = false;
|
||||||
|
|
||||||
|
protected array $columns = [
|
||||||
|
['key' => '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 "<strong>Kunde:</strong> {$kunde}<br>" .
|
||||||
|
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
|
||||||
|
"<strong>Kontakt:</strong> {$preorder->phone} / {$preorder->email}<br>" .
|
||||||
|
"<strong>OAID:</strong> <span class='text-pink'>{$preorder->oaid}</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class CreateRmlWorkorderTables extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -393,7 +393,7 @@ class mfBaseController
|
|||||||
|
|
||||||
public static function sendError(string $message): void {
|
public static function sendError(string $message): void {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
self::returnJson(['success' => false, 'message' => $message]);
|
self::returnJson(['success' => false, 'message' => $message, 'error' => $message]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ $jsFiles = [
|
|||||||
"plugins/vue/tt-components/tt-position-manager.js",
|
"plugins/vue/tt-components/tt-position-manager.js",
|
||||||
"plugins/vue/tt-components/tt-tooltip.js",
|
"plugins/vue/tt-components/tt-tooltip.js",
|
||||||
"plugins/vue/tt-components/tt-map.js",
|
"plugins/vue/tt-components/tt-map.js",
|
||||||
|
"plugins/vue/tt-components/tt-file-gallery.js",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
// phpinfo(); exit;
|
// phpinfo(); exit;
|
||||||
define('mfUI',"web");
|
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")) {
|
if(file_exists("../config/config.php")) {
|
||||||
require("../config/config.php");
|
require("../config/config.php");
|
||||||
|
|||||||
@@ -1,423 +0,0 @@
|
|||||||
// RMLWorkorder.js
|
|
||||||
|
|
||||||
// =================================================================================
|
|
||||||
// Main Component - Switches between Admin and Company View
|
|
||||||
// =================================================================================
|
|
||||||
Vue.component('r-m-l-workorder', {
|
|
||||||
template: `
|
|
||||||
<div>
|
|
||||||
<rml-workorder-admin-view v-if="window.TT_CONFIG.RML_ADMIN === '1'"></rml-workorder-admin-view>
|
|
||||||
<rml-workorder-company-view v-else></rml-workorder-company-view>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
data() { return { window: window } }
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// =================================================================================
|
|
||||||
// RML Admin View
|
|
||||||
// =================================================================================
|
|
||||||
Vue.component('rml-workorder-admin-view', {
|
|
||||||
template: `
|
|
||||||
<tt-card>
|
|
||||||
<assign-company-modal
|
|
||||||
v-if="assignModalWorkorderId"
|
|
||||||
:workorder-id="assignModalWorkorderId"
|
|
||||||
@close="assignModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<documentation-viewer-modal
|
|
||||||
v-if="docsModalWorkorderId"
|
|
||||||
:workorder-id="docsModalWorkorderId"
|
|
||||||
@close="docsModalWorkorderId = null"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<tt-table-crud
|
|
||||||
ref="table"
|
|
||||||
@assign="assignModalWorkorderId = $event.id"
|
|
||||||
@view_docs="docsModalWorkorderId = $event.id"
|
|
||||||
:crud-config="crudConfig"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- <template v-slot:preorderinfo="{ row }">-->
|
|
||||||
<!-- <div v-html="row.preorderInfo"></div>-->
|
|
||||||
<!-- </template>-->
|
|
||||||
|
|
||||||
<!-- <template v-slot:status="{ row }">-->
|
|
||||||
<!-- <traffic-light :deadline="row.deadlineDate" :status="row.status" />-->
|
|
||||||
<!-- <i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>-->
|
|
||||||
<!-- <span class="ml-2">{{ getStatusColumn(row.status).text }}</span>-->
|
|
||||||
<!-- </template>-->
|
|
||||||
|
|
||||||
<!-- <template v-slot:deadlinedate="{ row }">-->
|
|
||||||
<!-- {{ formatDate(row.deadlineDate) }}-->
|
|
||||||
<!-- </template>-->
|
|
||||||
<!-- -->
|
|
||||||
<!-- <template v-slot:appointmentdate="{ row }">-->
|
|
||||||
<!-- {{ formatDate(row.appointmentDate) }}-->
|
|
||||||
<!-- </template>-->
|
|
||||||
|
|
||||||
</tt-table-crud>
|
|
||||||
</tt-card>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<tt-card>
|
|
||||||
<schedule-appointment-modal
|
|
||||||
v-if="scheduleModalWorkorderId"
|
|
||||||
:workorder-id="scheduleModalWorkorderId"
|
|
||||||
@close="scheduleModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
|
||||||
/>
|
|
||||||
<documentation-modal
|
|
||||||
v-if="documentModalWorkorder"
|
|
||||||
:workorder="documentModalWorkorder"
|
|
||||||
@close="documentModalWorkorder = null; $refs.table.$refs.table.refreshTable()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<tt-table-crud
|
|
||||||
ref="table"
|
|
||||||
@schedule="scheduleModalWorkorderId = $event.id"
|
|
||||||
@document="documentModalWorkorder = $event"
|
|
||||||
:crud-config="crudConfig"
|
|
||||||
>
|
|
||||||
<template v-slot:preorderinfo="{ row }">
|
|
||||||
<div v-html="row.preorderInfo"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:status="{ row }">
|
|
||||||
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
|
||||||
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
|
||||||
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:deadlinedate="{ row }">
|
|
||||||
{{ formatDate(row.deadlineDate) }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:appointmentdate="{ row }">
|
|
||||||
{{ formatDate(row.appointmentDate) }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</tt-table-crud>
|
|
||||||
</tt-card>
|
|
||||||
`,
|
|
||||||
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: `<span :style="{ color: lightColor, fontSize: '1.2em' }" class="mr-2" title="Dringlichkeit">●</span>`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal for RML Admin to assign a company
|
|
||||||
Vue.component('assign-company-modal', {
|
|
||||||
props: ['workorderId'],
|
|
||||||
template: `
|
|
||||||
<tt-modal :show="true" title="Firma zuweisen" @submit="submit" @update:show="$emit('close')">
|
|
||||||
<tt-select label="Firma" :options="companies" v-model="selectedCompanyId" sm row required />
|
|
||||||
</tt-modal>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<tt-modal :show="true" title="Termin festlegen" @submit="submit" @update:show="$emit('close')">
|
|
||||||
<tt-date-picker label="Termindatum" :date-range="false" v-model="appointmentDate" sm row required />
|
|
||||||
</tt-modal>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<tt-modal :show="true" :title="'Dokumentation für Auftrag #' + workorder.id" :save="false" :delete="false" @update:show="$emit('close')">
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>Benötigte Dokumente</h5>
|
|
||||||
<ul>
|
|
||||||
<li v-for="docType in requiredDocTypes" :key="docType.value">
|
|
||||||
{{ docType.text }}
|
|
||||||
<i v-if="isUploaded(docType.value)" class="fas fa-check-circle text-success"></i>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Neues Dokument hochladen</h5>
|
|
||||||
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
|
|
||||||
<tt-input label="Beschreibung (optional)" v-model="uploadData.description" sm row />
|
|
||||||
<div class="form-group row">
|
|
||||||
<label class="col-form-label col-sm-4 col-form-label-sm">Datei</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input type="file" class="form-control-file" @change="handleFileUpload" ref="fileInput" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<tt-button text="Hochladen" @click="uploadFile" :loading="uploading" additional-class="btn-primary float-right" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<documentation-viewer-modal :workorder-id="workorder.id" :key="viewerKey" />
|
|
||||||
|
|
||||||
<template v-slot:footer>
|
|
||||||
<tt-button text="Auftrag abschließen" @click="completeWorkorder" :disabled="!canComplete" additional-class="btn-success" />
|
|
||||||
<button class="btn btn-secondary" @click="$emit('close')">Schließen</button>
|
|
||||||
</template>
|
|
||||||
</tt-modal>
|
|
||||||
`,
|
|
||||||
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: `
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>Hochgeladene Dokumente</h5>
|
|
||||||
</div>
|
|
||||||
<div v-if="loading" class="card-body text-center">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!docs.length" class="card-body text-center text-muted">
|
|
||||||
Keine Dokumente vorhanden.
|
|
||||||
</div>
|
|
||||||
<ul v-else class="list-group list-group-flush">
|
|
||||||
<li v-for="doc in docs" :key="doc.id" class="list-group-item">
|
|
||||||
<a :href="'/File/download?id=' + doc.fileId" target="_blank">
|
|
||||||
<i class="fas fa-file-download"></i> {{ doc.fileName }}
|
|
||||||
</a>
|
|
||||||
<div class="text-muted small">
|
|
||||||
<strong>Typ:</strong> {{ getDocTypeText(doc.documentType) }} <br/>
|
|
||||||
<strong>Beschreibung:</strong> {{ doc.description || '-' }} <br/>
|
|
||||||
<strong>Hochgeladen von:</strong> {{ doc.createBy }} am {{ formatDate(doc.create) }}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// RMLWorkorderAdmin.js
|
||||||
|
Vue.component('r-m-l-workorder-admin', {
|
||||||
|
template: `
|
||||||
|
<tt-card>
|
||||||
|
<assign-company-modal
|
||||||
|
v-if="assignModalWorkorderId"
|
||||||
|
:workorder-id="assignModalWorkorderId"
|
||||||
|
@close="assignModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<documentation-viewer-modal
|
||||||
|
v-if="docsModalWorkorderId"
|
||||||
|
:workorder-id="docsModalWorkorderId"
|
||||||
|
@close="docsModalWorkorderId = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tt-table-crud
|
||||||
|
ref="table"
|
||||||
|
@assign="assignModalWorkorderId = $event.id"
|
||||||
|
@view_docs="docsModalWorkorderId = $event.id"
|
||||||
|
:crud-config="crudConfig"
|
||||||
|
>
|
||||||
|
<template v-slot:preorderinfo="{ row }">
|
||||||
|
<div v-html="row.preorderInfo" class="small"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:status="{ row }">
|
||||||
|
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
||||||
|
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
||||||
|
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:deadlinedate="{ row }">
|
||||||
|
{{ formatDate(row.deadlineDate) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:appointmentdate="{ row }">
|
||||||
|
{{ formatDate(row.appointmentDate) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</tt-table-crud>
|
||||||
|
</tt-card>
|
||||||
|
`,
|
||||||
|
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: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">●</span>`
|
||||||
|
});
|
||||||
|
|
||||||
|
Vue.component('assign-company-modal', {
|
||||||
|
props: ['workorderId'],
|
||||||
|
template: `
|
||||||
|
<tt-modal :show="true" title="Firma zuweisen" @submit="submit" @update:show="$emit('close')">
|
||||||
|
<tt-select label="Firma" :options="companies" v-model="selectedCompanyId" sm row required />
|
||||||
|
</tt-modal>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<tt-modal :show="true" :title="'Dokumentation für Auftrag #' + workorderId" :save="false" :delete="false" @update:show="$emit('close')">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Hochgeladene Dokumente</h5>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="card-body text-center"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||||
|
<div v-else-if="!docs.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
|
||||||
|
<ul v-else class="list-group list-group-flush">
|
||||||
|
<li v-for="doc in docs" :key="doc.id" class="list-group-item">
|
||||||
|
<a :href="'/File/download?id=' + doc.fileId" target="_blank">
|
||||||
|
<i class="fas fa-file-download mr-2"></i> {{ doc.fileName }}
|
||||||
|
</a>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
<strong>Typ:</strong> {{ getDocTypeText(doc.documentType) }} <br/>
|
||||||
|
<strong>Beschreibung:</strong> {{ doc.description || '-' }} <br/>
|
||||||
|
<strong>Hochgeladen von:</strong> {{ doc.userName }} am {{ formatDate(doc.create) }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</tt-modal>
|
||||||
|
`,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// RMLWorkorderCompany.js
|
||||||
|
|
||||||
|
Vue.component('r-m-l-workorder-company', {
|
||||||
|
template: `
|
||||||
|
<tt-card>
|
||||||
|
<schedule-appointment-modal
|
||||||
|
v-if="scheduleModalWorkorderId"
|
||||||
|
:workorder-id="scheduleModalWorkorderId"
|
||||||
|
@close="scheduleModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<tt-table-crud
|
||||||
|
ref="table"
|
||||||
|
@schedule="scheduleModalWorkorderId = $event.id"
|
||||||
|
:crud-config="crudConfig"
|
||||||
|
>
|
||||||
|
<template v-slot:preorderinfo="{ row }">
|
||||||
|
<div v-html="row.preorderInfo" class="small"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:status="{ row }">
|
||||||
|
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
||||||
|
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
||||||
|
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:deadlinedate="{ row }">
|
||||||
|
{{ formatDate(row.deadlineDate) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:appointmentdate="{ row }">
|
||||||
|
{{ formatDate(row.appointmentDate) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:expandedRow="{ row }">
|
||||||
|
<documentation-manager
|
||||||
|
:workorder-id="row.id"
|
||||||
|
@workorder-completed="$refs.table.$refs.table.refreshTable()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</tt-table-crud>
|
||||||
|
</tt-card>
|
||||||
|
`,
|
||||||
|
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: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">●</span>`
|
||||||
|
});
|
||||||
|
|
||||||
|
Vue.component('schedule-appointment-modal', {
|
||||||
|
props: ['workorderId'],
|
||||||
|
template: `
|
||||||
|
<tt-modal :show="true" title="Termin festlegen" @submit="submit" @update:show="$emit('close')">
|
||||||
|
<tt-date-picker label="Termindatum" :date-range="false" v-model="appointmentDate" sm row required />
|
||||||
|
</tt-modal>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<div class="p-3 bg-light" style="width: 100%;">
|
||||||
|
<div v-if="loadingWorkorder" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||||
|
<div v-else class="row">
|
||||||
|
<div class="col-lg-4 mb-3 mb-lg-0">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Benötigte Dokumente</h5>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li v-for="docType in requiredDocTypes" :key="docType.value" class="mb-2 d-flex align-items-center">
|
||||||
|
<i :class="isUploaded(docType.value) ? 'fas fa-check-circle text-success' : 'far fa-circle text-muted'" class="fa-fw mr-2"></i>
|
||||||
|
<span>{{ docType.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<tt-button
|
||||||
|
text="Auftrag abschließen"
|
||||||
|
@click="completeWorkorder"
|
||||||
|
:disabled="!canComplete || workorder.status === 'completed'"
|
||||||
|
:loading="completing"
|
||||||
|
additional-class="btn-success w-100"
|
||||||
|
icon="fas fa-check-double"
|
||||||
|
/>
|
||||||
|
<small v-if="!canComplete" class="form-text text-muted text-center mt-2">
|
||||||
|
Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen.
|
||||||
|
</small>
|
||||||
|
<div v-if="workorder.status === 'completed'" class="alert alert-secondary text-center mt-2 p-2">
|
||||||
|
Auftrag bereits abgeschlossen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-3" v-if="workorder.status !== 'completed'">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Neues Dokument hochladen</h5>
|
||||||
|
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
|
||||||
|
<tt-input label="Beschreibung" v-model="uploadData.description" sm row placeholder="Optional"/>
|
||||||
|
<div class="form-group row">
|
||||||
|
<label class="col-form-label col-sm-4 col-form-label-sm">Dateien</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="file" class="form-control-file form-control-sm" @change="handleFileUpload" ref="fileInput" multiple />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading" additional-class="btn-primary float-right" icon="fas fa-upload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<tt-file-gallery :files="uploadedFiles" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h5>Hochgeladene Dokumente</h5></div>
|
||||||
|
<div v-if="!files.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
|
||||||
|
<div v-else class="card-body">
|
||||||
|
<div class="tt-file-gallery-grid">
|
||||||
|
<div v-for="file in files" :key="file.id" class="tt-file-gallery-item" @click="openViewer(file)">
|
||||||
|
<template v-if="isImage(file)">
|
||||||
|
<img :src="'/File/show?id=' + file.id + '&size=small'" class="tt-file-gallery-thumbnail" :alt="file.fileName">
|
||||||
|
<div class="tt-file-gallery-overlay"><i class="fas fa-search-plus"></i></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isPdf(file)">
|
||||||
|
<div class="tt-file-gallery-icon-container"><i class="fas fa-file-pdf fa-3x text-danger"></i></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a :href="'/File/download?id=' + file.id" target="_blank" @click.stop class="tt-file-gallery-icon-container">
|
||||||
|
<i :class="getFileIcon(file)" class="fa-3x"></i>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<div class="tt-file-gallery-filename" :title="file.fileName">{{ file.fileName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fullscreenItem" class="tt-fullscreen-overlay" @click.self="closeViewer" @keydown="handleKeyDown" tabindex="-1" ref="viewer">
|
||||||
|
<div class="tt-fullscreen-toolbar">
|
||||||
|
<a v-if="isViewingImage" :href="fullscreenDownloadUrl" download class="tt-fullscreen-btn" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<button class="tt-fullscreen-btn" @click="closeViewer" title="Close"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tt-fullscreen-content" @click.self="closeViewer">
|
||||||
|
<template v-if="isViewingImage">
|
||||||
|
<div class="tt-fullscreen-image-wrapper"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
@mousedown="onPanStart"
|
||||||
|
@mousemove="onPanMove"
|
||||||
|
@mouseup="onPanEnd"
|
||||||
|
@mouseleave="onPanEnd"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd">
|
||||||
|
<img :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-image" :style="imageTransformStyle" @click.stop />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isPdf(fullscreenItem)">
|
||||||
|
<iframe :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-pdf" @click.stop></iframe>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isViewingImage">
|
||||||
|
<button class="tt-fullscreen-nav-btn left" @click.stop="navigateImage(-1)" v-if="currentImageIndex > 0">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<button class="tt-fullscreen-nav-btn right" @click.stop="navigateImage(1)" v-if="currentImageIndex < imageFiles.length - 1">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
@@ -181,7 +181,7 @@ Vue.component('tt-table-crud', {
|
|||||||
key: this.crudConfig.key,
|
key: this.crudConfig.key,
|
||||||
tableHeader: this.crudConfig.tableHeader,
|
tableHeader: this.crudConfig.tableHeader,
|
||||||
headers: this.crudConfig.columns.filter(column => column.table !== false).map(column => {
|
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() {
|
}, modalConfig() {
|
||||||
|
|||||||
@@ -201,9 +201,9 @@ Vue.component('tt-table', {
|
|||||||
.isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key])
|
.isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key])
|
||||||
.format('DD.MM.YYYY HH:mm')) : ''
|
.format('DD.MM.YYYY HH:mm')) : ''
|
||||||
}}</span>
|
}}</span>
|
||||||
<i v-else-if="column.filter === 'iconSelect'"
|
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
|
||||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
|
||||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
|
||||||
<span v-else-if="column.filter === 'autocomplete'">
|
<span v-else-if="column.filter === 'autocomplete'">
|
||||||
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}
|
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}
|
||||||
|
|
||||||
@@ -236,9 +236,9 @@ Vue.component('tt-table', {
|
|||||||
}}</span>
|
}}</span>
|
||||||
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }}</span>
|
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }}</span>
|
||||||
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
|
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
|
||||||
<i v-else-if="column.filter === 'iconSelect'"
|
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
|
||||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
|
||||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
|
||||||
<span v-else-if="column.filter === 'autocomplete'">
|
<span v-else-if="column.filter === 'autocomplete'">
|
||||||
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}</span>
|
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}</span>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user