added mph workorders to thetool
This commit is contained in:
21
application/WorkorderMph/WorkorderMphModel.php
Normal file
21
application/WorkorderMph/WorkorderMphModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $hausnummerId;
|
||||
public ?int $companyId;
|
||||
public string $status;
|
||||
public ?int $assignmentDate;
|
||||
public ?int $deadlineDate;
|
||||
public ?int $appointmentDate;
|
||||
public ?string $additionalInfo;
|
||||
public ?int $easement;
|
||||
public ?int $btb;
|
||||
public ?int $fttxLocationSupplied;
|
||||
public ?int $conduitToHuepLaid;
|
||||
public ?int $huepMounted;
|
||||
public ?int $dropCableAvailable;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
311
application/WorkorderMphAdmin/WorkorderMphAdminController.php
Normal file
311
application/WorkorderMphAdmin/WorkorderMphAdminController.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphAdminController extends WorkorderMphBaseController
|
||||
{
|
||||
protected string $headerTitle = 'MPH Arbeitsaufträge Verwaltung';
|
||||
protected bool $createText = false;
|
||||
protected array $permissionCheck = ['WorkorderMphAdmin'];
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']],
|
||||
['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
|
||||
['key' => 'netzgebietName', 'text' => 'Netzgebiet', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
|
||||
['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
|
||||
['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]],
|
||||
['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
];
|
||||
|
||||
protected function prepareCrudConfig()
|
||||
{
|
||||
$hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key'));
|
||||
array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]);
|
||||
}
|
||||
|
||||
public function indexAction()
|
||||
{
|
||||
$this->createWorkordersFromHausnummer();
|
||||
parent::indexAction();
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||
$filters = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order'] ?? [];
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
|
||||
$whereClauses = "WHERE 1=1";
|
||||
|
||||
if (empty($filters['status'])) {
|
||||
$whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')";
|
||||
} else {
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
|
||||
}
|
||||
|
||||
if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
|
||||
if (!empty($filters['hausnummerInfo'])) {
|
||||
$searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo";
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns);
|
||||
}
|
||||
if (!empty($filters['netzgebietName'])) $whereClauses .= Helper::generateFilterCondition($filters['netzgebietName'], 'ng.name');
|
||||
if (!empty($filters['companyName'])) $whereClauses .= Helper::generateFilterCondition($filters['companyName'], 'c.name');
|
||||
if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
|
||||
if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
w.id, w.status, w.deadlineDate, w.appointmentDate, w.companyId, w.additionalInfo,
|
||||
IFNULL(c.name, 'Nicht zugewiesen') as companyName,
|
||||
CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo,
|
||||
str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city,
|
||||
IFNULL(ng.name, '-') as netzgebietName,
|
||||
(SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount
|
||||
FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
$whereClauses
|
||||
";
|
||||
|
||||
$orderBy = "";
|
||||
if (!empty($order['key'])) {
|
||||
$sortableColumns = ['id', 'status', 'deadlineDate', 'companyName', 'additionalInfo', 'appointmentDate', 'netzgebietName'];
|
||||
if (in_array($order['key'], $sortableColumns)) {
|
||||
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
|
||||
$orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder;
|
||||
}
|
||||
}
|
||||
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
|
||||
|
||||
$sql .= $orderBy;
|
||||
|
||||
// Get total count
|
||||
$countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
LEFT JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
$whereClauses";
|
||||
$totalCount = $db->query($countSql)->fetch_assoc()['count'];
|
||||
|
||||
// Add pagination
|
||||
if ($pagination['per_page'] !== null) {
|
||||
$sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']);
|
||||
}
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
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
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getCompaniesAction()
|
||||
{
|
||||
$companies = WorkorderCompanyModel::getAll();
|
||||
self::returnJson(array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies));
|
||||
}
|
||||
|
||||
protected function assignWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$deadline = !empty($this->postData['deadlineDate']) ? $this->postData['deadlineDate'] : strtotime('+6 weeks');
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$oldCompanyId = $workorder->companyId;
|
||||
|
||||
$workorder->companyId = $this->postData['companyId'];
|
||||
$workorder->status = 'assigned';
|
||||
$workorder->assignmentDate = time();
|
||||
$workorder->deadlineDate = $deadline;
|
||||
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
$company = WorkorderCompanyModel::get($this->postData['companyId']);
|
||||
$statusChange = $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('assigned');
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Arbeitsauftrag zugewiesen an: " . ($company ? $company->name : "Firma ID " . $this->postData['companyId']),
|
||||
'statusChange' => $statusChange,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich zugewiesen.']);
|
||||
}
|
||||
|
||||
protected function updateDeadlineAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['deadlineDate'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$workorder->deadlineDate = $this->postData['deadlineDate'];
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Deadline geändert auf ' . date('d.m.Y', $this->postData['deadlineDate']) . '.',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']);
|
||||
}
|
||||
|
||||
protected function acceptDocumentationAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
if ($workorder->status !== 'documented') self::sendError("Die Dokumentation muss zuerst von der Firma als fertig markiert werden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'completed';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background task: Creates WorkorderMph from Hausnummer with >2 Wohneinheiten
|
||||
* and RIMO state not in grossplaning/not2connect
|
||||
*/
|
||||
private function createWorkordersFromHausnummer()
|
||||
{
|
||||
$lockFile = TEMP_DIR . "/task_create_workorder_mph.lock";
|
||||
if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) {
|
||||
return; // Run only every 5 minutes
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
|
||||
// Build netzgebiet filter
|
||||
$netzgebietIds = defined('TT_WORKORDER_MPH_NETZGEBIET_IDS') ? TT_WORKORDER_MPH_NETZGEBIET_IDS : [];
|
||||
$netzgebietFilter = '';
|
||||
if (!empty($netzgebietIds)) {
|
||||
$escapedIds = array_map(fn($id) => $db->escape($id), $netzgebietIds);
|
||||
$netzgebietFilter = " AND hn.netzgebiet_id IN (" . implode(',', $escapedIds) . ")";
|
||||
}
|
||||
|
||||
// Find Hausnummer with >2 Wohneinheiten and state not in grossplaning/not2connect
|
||||
$sql = "
|
||||
SELECT hn.id, hn.netzgebiet_id, COUNT(we.id) as we_count
|
||||
FROM Hausnummer hn
|
||||
LEFT JOIN Wohneinheit we ON hn.id = we.hausnummer_id
|
||||
WHERE hn.rimo_ex_state NOT IN ('grossplaning', 'not2connect')
|
||||
AND hn.rimo_op_state NOT IN ('grossplaning', 'not2connect')
|
||||
$netzgebietFilter
|
||||
GROUP BY hn.id
|
||||
HAVING we_count > 2
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$hausnummern = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
// Get valid hausnummer IDs
|
||||
$validHausnummerIds = array_column($hausnummern, 'id');
|
||||
|
||||
foreach ($hausnummern as $hn) {
|
||||
// Check if WorkorderMph already exists
|
||||
$existing = WorkorderMphModel::getFirst(['hausnummerId' => $hn['id']]);
|
||||
|
||||
if (!$existing) {
|
||||
// Create new WorkorderMph
|
||||
WorkorderMphModel::create([
|
||||
'hausnummerId' => $hn['id'],
|
||||
'status' => 'new',
|
||||
'create' => time(),
|
||||
'createBy' => 1 // System user
|
||||
]);
|
||||
} elseif ($existing->status === 'archived') {
|
||||
// Reactivate archived workorder
|
||||
$existing->status = 'new';
|
||||
$existing->companyId = null;
|
||||
$existing->deadlineDate = null;
|
||||
$existing->appointmentDate = null;
|
||||
WorkorderMphModel::update((array)$existing);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $existing->id,
|
||||
'text' => 'Arbeitsauftrag wurde automatisch reaktiviert.',
|
||||
'statusChange' => $this->getStatusText('archived') . " -> " . $this->getStatusText('new'),
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Archive workorders for Hausnummer that are no longer in allowed netzgebiete or don't meet criteria
|
||||
if (!empty($netzgebietIds)) {
|
||||
$allWorkorders = WorkorderMphModel::getAll(['status' => ['new', 'assigned', 'scheduled', 'in_progress']]);
|
||||
foreach ($allWorkorders as $workorder) {
|
||||
if (!in_array($workorder->hausnummerId, $validHausnummerIds)) {
|
||||
$workorder->status = 'archived';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeitsauftrag automatisch archiviert (Netzgebiet deaktiviert oder Kriterien nicht mehr erfüllt).',
|
||||
'statusChange' => 'active -> archived',
|
||||
'create' => time(),
|
||||
'createBy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($lockFile, time());
|
||||
}
|
||||
|
||||
protected function cancelWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'cancelled';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
$reason = !empty($this->postData['reason']) ? $this->postData['reason'] : 'Kein Grund angegeben';
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Arbeitsauftrag storniert. Grund: " . $reason,
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('cancelled'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
|
||||
}
|
||||
}
|
||||
304
application/WorkorderMphBase/WorkorderMphBaseController.php
Normal file
304
application/WorkorderMphBase/WorkorderMphBaseController.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphBaseController extends TTCrud
|
||||
{
|
||||
protected array $statusColumn = [
|
||||
'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' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'],
|
||||
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'],
|
||||
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
|
||||
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-ban text-danger'],
|
||||
['value' => 'archived', 'text' => 'Archiviert', 'icon' => 'fas fa-archive text-muted'],
|
||||
]]
|
||||
];
|
||||
|
||||
protected array $additionalJS = ["js/pages/WorkorderMphBase/WorkorderMphBase.js"];
|
||||
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/WorkorderMphBase/WorkorderMphBase.css'>"];
|
||||
|
||||
// Wohneinheit status options
|
||||
protected array $wohneinheitStatuses = [
|
||||
['value' => 1, 'text' => '10 - new'],
|
||||
['value' => 12, 'text' => '241 - BEP installed (MD)'],
|
||||
['value' => 13, 'text' => '242 - Inhouse cabling finished'],
|
||||
['value' => 18, 'text' => '243 - Cable in stairwell'],
|
||||
['value' => 14, 'text' => '244 - BEP installed (SD)'],
|
||||
['value' => 15, 'text' => '245 - Installation Approved'],
|
||||
['value' => 16, 'text' => '300 - ONT installed'],
|
||||
];
|
||||
|
||||
protected function getStatusText(string $statusKey): string
|
||||
{
|
||||
$statusMap = array_column($this->statusColumn['table']['filterOptions'] ?? [], 'text', 'value');
|
||||
return $statusMap[$statusKey] ?? ucfirst(str_replace('_', ' ', $statusKey));
|
||||
}
|
||||
|
||||
protected function getWohneinheitStatusText(int $statusValue): string
|
||||
{
|
||||
$statusMap = array_column($this->wohneinheitStatuses, 'text', 'value');
|
||||
return $statusMap[$statusValue] ?? "Status $statusValue";
|
||||
}
|
||||
|
||||
//region SHARED ACTIONS
|
||||
/**
|
||||
* Fetches documentation and journal entries for a given workorder.
|
||||
*/
|
||||
protected function getDocumentationAction()
|
||||
{
|
||||
if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$docs = WorkorderMphDocumentationModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'ASC']);
|
||||
$journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($this->request->workorderMphId)], null, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
|
||||
$responseDocs = [];
|
||||
$typeCounts = [];
|
||||
|
||||
foreach ($docs as $doc) {
|
||||
$file = new File($doc->fileId);
|
||||
$documentTypeKey = $doc->documentType;
|
||||
$typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
|
||||
$originalFilename = $file->orig_filename ?? $file->filename;
|
||||
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||
$newFilename = "{$documentTypeKey}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
|
||||
|
||||
$responseDocs[] = [
|
||||
'id' => $doc->id,
|
||||
'fileId' => $doc->fileId,
|
||||
'fileName' => $newFilename,
|
||||
'description' => $doc->description,
|
||||
'documentType' => $documentTypeKey,
|
||||
'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
|
||||
'mimetype' => $file->mimetype ?? 'application/octet-stream',
|
||||
'create' => $doc->create
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($journals as $journal) {
|
||||
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
|
||||
}
|
||||
|
||||
self::returnJson(['docs' => $responseDocs, 'journals' => $journals]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new entry to a workorder's journal.
|
||||
*/
|
||||
protected function addJournalAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId']) || empty(trim($post['text']))) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich.");
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $post['workorderMphId'],
|
||||
'text' => $post['text'],
|
||||
'createBy' => $this->user->id,
|
||||
'create' => time()
|
||||
]);
|
||||
|
||||
$journals = WorkorderMphJournalModel::getAll(['workorderMphId' => intval($post['workorderMphId'])], null, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
foreach ($journals as $journal) {
|
||||
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
|
||||
}
|
||||
self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.', 'journals' => $journals]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the additional info field for a workorder.
|
||||
*/
|
||||
protected function updateAdditionalInfoAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($post['workorderMphId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldInfo = $workorder->additionalInfo;
|
||||
$newInfo = $post['additionalInfo'] ?? null;
|
||||
$workorder->additionalInfo = $newInfo;
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Zusatzinfo aktualisiert.', 'newInfo' => $newInfo]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Wohneinheiten for a specific workorder with their statuses and notes
|
||||
*/
|
||||
protected function getWohneinheitenAction()
|
||||
{
|
||||
if (empty($this->request->workorderMphId)) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorderMphId = intval($this->request->workorderMphId);
|
||||
$workorder = WorkorderMphModel::get($workorderMphId);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
// Get all Wohneinheiten for this Hausnummer from addressdb
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$hausnummerId = $db->escape($workorder->hausnummerId);
|
||||
|
||||
$sql = "SELECT w.id, w.bezeichner, w.contact
|
||||
FROM Wohneinheit w
|
||||
WHERE w.hausnummer_id = $hausnummerId
|
||||
ORDER BY w.bezeichner";
|
||||
$result = $db->query($sql);
|
||||
$wohneinheiten = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
// Get existing WorkorderMphWohneinheit records
|
||||
$existingRecords = WorkorderMphWohneinheitModel::getAll(['workorderMphId' => $workorderMphId]);
|
||||
$recordsMap = [];
|
||||
foreach ($existingRecords as $record) {
|
||||
$recordsMap[$record->wohneinheitId] = $record;
|
||||
}
|
||||
|
||||
// Merge data
|
||||
$response = [];
|
||||
foreach ($wohneinheiten as $we) {
|
||||
$record = $recordsMap[$we['id']] ?? null;
|
||||
$response[] = [
|
||||
'wohneinheitId' => intval($we['id']),
|
||||
'bezeichner' => $we['bezeichner'],
|
||||
'contact' => $we['contact'],
|
||||
'status' => $record ? $record->status : 1,
|
||||
'note' => $record ? $record->note : null,
|
||||
'recordId' => $record ? $record->id : null,
|
||||
];
|
||||
}
|
||||
|
||||
self::returnJson(['wohneinheiten' => $response, 'statusOptions' => $this->wohneinheitStatuses]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status and note for a specific Wohneinheit
|
||||
*/
|
||||
protected function updateWohneinheitAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId']) || empty($post['wohneinheitId'])) {
|
||||
self::sendError("Arbeitsauftrags-ID und Wohneinheit-ID sind erforderlich.");
|
||||
}
|
||||
|
||||
$workorderMphId = intval($post['workorderMphId']);
|
||||
$wohneinheitId = intval($post['wohneinheitId']);
|
||||
$status = intval($post['status'] ?? 1);
|
||||
$note = $post['note'] ?? null;
|
||||
|
||||
// Check if record exists
|
||||
$existing = WorkorderMphWohneinheitModel::getFirst([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'wohneinheitId' => $wohneinheitId
|
||||
]);
|
||||
|
||||
$oldStatus = $existing ? $existing->status : 1;
|
||||
$oldNote = $existing ? $existing->note : null;
|
||||
|
||||
if ($existing) {
|
||||
$existing->status = $status;
|
||||
$existing->note = $note;
|
||||
$existing->edit = time();
|
||||
$existing->editBy = $this->user->id;
|
||||
WorkorderMphWohneinheitModel::update((array)$existing);
|
||||
} else {
|
||||
WorkorderMphWohneinheitModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'wohneinheitId' => $wohneinheitId,
|
||||
'status' => $status,
|
||||
'note' => $note,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
}
|
||||
|
||||
// Add journal entry if status or note changed
|
||||
if ($oldStatus !== $status || $oldNote !== $note) {
|
||||
$changes = [];
|
||||
if ($oldStatus !== $status) {
|
||||
$changes[] = "Status: " . $this->getWohneinheitStatusText($oldStatus) . " → " . $this->getWohneinheitStatusText($status);
|
||||
}
|
||||
if ($oldNote !== $note) {
|
||||
$changes[] = "Notiz aktualisiert";
|
||||
}
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'text' => "Wohneinheit $wohneinheitId: " . implode(', ', $changes),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
|
||||
// If status is 241 (BEP MD) or 300 (ONT installed), set statusflag 200 on Wohneinheit
|
||||
if (in_array($status, [12, 16])) { // 12=241 BEP MD, 16=300 ONT
|
||||
$this->setWohneinheitStatusflag($wohneinheitId, 200);
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Wohneinheit aktualisiert.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set statusflag on Wohneinheit in addressdb
|
||||
*/
|
||||
private function setWohneinheitStatusflag(int $wohneinheitId, int $statusflagId)
|
||||
{
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$weId = $db->escape($wohneinheitId);
|
||||
$sfId = $db->escape($statusflagId);
|
||||
|
||||
// Check if statusflag already exists
|
||||
$checkSql = "SELECT COUNT(*) as count FROM WohneinheitStatusflagValue WHERE wohneinheit_id = $weId AND statusflag_id = $sfId";
|
||||
$result = $db->query($checkSql);
|
||||
$exists = $result->fetch_assoc()['count'] > 0;
|
||||
|
||||
if (!$exists) {
|
||||
$insertSql = "INSERT INTO WohneinheitStatusflagValue (wohneinheit_id, statusflag_id, create, createBy)
|
||||
VALUES ($weId, $sfId, " . time() . ", " . $this->user->id . ")";
|
||||
$db->query($insertSql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update checkbox documentation fields
|
||||
*/
|
||||
protected function updateCheckboxesAction()
|
||||
{
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderMphId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
|
||||
$workorder = WorkorderMphModel::get($post['workorderMphId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$changes = [];
|
||||
$checkboxFields = ['easement', 'btb', 'fttxLocationSupplied', 'conduitToHuepLaid', 'huepMounted', 'dropCableAvailable'];
|
||||
|
||||
foreach ($checkboxFields as $field) {
|
||||
if (array_key_exists($field, $post)) {
|
||||
$oldValue = $workorder->$field;
|
||||
$newValue = $post[$field] ? 1 : 0;
|
||||
if ($oldValue !== $newValue) {
|
||||
$workorder->$field = $newValue;
|
||||
$changes[] = "$field: " . ($newValue ? 'ja' : 'nein');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Dokumentation aktualisiert:\n" . implode("\n", $changes),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation aktualisiert.']);
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphCompanyController extends WorkorderMphBaseController
|
||||
{
|
||||
protected string $headerTitle = 'Meine MPH Arbeitsaufträge';
|
||||
protected bool $createText = false;
|
||||
protected array $permissionCheck = ['RMLCompany'];
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true]],
|
||||
['key' => 'hausnummerInfo', 'text' => 'Adresse', 'modal' => false, 'table' => ['sortable' => false]],
|
||||
['key' => 'wohneinheitCount', 'text' => 'WE', 'modal' => false, 'table' => ['sortable' => false]],
|
||||
['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
|
||||
];
|
||||
protected array $additionalJSVariables = ['COMPANY_ID' => '0', 'IS_COMPANY_VIEW' => true];
|
||||
|
||||
protected function prepareCrudConfig()
|
||||
{
|
||||
$hausnummerInfoColIdx = array_search('hausnummerInfo', array_column($this->columns, 'key'));
|
||||
array_splice($this->columns, $hausnummerInfoColIdx + 1, 0, [$this->statusColumn]);
|
||||
|
||||
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
$this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0;
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||
$filters = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order'] ?? [];
|
||||
|
||||
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
if (!$company) {
|
||||
self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
|
||||
$whereClauses = "WHERE w.companyId = " . intval($company->id);
|
||||
|
||||
if (empty($filters['status'])) {
|
||||
$whereClauses .= " AND w.status NOT IN ('completed', 'cancelled', 'archived')";
|
||||
} else {
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
|
||||
}
|
||||
|
||||
if (!empty($filters['id'])) $whereClauses .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
|
||||
if (!empty($filters['hausnummerInfo'])) {
|
||||
$searchColumns = "str.name|hn.hausnummer|hn.stiege|plz.plz|ort.name|w.additionalInfo";
|
||||
$whereClauses .= Helper::generateFilterCondition($filters['hausnummerInfo'], $searchColumns);
|
||||
}
|
||||
if (!empty($filters['deadlineDate'])) $whereClauses .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
|
||||
if (!empty($filters['appointmentDate'])) $whereClauses .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate');
|
||||
if (!empty($filters['additionalInfo'])) $whereClauses .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo,
|
||||
CONCAT_WS(' ', str.name, hn.hausnummer, hn.stiege) as hausnummerInfo,
|
||||
str.name as street, hn.hausnummer, hn.stiege, plz.plz, ort.name as city,
|
||||
(SELECT COUNT(*) FROM `$addressDbName`.`Wohneinheit` we WHERE we.hausnummer_id = hn.id) as wohneinheitCount
|
||||
FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
$whereClauses
|
||||
";
|
||||
|
||||
$orderBy = "";
|
||||
if (!empty($order['key'])) {
|
||||
$sortableColumns = ['id', 'status', 'deadlineDate', 'additionalInfo', 'appointmentDate'];
|
||||
if (in_array($order['key'], $sortableColumns)) {
|
||||
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
|
||||
$orderBy = " ORDER BY " . $db->escape($order['key']) . " " . $sortOrder;
|
||||
}
|
||||
}
|
||||
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
|
||||
|
||||
$sql .= $orderBy;
|
||||
|
||||
// Get total count
|
||||
$countSql = "SELECT COUNT(*) as count FROM `$fronkDbName`.`WorkorderMph` w
|
||||
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON w.hausnummerId = hn.id
|
||||
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
|
||||
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
|
||||
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
|
||||
$whereClauses";
|
||||
$totalCount = $db->query($countSql)->fetch_assoc()['count'];
|
||||
|
||||
// Add pagination
|
||||
if ($pagination['per_page'] !== null) {
|
||||
$sql .= " LIMIT " . intval($pagination['per_page']) . " OFFSET " . intval(($pagination['page'] - 1) * $pagination['per_page']);
|
||||
}
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
|
||||
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()
|
||||
{
|
||||
if (empty($this->request->id)) self::sendError("ID fehlt");
|
||||
$workorder = WorkorderMphModel::get($this->request->id);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
|
||||
self::returnJson((array)$workorder);
|
||||
}
|
||||
|
||||
protected function scheduleAppointmentAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
|
||||
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->appointmentDate = $this->postData['appointmentDate'];
|
||||
$workorder->status = 'scheduled';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $this->postData['appointmentDate']),
|
||||
'statusChange' => $oldStatus !== 'scheduled' ? $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('scheduled') : null,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
|
||||
}
|
||||
|
||||
protected function rescheduleAppointmentAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate']) || empty($this->postData['reason'])) self::sendError("Erforderliche Felder fehlen.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
|
||||
|
||||
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
|
||||
$newDateFormatted = date('d.m.Y H:i', $this->postData['appointmentDate']);
|
||||
$workorder->appointmentDate = $this->postData['appointmentDate'];
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $this->postData['reason'],
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
|
||||
}
|
||||
|
||||
protected function startWorkAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'in_progress';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeit begonnen.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('in_progress'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeit wurde gestartet.']);
|
||||
}
|
||||
|
||||
protected function completeWorkorderAction()
|
||||
{
|
||||
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
|
||||
$workorder = WorkorderMphModel::get($this->postData['workorderId']);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
// Validate that all required Wohneinheiten have notes
|
||||
$wohneinheiten = WorkorderMphWohneinheitModel::getAll(['workorderMphId' => $workorder->id]);
|
||||
foreach ($wohneinheiten as $we) {
|
||||
if (empty($we->note)) {
|
||||
self::sendError("Bitte fügen Sie für jede Wohneinheit eine Notiz hinzu, bevor Sie den Auftrag abschließen.");
|
||||
}
|
||||
}
|
||||
|
||||
$oldStatus = $workorder->status;
|
||||
$workorder->status = 'documented';
|
||||
WorkorderMphModel::update((array)$workorder);
|
||||
|
||||
WorkorderMphJournalModel::create([
|
||||
'workorderMphId' => $workorder->id,
|
||||
'text' => 'Arbeitsauftrag abgeschlossen und dokumentiert.',
|
||||
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'),
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id,
|
||||
]);
|
||||
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich abgeschlossen.']);
|
||||
}
|
||||
|
||||
protected function uploadDocumentationAction()
|
||||
{
|
||||
if (empty($_FILES['file']) || empty($_POST['workorderMphId'])) self::sendError("Datei und Arbeitsauftrags-ID sind erforderlich.");
|
||||
|
||||
$workorderMphId = intval($_POST['workorderMphId']);
|
||||
$workorder = WorkorderMphModel::get($workorderMphId);
|
||||
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
|
||||
|
||||
$documentType = $_POST['documentType'] ?? 'photo';
|
||||
$description = $_POST['description'] ?? null;
|
||||
|
||||
// Upload file using mfUpload
|
||||
$upload = new mfUpload($_FILES['file']);
|
||||
if (!$upload->upload()) {
|
||||
self::sendError("Datei-Upload fehlgeschlagen.");
|
||||
}
|
||||
|
||||
$file = $upload->getFile();
|
||||
|
||||
WorkorderMphDocumentationModel::create([
|
||||
'workorderMphId' => $workorderMphId,
|
||||
'fileId' => $file->id,
|
||||
'description' => $description,
|
||||
'documentType' => $documentType,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Dokument erfolgreich hochgeladen.', 'fileId' => $file->id]);
|
||||
}
|
||||
|
||||
protected function deleteDocumentationAction()
|
||||
{
|
||||
if (empty($this->postData['documentationId'])) self::sendError("Dokumentations-ID fehlt.");
|
||||
|
||||
$doc = WorkorderMphDocumentationModel::get($this->postData['documentationId']);
|
||||
if (!$doc) self::sendError("Dokumentation nicht gefunden.");
|
||||
|
||||
WorkorderMphDocumentationModel::delete($doc->id);
|
||||
self::returnJson(['success' => true, 'message' => 'Dokumentation gelöscht.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphDocumentationModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $workorderMphId;
|
||||
public int $fileId;
|
||||
public ?string $description;
|
||||
public string $documentType;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
12
application/WorkorderMphJournal/WorkorderMphJournalModel.php
Normal file
12
application/WorkorderMphJournal/WorkorderMphJournalModel.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphJournalModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $workorderMphId;
|
||||
public ?string $text;
|
||||
public ?string $fileIds;
|
||||
public ?string $statusChange;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
class WorkorderMphWohneinheitModel extends TTCrudBaseModel
|
||||
{
|
||||
public int $id;
|
||||
public int $workorderMphId;
|
||||
public int $wohneinheitId;
|
||||
public int $status;
|
||||
public ?string $note;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
public ?int $edit;
|
||||
public ?int $editBy;
|
||||
}
|
||||
95
db/migrations/20251116120000_create_workorder_mph_tables.php
Normal file
95
db/migrations/20251116120000_create_workorder_mph_tables.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateWorkorderMphTables extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$table = $this->table("WorkerPermission");
|
||||
$table->addColumn("canWorkorderMphAdmin", "enum", [
|
||||
"null" => false,
|
||||
"values" => ['false', 'true'],
|
||||
"default" => "false",
|
||||
"after" => "canWorkorderAdmin"
|
||||
]);
|
||||
$table->update();
|
||||
|
||||
$workorderMph = $this->table('WorkorderMph', ['id' => 'id', 'primary_key' => 'id']);
|
||||
$workorderMph
|
||||
->addColumn('hausnummerId', '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('additionalInfo', 'text', ['null' => true])
|
||||
->addColumn('easement', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leitungsrecht'])
|
||||
->addColumn('btb', 'boolean', ['null' => true, 'default' => null])
|
||||
->addColumn('fttxLocationSupplied', 'boolean', ['null' => true, 'default' => null, 'comment' => 'FTTx Location mit Leerrohr versorgt'])
|
||||
->addColumn('conduitToHuepLaid', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Leerrohr bis HÜP/HAK verlegt'])
|
||||
->addColumn('huepMounted', 'boolean', ['null' => true, 'default' => null, 'comment' => 'HÜP/HAK montiert'])
|
||||
->addColumn('dropCableAvailable', 'boolean', ['null' => true, 'default' => null, 'comment' => 'Dropkabel vorhanden'])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->addIndex(['hausnummerId'], ['name' => 'hausnummerId_idx'])
|
||||
->addIndex(['companyId'], ['name' => 'companyId_mph_idx'])
|
||||
->addIndex(['status'], ['name' => 'status_mph_idx'])
|
||||
->create();
|
||||
|
||||
$workorderMphWohneinheit = $this->table('WorkorderMphWohneinheit', ['id' => 'id', 'primary_key' => 'id']);
|
||||
$workorderMphWohneinheit
|
||||
->addColumn('workorderMphId', 'integer', ['null' => false])
|
||||
->addColumn('wohneinheitId', 'integer', ['null' => false])
|
||||
->addColumn('status', 'integer', ['null' => false, 'default' => 1, 'comment' => '1=new, 12=241 BEP MD, 13=242 Inhouse, 18=243 Stairwell, 14=244 BEP SD, 15=245 Approved, 16=300 ONT'])
|
||||
->addColumn('note', 'text', ['null' => true])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->addColumn('edit', 'integer', ['null' => true])
|
||||
->addColumn('editBy', 'integer', ['null' => true])
|
||||
->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx'])
|
||||
->addIndex(['wohneinheitId'], ['name' => 'wohneinheitId_idx'])
|
||||
->addIndex(['workorderMphId', 'wohneinheitId'], ['unique' => true, 'name' => 'workorder_wohneinheit_unique'])
|
||||
->create();
|
||||
|
||||
$workorderMphJournal = $this->table('WorkorderMphJournal', ['id' => false, 'primary_key' => ['id']]);
|
||||
$workorderMphJournal
|
||||
->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('workorderMphId', 'integer', ['null' => false])
|
||||
->addColumn('text', 'text', ['null' => true])
|
||||
->addColumn('fileIds', 'json', ['null' => true])
|
||||
->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->addIndex(['workorderMphId'], ['name' => 'workorderMphId_idx'])
|
||||
->create();
|
||||
|
||||
$workorderMphDocumentation = $this->table('WorkorderMphDocumentation', ['id' => 'id', 'primary_key' => 'id']);
|
||||
$workorderMphDocumentation
|
||||
->addColumn('workorderMphId', '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(['workorderMphId'], ['name' => 'workorderMphId_idx'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$this->table('WorkorderMphDocumentation')->drop()->save();
|
||||
$this->table('WorkorderMphJournal')->drop()->save();
|
||||
$this->table('WorkorderMphWohneinheit')->drop()->save();
|
||||
$this->table('WorkorderMph')->drop()->save();
|
||||
|
||||
$table = $this->table("WorkerPermission");
|
||||
$table->removeColumn("canWorkorderMphAdmin");
|
||||
$table->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
237
public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js
Normal file
237
public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js
Normal file
@@ -0,0 +1,237 @@
|
||||
// WorkorderMphAdmin.js
|
||||
Vue.component('workorder-mph-admin', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<tt-table-crud ref="table" :crud-config="crudConfig">
|
||||
<template v-slot:hausnummerinfo="{ row }">
|
||||
<div class="small">
|
||||
<div><strong>Adresse:</strong> {{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template></div>
|
||||
<div><strong>Ort:</strong> {{ row.plz }} {{ row.city }}</div>
|
||||
<div><strong>Wohneinheiten:</strong> {{ row.wohneinheitCount }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<traffic-light-mph :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:companyname="{ row }">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<div v-if="editingWorkorderId === row.id">
|
||||
<div v-if="companiesLoading" class="spinner-border spinner-border-sm"></div>
|
||||
<tt-select v-else :options="companies" :value="row.companyId"
|
||||
@input="assignCompany(row, $event)" @blur="editingWorkorderId = null"
|
||||
@keydown.esc.native="editingWorkorderId = null"
|
||||
placeholder="Firma zuweisen..." sm no-form-group/>
|
||||
</div>
|
||||
<div v-else-if="row.status === 'new'">
|
||||
<tt-select :options="companies" :value="row.companyId"
|
||||
@input="assignCompany(row, $event)" @focus="loadCompanies"
|
||||
placeholder="Firma zuweisen..." sm no-form-group/>
|
||||
</div>
|
||||
<div v-else><span>{{ row.companyName || 'N/A' }}</span></div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, auto); gap: 0px; padding-left: 8px;">
|
||||
<tt-button v-if="!['completed', 'new'].includes(row.status)" icon="fas fa-edit"
|
||||
@click="startCompanyEdit(row)" additional-class="btn-link workorder-mph-button"
|
||||
title="Zuweisung ändern"/>
|
||||
<tt-button v-if="!['completed', 'cancelled'].includes(row.status)" icon="fas fa-ban text-danger"
|
||||
@click="cancelWorkorderModalData = row" additional-class="btn-link workorder-mph-button"
|
||||
title="Auftrag stornieren"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:additionalinfo="{ row }">
|
||||
<div v-if="editingAdditionalInfoId === row.id">
|
||||
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" rows="3" no-form-group sm ref="editTextarea"/>
|
||||
<div class="mt-2 d-flex justify-content-end">
|
||||
<tt-button text="Abbrechen" @click="cancelEdit" sm additional-class="btn-secondary mr-2"/>
|
||||
<tt-button text="Speichern" @click="updateAdditionalInfo(row)" sm additional-class="btn-success"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="d-flex align-items-start">
|
||||
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
|
||||
<tt-button icon="fas fa-edit" @click="startAdditionalInfoEdit(row)"
|
||||
additional-class="btn-link btn-sm p-0 ml-2" title="Zusatz-Info bearbeiten"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:deadlinedate="{ row }">
|
||||
<div v-if="editingDeadlineId === row.id">
|
||||
<tt-date-picker :value="row.deadlineDate" :date-range="false"
|
||||
@input="updateDeadline(row, $event)" @blur="editingDeadlineId = null"
|
||||
sm no-form-group/>
|
||||
</div>
|
||||
<div v-else class="d-flex align-items-center">
|
||||
<span>{{ formatDate(row.deadlineDate) }}</span>
|
||||
<tt-button icon="fas fa-edit" @click="editingDeadlineId = row.id"
|
||||
additional-class="btn-link btn-sm p-0 ml-2" title="Deadline ändern"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:appointmentdate="{ row }">{{ formatDate(row.appointmentDate, true) }}</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="true"/>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="true"/>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<workorder-mph-details-manager :workorder-mph-id="row.id" :is-admin="true"
|
||||
@documentation-accepted="$refs.table.$refs.table.refreshTable()"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tt-table-crud>
|
||||
|
||||
<tt-modal v-if="cancelWorkorderModalData" :show.sync="cancelWorkorderModalData"
|
||||
title="Auftrag stornieren" @submit="cancelWorkorder">
|
||||
<p>Soll der Auftrag <strong>#{{ cancelWorkorderModalData.id }}</strong> wirklich storniert werden?</p>
|
||||
<tt-textarea label="Grund (optional)" v-model="cancelWorkorderModalData.reason" sm row/>
|
||||
</tt-modal>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window,
|
||||
editingWorkorderId: null,
|
||||
editingDeadlineId: null,
|
||||
editingAdditionalInfoId: null,
|
||||
tempAdditionalInfo: '',
|
||||
companies: [],
|
||||
companiesLoading: false,
|
||||
cancelWorkorderModalData: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
selectable: false,
|
||||
expandable: true,
|
||||
customRowClass: (row) => {
|
||||
if (['completed', 'new', 'cancelled', 'archived'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
|
||||
const deadlineDate = moment.unix(row.deadlineDate);
|
||||
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
|
||||
const daysLeft = deadlineDate.diff(moment(), 'days');
|
||||
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
|
||||
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
|
||||
return 'tt-mph-workorder-ontrack';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp, withTime = false) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
|
||||
},
|
||||
async loadCompanies() {
|
||||
if (this.companies.length > 0) return;
|
||||
this.companiesLoading = true;
|
||||
try {
|
||||
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/getCompanies`);
|
||||
this.companies = data;
|
||||
} catch (e) {
|
||||
window.notify('error', 'Firmenliste konnte nicht geladen werden.');
|
||||
} finally {
|
||||
this.companiesLoading = false;
|
||||
}
|
||||
},
|
||||
async startCompanyEdit(row) {
|
||||
await this.loadCompanies();
|
||||
this.editingWorkorderId = row.id;
|
||||
},
|
||||
async assignCompany(workorder, companyId) {
|
||||
if (!companyId) {
|
||||
this.editingWorkorderId = null;
|
||||
return;
|
||||
}
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/assignWorkorder`, {
|
||||
workorderId: workorder.id,
|
||||
companyId: companyId
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
} else {
|
||||
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
this.editingWorkorderId = null;
|
||||
},
|
||||
async updateDeadline(workorder, newDate) {
|
||||
if (!newDate) {
|
||||
this.editingDeadlineId = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateDeadline`, {
|
||||
workorderId: workorder.id,
|
||||
deadlineDate: newDate
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
} else {
|
||||
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler.');
|
||||
} finally {
|
||||
this.editingDeadlineId = null;
|
||||
}
|
||||
},
|
||||
startAdditionalInfoEdit(row) {
|
||||
this.editingAdditionalInfoId = row.id;
|
||||
this.tempAdditionalInfo = row.additionalInfo || '';
|
||||
this.$nextTick(() => this.$refs.editTextarea?.$el.querySelector('textarea').focus());
|
||||
},
|
||||
cancelEdit() {
|
||||
this.editingAdditionalInfoId = null;
|
||||
this.tempAdditionalInfo = '';
|
||||
},
|
||||
async updateAdditionalInfo(row) {
|
||||
if (row.additionalInfo === this.tempAdditionalInfo) {
|
||||
this.cancelEdit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateAdditionalInfo`, {
|
||||
workorderMphId: row.id,
|
||||
additionalInfo: this.tempAdditionalInfo
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
row.additionalInfo = data.newInfo;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Update fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler.');
|
||||
} finally {
|
||||
this.cancelEdit();
|
||||
}
|
||||
},
|
||||
async cancelWorkorder() {
|
||||
const { id, reason } = this.cancelWorkorderModalData;
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/cancelWorkorder`, {
|
||||
workorderId: id,
|
||||
reason: reason
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
this.cancelWorkorderModalData = null;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
208
public/js/pages/WorkorderMphBase/WorkorderMphBase.css
Normal file
208
public/js/pages/WorkorderMphBase/WorkorderMphBase.css
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* CSS for WorkorderMph Table Row Highlighting
|
||||
*/
|
||||
|
||||
/* Urgent: Deadline passed or less than 1 week away */
|
||||
.table-hover .tt-mph-workorder-urgent:hover,
|
||||
.tt-mph-workorder-urgent {
|
||||
background-color: #fbe9e7 !important; /* Soft Red */
|
||||
}
|
||||
|
||||
/* Medium: Deadline less than 3 weeks away */
|
||||
.table-hover .tt-mph-workorder-medium:hover,
|
||||
.tt-mph-workorder-medium {
|
||||
background-color: #fff8e1 !important; /* Soft Yellow */
|
||||
}
|
||||
|
||||
/* On Track: Deadline more than 3 weeks away */
|
||||
.table-hover .tt-mph-workorder-ontrack:hover,
|
||||
.tt-mph-workorder-ontrack {
|
||||
background-color: #e8f5e9 !important; /* Soft Green */
|
||||
}
|
||||
|
||||
/* Irrelevant: No deadline or status makes it not applicable */
|
||||
.table-hover .tt-mph-workorder-irrelevant:hover,
|
||||
.tt-mph-workorder-irrelevant {
|
||||
background-color: #fafafa !important; /* Very light grey */
|
||||
}
|
||||
|
||||
.table-hover .tt-mph-workorder-high:hover,
|
||||
.tt-mph-workorder-high {
|
||||
background-color: #f8d7da !important; /* A slightly more intense red for high priority issues */
|
||||
}
|
||||
|
||||
/*
|
||||
* Wohneinheit Manager - Dense Table Layout
|
||||
*/
|
||||
.wohneinheit-manager .we-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-row {
|
||||
display: table-row;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-cell {
|
||||
display: table-cell;
|
||||
padding: 8px 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-bezeichner {
|
||||
width: 25%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-status {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-note {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.wohneinheit-manager .we-actions {
|
||||
width: 15%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
padding-left: 4px;
|
||||
border-left: 2px solid #007bff;
|
||||
}
|
||||
|
||||
.workorder-mph-button {
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom Checkboxes - Compact & Beautiful
|
||||
*/
|
||||
.custom-checkboxes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.custom-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.custom-checkbox-item:hover:not(.disabled) {
|
||||
background: #e9ecef;
|
||||
border-color: #007bff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.custom-checkbox-item.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.custom-checkbox-item input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-checkbox-item .checkmark {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
background-color: #fff;
|
||||
border: 2px solid #adb5bd;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.custom-checkbox-item .checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 6px;
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-checkbox-item .checkbox-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkbox-label {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/*
|
||||
* Required Documents Checklist
|
||||
*/
|
||||
.required-docs-checklist {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.doc-check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 4px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-check-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.doc-check-item i:first-child {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-check-item span {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-check-item .ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
543
public/js/pages/WorkorderMphBase/WorkorderMphBase.js
Normal file
543
public/js/pages/WorkorderMphBase/WorkorderMphBase.js
Normal file
@@ -0,0 +1,543 @@
|
||||
// WorkorderMphBase.js - Shared components for WorkorderMph module
|
||||
|
||||
// Traffic light component (reused from WorkorderBase)
|
||||
Vue.component('traffic-light-mph', {
|
||||
props: ['deadline', 'status'],
|
||||
computed: {
|
||||
lightInfo() {
|
||||
const deadlineDate = moment.unix(this.deadline);
|
||||
const daysLeft = deadlineDate.diff(moment(), 'days');
|
||||
|
||||
if (['completed', 'new', 'cancelled'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
|
||||
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
|
||||
if (deadlineDate.isBefore(moment())) return { color: '#dc3545', title: 'Deadline überschritten' };
|
||||
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>`
|
||||
});
|
||||
|
||||
// Wohneinheit Status Manager Component
|
||||
Vue.component('wohneinheit-status-manager', {
|
||||
props: {
|
||||
workorderMphId: { type: Number, required: true },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
},
|
||||
template: `
|
||||
<div class="card wohneinheit-manager">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-building mr-2"></i>Wohneinheiten Status</h5>
|
||||
</div>
|
||||
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||
<div v-else class="card-body p-0">
|
||||
<div v-if="wohneinheiten.length === 0" class="alert alert-info m-3">
|
||||
Keine Wohneinheiten gefunden.
|
||||
</div>
|
||||
<div v-else class="we-table">
|
||||
<div v-for="we in wohneinheiten" :key="we.wohneinheitId" class="we-row">
|
||||
<div class="we-cell we-bezeichner">
|
||||
<strong>{{ we.bezeichner }}</strong>
|
||||
<div v-if="we.contact" class="contact-info text-muted">
|
||||
<i class="fas fa-user mr-1"></i>{{ we.contact }}
|
||||
</div>
|
||||
<div v-else class="text-muted small fst-italic">
|
||||
<i class="fas fa-user-slash mr-1"></i>Keine Kontaktinfo
|
||||
</div>
|
||||
</div>
|
||||
<div class="we-cell we-status">
|
||||
<tt-select v-model="we.status" :options="statusOptions" sm no-form-group
|
||||
:disabled="isAdmin" @input="markAsChanged(we)"/>
|
||||
</div>
|
||||
<div class="we-cell we-note">
|
||||
<tt-textarea v-model="we.note" sm rows="1" no-form-group
|
||||
:disabled="isAdmin"
|
||||
@input="markAsChanged(we)"
|
||||
:placeholder="isAdmin ? 'Keine Notiz' : 'Pflichtfeld'"/>
|
||||
</div>
|
||||
<div class="we-cell we-actions">
|
||||
<span v-if="we.changed" class="text-warning small">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
<tt-button v-if="!isAdmin" @click="saveWohneinheit(we)" text="Speichern" sm
|
||||
:loading="we.saving" :disabled="!we.changed"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data: () => ({
|
||||
loading: true,
|
||||
wohneinheiten: [],
|
||||
statusOptions: []
|
||||
}),
|
||||
methods: {
|
||||
async fetchWohneinheiten() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
|
||||
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheiten`, {
|
||||
params: { workorderMphId: this.workorderMphId }
|
||||
});
|
||||
this.wohneinheiten = data.wohneinheiten.map(we => ({ ...we, changed: false, saving: false }));
|
||||
this.statusOptions = data.statusOptions || [];
|
||||
} catch (e) {
|
||||
window.notify('error', 'Wohneinheiten konnten nicht geladen werden.');
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
markAsChanged(we) {
|
||||
we.changed = true;
|
||||
},
|
||||
async saveWohneinheit(we) {
|
||||
if (!we.note || !we.note.trim()) {
|
||||
return window.notify('error', 'Bitte eine Notiz eingeben.');
|
||||
}
|
||||
|
||||
we.saving = true;
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateWohneinheit`, {
|
||||
workorderMphId: this.workorderMphId,
|
||||
wohneinheitId: we.wohneinheitId,
|
||||
status: we.status,
|
||||
note: we.note
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
we.changed = false;
|
||||
this.$emit('wohneinheit-updated');
|
||||
} else {
|
||||
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
} finally {
|
||||
we.saving = false;
|
||||
}
|
||||
},
|
||||
getStatusText(statusValue) {
|
||||
const option = this.statusOptions.find(opt => opt.value === statusValue);
|
||||
return option ? option.text : '';
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchWohneinheiten();
|
||||
}
|
||||
});
|
||||
|
||||
// Checkbox Documentation Component
|
||||
Vue.component('checkbox-documentation', {
|
||||
props: {
|
||||
workorderMphId: { type: Number, required: true },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
},
|
||||
template: `
|
||||
<div class="checkbox-docs card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<h5 class="card-title mb-3"><i class="fas fa-clipboard-check mr-2"></i>Dokumentation Checkboxen</h5>
|
||||
<div v-if="loading" class="text-center p-3"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
<div v-else>
|
||||
<div class="custom-checkboxes-grid">
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.easement" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">Leitungsrecht</span>
|
||||
</label>
|
||||
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.btb" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">BTB</span>
|
||||
</label>
|
||||
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.fttxLocationSupplied" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">FTTx Location mit Leerrohr versorgt</span>
|
||||
</label>
|
||||
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.conduitToHuepLaid" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">Leerrohr bis HÜP/HAK verlegt</span>
|
||||
</label>
|
||||
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.huepMounted" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">HÜP/HAK montiert</span>
|
||||
</label>
|
||||
|
||||
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
|
||||
<input type="checkbox" v-model="checkboxes.dropCableAvailable" :disabled="isAdmin">
|
||||
<span class="checkmark"></span>
|
||||
<span class="checkbox-label">Dropkabel vorhanden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 text-right" v-if="!isAdmin">
|
||||
<tt-button @click="saveCheckboxes" text="Speichern"
|
||||
:loading="saving" additional-class="btn-primary btn-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data: () => ({
|
||||
loading: true,
|
||||
saving: false,
|
||||
checkboxes: {
|
||||
easement: false,
|
||||
btb: false,
|
||||
fttxLocationSupplied: false,
|
||||
conduitToHuepLaid: false,
|
||||
huepMounted: false,
|
||||
dropCableAvailable: false
|
||||
}
|
||||
}),
|
||||
methods: {
|
||||
async fetchCheckboxes() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/getWorkorderById`, {
|
||||
params: { id: this.workorderMphId }
|
||||
});
|
||||
this.checkboxes = {
|
||||
easement: !!data.easement,
|
||||
btb: !!data.btb,
|
||||
fttxLocationSupplied: !!data.fttxLocationSupplied,
|
||||
conduitToHuepLaid: !!data.conduitToHuepLaid,
|
||||
huepMounted: !!data.huepMounted,
|
||||
dropCableAvailable: !!data.dropCableAvailable
|
||||
};
|
||||
} catch (e) {
|
||||
window.notify('error', 'Checkboxen konnten nicht geladen werden.');
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async saveCheckboxes() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateCheckboxes`, {
|
||||
workorderMphId: this.workorderMphId,
|
||||
...this.checkboxes
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$emit('checkboxes-updated');
|
||||
} else {
|
||||
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchCheckboxes();
|
||||
}
|
||||
});
|
||||
|
||||
// WorkorderMph Details Manager
|
||||
Vue.component('workorder-mph-details-manager', {
|
||||
props: {
|
||||
workorderMphId: { type: String, required: true },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
docs: [],
|
||||
journals: [],
|
||||
newJournalMessage: '',
|
||||
addingJournalEntry: false,
|
||||
uploading: false,
|
||||
completing: false,
|
||||
showCompleteModal: false,
|
||||
showAcceptModal: false,
|
||||
uploadData: { files: [], documentType: '', description: '' },
|
||||
wohneinheitenWithNotes: true,
|
||||
requiredDocs: [
|
||||
{ key: 'huep_photo', label: 'HÜP/HAK Foto', icon: 'fas fa-camera', example: 'Foto der installierten HÜP/HAK' },
|
||||
{ key: 'bep_md_photo', label: 'BEP MD Foto', icon: 'fas fa-camera', example: 'Foto der BEP (MD) Installation' },
|
||||
{ key: 'ont_photo', label: 'ONT Foto', icon: 'fas fa-camera', example: 'Foto der ONT Installation' },
|
||||
{ key: 'cable_routing', label: 'Kabelverlegung', icon: 'fas fa-route', example: 'Fotos der Kabelverlegung im Treppenhaus' },
|
||||
{ key: 'fttx_location', label: 'FTTx Location', icon: 'fas fa-map-marker-alt', example: 'Foto/Dokument der FTTx Location' },
|
||||
{ key: 'signature', label: 'Unterschrift', icon: 'fas fa-signature', example: 'Unterschriebenes Übergabeprotokoll' },
|
||||
{ key: 'other', label: 'Sonstige Dokumentation', icon: 'fas fa-file', example: 'Weitere relevante Dokumente' }
|
||||
]
|
||||
}),
|
||||
template: `
|
||||
<div class="p-3 bg-light">
|
||||
<div v-if="loading" 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-5 mb-3 mb-lg-0">
|
||||
<div v-if="!isAdmin" class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Auftrag abschließen</h5>
|
||||
<p class="small text-muted">Dokumentieren Sie alle Wohneinheiten und laden Sie die erforderlichen Dokumente hoch.</p>
|
||||
<hr>
|
||||
<tt-button text="Auftrag zur Prüfung einreichen" @click="showCompleteModal = true"
|
||||
:disabled="!canComplete || isReadOnly" :loading="completing"
|
||||
additional-class="btn-success w-100" icon="fas fa-check-double"/>
|
||||
<small v-if="!canComplete && !isReadOnly" class="form-text text-muted text-center mt-2">
|
||||
Bitte fügen Sie für jede Wohneinheit eine Notiz hinzu und laden Sie Dokumente hoch.
|
||||
</small>
|
||||
<div v-if="isReadOnly" class="alert alert-secondary text-center mt-2 p-2">
|
||||
Auftrag bereits abgeschlossen oder storniert.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="card mb-3">
|
||||
<div class="card-body p-3">
|
||||
<h5 class="card-title mb-3">Prüfung & Freigabe</h5>
|
||||
<p class="small text-muted mb-3">Prüfen Sie die hochgeladenen Dokumente:</p>
|
||||
|
||||
<div class="required-docs-checklist mb-3">
|
||||
<div v-for="doc in requiredDocs" :key="doc.key" class="doc-check-item">
|
||||
<i :class="[doc.icon, hasDocType(doc.key) ? 'text-success' : 'text-muted']"></i>
|
||||
<span :class="{ 'text-success': hasDocType(doc.key) }">{{ doc.label }}</span>
|
||||
<i v-if="hasDocType(doc.key)" class="fas fa-check-circle text-success ml-auto"></i>
|
||||
<i v-else class="fas fa-circle text-muted ml-auto" style="font-size: 0.8em;"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small class="text-muted d-block mb-3">
|
||||
<i class="fas fa-info-circle"></i> Stellen Sie sicher, dass alle relevanten Dokumente vorhanden sind.
|
||||
</small>
|
||||
|
||||
<tt-button text="Dokumentation akzeptieren" @click="showAcceptModal = true"
|
||||
additional-class="btn-success w-100" icon="fas fa-check"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h5><i class="fas fa-history mr-2"></i>Journal</h5></div>
|
||||
<div class="card-body p-0" style="max-height: 250px; overflow-y: auto;">
|
||||
<ul v-if="journals.length" class="list-group list-group-flush">
|
||||
<li v-for="log in journals" :key="log.id" class="list-group-item small">
|
||||
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
|
||||
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="card-body text-muted text-center">Keine Journaleinträge.</div>
|
||||
</div>
|
||||
<div class="card-footer" v-if="!isReadOnly">
|
||||
<tt-textarea v-model="newJournalMessage" placeholder="Nachricht oder Anmerkung..." rows="2"/>
|
||||
<tt-button text="Eintrag speichern" @click="addJournalEntry" :loading="addingJournalEntry"
|
||||
additional-class="btn-info btn-sm w-100 mt-2" icon="fas fa-paper-plane"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card mb-3" v-if="!isReadOnly">
|
||||
<div class="card-body p-3">
|
||||
<h5 class="card-title mb-3">Neues Dokument hochladen</h5>
|
||||
|
||||
<div class="form-group row mb-2">
|
||||
<label class="col-form-label col-sm-4 col-form-label-sm">Dokumententyp*</label>
|
||||
<div class="col-sm-8">
|
||||
<select v-model="uploadData.documentType" class="form-control form-control-sm" required>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
<option v-for="doc in requiredDocs" :key="doc.key" :value="doc.key">
|
||||
{{ doc.label }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="uploadData.documentType" class="form-text text-muted">
|
||||
<i class="fas fa-info-circle"></i> {{ getDocExample(uploadData.documentType) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row mb-2">
|
||||
<label class="col-form-label col-sm-4 col-form-label-sm">Beschreibung</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" v-model="uploadData.description" class="form-control form-control-sm"
|
||||
placeholder="Optional: Zusätzliche Beschreibung"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row mb-3">
|
||||
<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 accept="image/*,.pdf"/>
|
||||
<small class="form-text text-muted">Erlaubt: Bilder (JPG, PNG) und PDF</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading"
|
||||
:disabled="!uploadData.documentType || !uploadData.files.length"
|
||||
additional-class="btn-primary btn-sm" icon="fas fa-upload"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tt-file-gallery :files="docs" :edit-mode="false" :delete-mode="!isReadOnly && !isAdmin"
|
||||
@delete-file="deleteDocumentation">
|
||||
</tt-file-gallery>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tt-modal :show.sync="showCompleteModal" title="Auftrag abschließen" @submit="completeWorkorder" :delete="false">
|
||||
Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?
|
||||
</tt-modal>
|
||||
<tt-modal :show.sync="showAcceptModal" title="Dokumentation akzeptieren" @submit="acceptDocumentation" :delete="false">
|
||||
Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?
|
||||
</tt-modal>
|
||||
</div>
|
||||
`,
|
||||
computed: {
|
||||
isReadOnly() {
|
||||
return ['completed', 'cancelled'].includes(this.workorder?.status);
|
||||
},
|
||||
canComplete() {
|
||||
return this.wohneinheitenWithNotes && this.docs.length > 0;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate(timestamp) {
|
||||
return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : '–';
|
||||
},
|
||||
hasDocType(docType) {
|
||||
return this.docs.some(doc => doc.documentType === docType);
|
||||
},
|
||||
getDocExample(docType) {
|
||||
const doc = this.requiredDocs.find(d => d.key === docType);
|
||||
return doc ? doc.example : '';
|
||||
},
|
||||
async fetchData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
|
||||
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getDocumentation`, {
|
||||
params: { workorderMphId: this.workorderMphId }
|
||||
});
|
||||
this.docs = data.docs || [];
|
||||
this.journals = data.journals || [];
|
||||
} catch (e) {
|
||||
window.notify('error', 'Details konnten nicht geladen werden.');
|
||||
this.docs = [];
|
||||
this.journals = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async addJournalEntry() {
|
||||
if (!this.newJournalMessage.trim()) return window.notify('error', 'Bitte eine Nachricht eingeben.');
|
||||
|
||||
this.addingJournalEntry = true;
|
||||
try {
|
||||
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/addJournal`, {
|
||||
workorderMphId: this.workorderMphId,
|
||||
text: this.newJournalMessage
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.journals = data.journals || [];
|
||||
this.newJournalMessage = '';
|
||||
} else {
|
||||
window.notify('error', data.message || 'Eintrag fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
} finally {
|
||||
this.addingJournalEntry = false;
|
||||
}
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
this.uploadData.files = event.target.files;
|
||||
},
|
||||
async uploadFiles() {
|
||||
if (!this.uploadData.files?.length) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.');
|
||||
|
||||
this.uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('workorderMphId', this.workorderMphId);
|
||||
formData.append('documentType', this.uploadData.documentType);
|
||||
formData.append('description', this.uploadData.description);
|
||||
for (const file of this.uploadData.files) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/uploadDocumentation`, formData);
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.fileInput.value = '';
|
||||
this.uploadData = { files: [], documentType: 'photo', description: '' };
|
||||
await this.fetchData();
|
||||
} else {
|
||||
window.notify('error', data.error || 'Upload fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
async deleteDocumentation(file) {
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/deleteDocumentation`, {
|
||||
documentationId: file.id
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
await this.fetchData();
|
||||
} else {
|
||||
window.notify('error', data.message || 'Löschen fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler beim Löschen.');
|
||||
}
|
||||
},
|
||||
async completeWorkorder() {
|
||||
this.completing = true;
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, {
|
||||
workorderId: this.workorderMphId
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$emit('workorder-completed');
|
||||
this.showCompleteModal = false;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Abschluss fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
} finally {
|
||||
this.completing = false;
|
||||
}
|
||||
},
|
||||
async acceptDocumentation() {
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/acceptDocumentation`, {
|
||||
workorderId: this.workorderMphId
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$emit('documentation-accepted');
|
||||
this.showAcceptModal = false;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Akzeptieren fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchData();
|
||||
}
|
||||
});
|
||||
237
public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js
Normal file
237
public/js/pages/WorkorderMphCompany/WorkorderMphCompany.js
Normal file
@@ -0,0 +1,237 @@
|
||||
// WorkorderMphCompany.js
|
||||
Vue.component('workorder-mph-company', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<tt-table-crud ref="table" :crud-config="crudConfig">
|
||||
<template v-slot:hausnummerinfo="{ row }">
|
||||
<div class="small">
|
||||
<div><strong>Adresse:</strong> {{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template></div>
|
||||
<div><strong>Ort:</strong> {{ row.plz }} {{ row.city }}</div>
|
||||
<div><strong>Wohneinheiten:</strong> {{ row.wohneinheitCount }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<traffic-light-mph :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 }">
|
||||
<div v-if="editingAppointmentId === row.id">
|
||||
<tt-date-picker :value="row.appointmentDate" :date-range="false" time-picker
|
||||
@input="scheduleAppointment(row, $event)" @blur="editingAppointmentId = null"
|
||||
sm no-form-group/>
|
||||
</div>
|
||||
<div v-else class="d-flex align-items-center">
|
||||
<span>{{ formatDate(row.appointmentDate, true) }}</span>
|
||||
<tt-button v-if="canSchedule(row)" icon="fas fa-edit"
|
||||
@click="editingAppointmentId = row.id"
|
||||
additional-class="btn-link btn-sm p-0 ml-2" title="Termin planen"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:additionalinfo="{ row }">
|
||||
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div class="row">
|
||||
<!-- Action Buttons -->
|
||||
<div class="col-12 mb-3" v-if="!['completed', 'cancelled', 'documented'].includes(row.status)">
|
||||
<div class="btn-group" role="group">
|
||||
<tt-button v-if="row.status === 'assigned'" text="Termin planen"
|
||||
@click="editingAppointmentId = row.id" icon="fas fa-calendar-plus"
|
||||
additional-class="btn-primary"/>
|
||||
<tt-button v-if="row.status === 'scheduled'" text="Arbeit beginnen"
|
||||
@click="startWork(row)" icon="fas fa-play" additional-class="btn-success"/>
|
||||
<tt-button v-if="row.status === 'scheduled'" text="Termin verschieben"
|
||||
@click="openRescheduleModal(row)" icon="fas fa-calendar-alt"
|
||||
additional-class="btn-warning"/>
|
||||
<tt-button v-if="row.status === 'in_progress'" text="Auftrag abschließen"
|
||||
@click="openCompleteModal(row)" icon="fas fa-check-double"
|
||||
additional-class="btn-success"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wohneinheit Manager -->
|
||||
<div class="col-12">
|
||||
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="false"
|
||||
@wohneinheit-updated="checkAllWohneinheitenHaveNotes(row.id)"/>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox Documentation -->
|
||||
<div class="col-12 mt-3">
|
||||
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="false"/>
|
||||
</div>
|
||||
|
||||
<!-- Details Manager (Docs & Journal) -->
|
||||
<div class="col-12 mt-3">
|
||||
<workorder-mph-details-manager :workorder-mph-id="row.id" :is-admin="false"
|
||||
@workorder-completed="$refs.table.$refs.table.refreshTable()"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tt-table-crud>
|
||||
|
||||
<tt-modal v-if="rescheduleModalData" :show.sync="rescheduleModalData"
|
||||
title="Termin verschieben" @submit="rescheduleAppointment">
|
||||
<p>Aktueller Termin: <strong>{{ formatDate(rescheduleModalData.currentDate, true) }}</strong></p>
|
||||
<tt-date-picker label="Neuer Termin" v-model="rescheduleModalData.newDate"
|
||||
:date-range="false" time-picker sm row required/>
|
||||
<tt-textarea label="Grund" v-model="rescheduleModalData.reason" sm row required/>
|
||||
</tt-modal>
|
||||
|
||||
<tt-modal v-if="completeModalData" :show.sync="completeModalData"
|
||||
title="Auftrag abschließen" @submit="completeWorkorder" :delete="false">
|
||||
<p>Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?</p>
|
||||
<div class="alert alert-warning small">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Bitte stellen Sie sicher, dass alle Wohneinheiten dokumentiert sind und alle erforderlichen Dokumente hochgeladen wurden.
|
||||
</div>
|
||||
</tt-modal>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window,
|
||||
editingAppointmentId: null,
|
||||
rescheduleModalData: null,
|
||||
completeModalData: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
selectable: false,
|
||||
expandable: true,
|
||||
customRowClass: (row) => {
|
||||
if (['completed', 'new', 'cancelled'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
|
||||
const deadlineDate = moment.unix(row.deadlineDate);
|
||||
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
|
||||
const daysLeft = deadlineDate.diff(moment(), 'days');
|
||||
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
|
||||
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
|
||||
return 'tt-mph-workorder-ontrack';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp, withTime = false) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
|
||||
},
|
||||
canSchedule(row) {
|
||||
return ['assigned', 'scheduled'].includes(row.status);
|
||||
},
|
||||
async scheduleAppointment(row, newDate) {
|
||||
if (!newDate) {
|
||||
this.editingAppointmentId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const hour = parseInt(moment.unix(newDate).format('H'));
|
||||
if (hour >= 23 || hour < 1) {
|
||||
window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/scheduleAppointment`, {
|
||||
workorderId: row.id,
|
||||
appointmentDate: newDate
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
} else {
|
||||
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler.');
|
||||
} finally {
|
||||
this.editingAppointmentId = null;
|
||||
}
|
||||
},
|
||||
openRescheduleModal(row) {
|
||||
this.rescheduleModalData = {
|
||||
workorderId: row.id,
|
||||
currentDate: row.appointmentDate,
|
||||
newDate: null,
|
||||
reason: ''
|
||||
};
|
||||
},
|
||||
async rescheduleAppointment() {
|
||||
if (!this.rescheduleModalData.newDate || !this.rescheduleModalData.reason) {
|
||||
return window.notify('error', 'Bitte füllen Sie alle Felder aus.');
|
||||
}
|
||||
|
||||
const hour = parseInt(moment.unix(this.rescheduleModalData.newDate).format('H'));
|
||||
if (hour >= 23 || hour < 1) {
|
||||
window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/rescheduleAppointment`, {
|
||||
workorderId: this.rescheduleModalData.workorderId,
|
||||
appointmentDate: this.rescheduleModalData.newDate,
|
||||
reason: this.rescheduleModalData.reason
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
this.rescheduleModalData = null;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler.');
|
||||
}
|
||||
},
|
||||
async startWork(row) {
|
||||
if (!confirm(`Möchten Sie mit der Arbeit an Auftrag #${row.id} beginnen?`)) return;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/startWork`, {
|
||||
workorderId: row.id
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
} else {
|
||||
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Netzwerkfehler.');
|
||||
}
|
||||
},
|
||||
openCompleteModal(row) {
|
||||
this.completeModalData = { workorderId: row.id };
|
||||
},
|
||||
async completeWorkorder() {
|
||||
try {
|
||||
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, {
|
||||
workorderId: this.completeModalData.workorderId
|
||||
});
|
||||
if (data.success) {
|
||||
window.notify('success', data.message);
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
this.completeModalData = null;
|
||||
} else {
|
||||
window.notify('error', data.message || 'Abschluss fehlgeschlagen.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
}
|
||||
},
|
||||
async checkAllWohneinheitenHaveNotes(workorderId) {
|
||||
// This is called when a wohneinheit is updated
|
||||
// Could be used to enable/disable the complete button
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user