added new module rml-workorder

This commit is contained in:
Luca Haid
2025-06-29 20:32:28 +02:00
parent 8d4e12e441
commit c629c9d38b
11 changed files with 983 additions and 0 deletions

View File

@@ -491,6 +491,15 @@ $siteTitle = "Benutzer";
<label for="can_AssetAdmin" class="form-check-label">Asset-Admin</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[RMLAdmin]"
id="can_RMLAdmin"
value="1" <?=($user && $user->can("RMLAdmin")) ? "checked='checked'" : ""?> />
<label for="can_RMLAdmin" class="form-check-label">RML-Workorder-Admin</label>
</div>
</div>
</div>
<hr/>

View File

@@ -0,0 +1,267 @@
<?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);
}
}

View File

@@ -0,0 +1,59 @@
<?php
// RMLWorkorderModel.php
class RMLWorkorderModel extends TTCrudBaseModel {
public int $id;
public int $preorderId;
public ?int $companyId;
public string $status;
public ?int $assignmentDate;
public ?int $deadlineDate;
public ?int $appointmentDate;
public int $create;
public int $createBy;
/**
* Finds work orders that are nearing their deadline or are overdue.
* This can be used for the traffic light system.
*
* @param string $urgency 'red', 'yellow', or 'green'
* @return array
*/
public static function getWorkordersByUrgency(string $urgency, ?int $companyId = null): array {
$db = self::getDB();
$table = self::getFullyQualifiedTable();
$now = time();
$whereClause = "WHERE status IN ('assigned', 'scheduled')";
if ($companyId) {
$whereClause .= " AND companyId = " . intval($companyId);
}
switch ($urgency) {
case 'red': // Less than 1 week left or overdue
$redDate = strtotime('+1 week');
$whereClause .= " AND deadlineDate < $redDate";
break;
case 'yellow': // Between 1 and 3 weeks left
$yellowDateStart = strtotime('+1 week');
$yellowDateEnd = strtotime('+3 weeks');
$whereClause .= " AND deadlineDate BETWEEN $yellowDateStart AND $yellowDateEnd";
break;
case 'green': // More than 3 weeks left
$greenDate = strtotime('+3 weeks');
$whereClause .= " AND deadlineDate > $greenDate";
break;
default:
return [];
}
$sql = "SELECT * FROM $table $whereClause ORDER BY deadlineDate ASC";
$result = $db->query($sql);
$orders = [];
while ($row = $result->fetch_assoc()) {
$orders[] = new self($row);
}
return $orders;
}
}

View File

@@ -0,0 +1,10 @@
<?php
// RMLWorkorderCompanyModel.php
class RMLWorkorderCompanyModel extends TTCrudBaseModel {
public int $id;
public int $addressId;
public string $name;
public int $create;
public int $createBy;
}

View File

@@ -0,0 +1,12 @@
<?php
// RMLWorkorderDocumentationModel.php
class RMLWorkorderDocumentationModel extends TTCrudBaseModel {
public int $id;
public int $workorderId;
public int $fileId;
public ?string $description;
public string $documentType;
public int $create;
public int $createBy;
}

View File

@@ -264,6 +264,7 @@ class UserController extends mfBaseController
$user->permissions->canWarehouseUser = "false";
$user->permissions->canADBExtended = "false";
$user->permissions->canAssetAdmin = "false";
$user->permissions->canRMLAdmin = "false";
if($r->get("can") && is_array($r->can)) {
foreach($r->can as $key => $can) {

View File

@@ -0,0 +1,66 @@
<?php /** @noinspection ALL */
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RmlWorkorderCreate extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
$table = $this->table("WorkerPermission");
$table->addColumn("canRMLAdmin", "enum", ["null" => false, "values" => ['false', 'true'], "default" => "false", "after" => "canSuperexpert"]);
$table->update();
}
if ($this->getEnvironment() == "addressdb") {
// Create RMLWorkorderCompany table
$rmlWorkorderCompany = $this->table('RMLWorkorderCompany', ['id' => 'id', 'primary_key' => 'id']);
$rmlWorkorderCompany->addColumn('addressId', 'integer', ['null' => false, 'comment' => 'FK to the Address table, identifying the company.'])
->addColumn('name', 'string', ['limit' => 255, 'null' => false, 'comment' => 'Cached company name for easy display.'])
->addColumn('create', 'integer', ['null' => false])
->addColumn('createBy', 'integer', ['null' => false])
->addIndex(['addressId'], ['unique' => true, 'name' => 'addressId_unique'])
->create();
// Create RMLWorkorder table
$rmlWorkorder = $this->table('RMLWorkorder', ['id' => 'id', 'primary_key' => 'id']);
$rmlWorkorder->addColumn('preorderId', 'integer', ['null' => false, 'comment' => 'FK to the Preorder that triggered this work order.'])
->addColumn('companyId', 'integer', ['null' => true, 'comment' => 'FK to RMLWorkorderCompany, assigned by an RML admin.'])
->addColumn('status', 'string', ['limit' => 50, 'null' => false, 'default' => 'new', 'comment' => 'Workflow status: new, assigned, scheduled, documented, completed.'])
->addColumn('assignmentDate', 'integer', ['null' => true, 'comment' => 'Timestamp when the order was assigned.'])
->addColumn('deadlineDate', 'integer', ['null' => true, 'comment' => 'Timestamp for the 6-week completion deadline.'])
->addColumn('appointmentDate', 'integer', ['null' => true, 'comment' => 'The date scheduled by the company for the work.'])
->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();
// Create RMLWorkorderDocumentation table
$rmlWorkorderDocumentation = $this->table('RMLWorkorderDocumentation', ['id' => 'id', 'primary_key' => 'id']);
$rmlWorkorderDocumentation->addColumn('workorderId', 'integer', ['null' => false, 'comment' => 'FK to the RMLWorkorder.'])
->addColumn('fileId', 'integer', ['null' => false, 'comment' => 'FK to the main File table after upload.'])
->addColumn('description', 'text', ['null' => true, 'comment' => 'User-provided description for the file.'])
->addColumn('documentType', 'string', ['limit' => 100, 'null' => false, 'comment' => 'Categorizes the upload, e.g., photo_before, measurement_protocol.'])
->addColumn('create', 'integer', ['null' => false])
->addColumn('createBy', 'integer', ['null' => false])
->addIndex(['workorderId'], ['name' => 'workorderId_idx'])
->create();
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$table = $this->table("WorkerPermission");
$table->removeColumn("canRMLAdmin");
$table->save();
}
if ($this->getEnvironment() == "addressdb") {
// Drop tables in reverse order of creation (or dependency)
$this->table('RMLWorkorderDocumentation')->drop();
$this->table('RMLWorkorder')->drop();
$this->table('RMLWorkorderCompany')->drop();
}
}
}

View File

@@ -203,4 +203,26 @@ class TTCrudBaseModel {
return $db->affected_rows;
}
public static function getFirst($filter = [], $order = ["key" => null]): ?TTCrudBaseModel {
$db = self::getDB();
$table = self::getFullyQualifiedTable();
$filter = self::getSQLFilter($filter);
$sql = "SELECT * FROM $table $filter";
if ($order['key'] !== null) {
$sql .= " ORDER BY `" . $order['key'] . "` " . $order['order'];
} else {
$sql .= " ORDER BY `id` ASC";
}
$sql .= " LIMIT 1";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return null;
}
return new static($result->fetch_assoc());
}
}

View File

@@ -0,0 +1,423 @@
// 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">&#9679;</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();
}
});

View File

@@ -0,0 +1,53 @@
// RMLWorkorderCompanyDashboardView.js
// This would be a separate file and view.
Vue.component('rml-workorder-company-dashboard', {
template: `
<div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine offenen Aufträge</h4>
<p class="display-4 text-primary">{{ stats.assigned || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine dringenden Aufträge</h4>
<p class="display-4 text-danger">{{ stats.urgent || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine terminierten Aufträge</h4>
<p class="display-4 text-warning">{{ stats.scheduled || 0 }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<tt-card>
<template v-slot:header><h5>Nächste Termine</h5></template>
</tt-card>
</div>
</div>
</div>
`,
data() {
return {
stats: {}
}
},
async mounted() {
// You would create a new controller action e.g., /RMLWorkorder/getCompanyDashboardStats
// const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getCompanyDashboardStats`);
// this.stats = response.data;
}
})

View File

@@ -0,0 +1,61 @@
// RMLWorkorderAdminDashboardView.js
// This would be a separate file and view.
Vue.component('rml-workorder-admin-dashboard', {
template: `
<div>
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Neue Aufträge</h4>
<p class="display-4 text-primary">{{ stats.new || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">In Arbeit</h4>
<p class="display-4 text-warning">{{ stats.in_progress || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Überfällig</h4>
<p class="display-4 text-danger">{{ stats.overdue || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Abgeschlossen (30T)</h4>
<p class="display-4 text-success">{{ stats.completed_30d || 0 }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<tt-card>
<template v-slot:header><h5>Dringende Aufträge (Deadline < 1 Woche)</h5></template>
</tt-card>
</div>
</div>
</div>
`,
data() {
return {
stats: {}
}
},
async mounted() {
// You would create a new controller action e.g., /RMLWorkorder/getDashboardStats
// const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDashboardStats`);
// this.stats = response.data;
}
})