10 Commits
Author SHA1 Message Date
Daniel Spitzer 7010e15f16 Merge branch 'spidev' into 'master'
Pop Erweiterung Adressen nun mit Straßenschlüssel

See merge request fronk/thetool!2105
2026-03-01 19:06:59 +00:00
Daniel Spitzer 6312f6657b Pop Erweiterung Adressen nun mit Straßenschlüssel
* Bugfix filtern und sortieren auf Adressenbasis funktioniert nun
2026-03-01 20:06:25 +01:00
Luca Haid 7e12c6ff1d Merge branch 'feat/workorder-notiz-ui-improvements' into 'master'
feat: improve notiz column UI and fix memory exhaustion

See merge request fronk/thetool!2104
2026-02-27 14:42:14 +00:00
LucaandClaude Opus 4.6 6593e921ee feat: improve notiz column UI and fix memory exhaustion
Notiz column now has max 5 lines with expand/collapse animation and auto-resizing edit textarea. Moved raw SQL query to WorkorderModel::getPreorderIdsByCampaigns() to fix OOM in archiveWorkorders().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:35:17 +01:00
LucaandClaude Sonnet 4.6 a53ff3912c fix: fix popup blocker and Select2 error when prefilling calendar
- Open window synchronously before await to avoid popup blocker,
  then navigate it to /Calendar/View after successful save; close
  it if the save fails
- Guard the #customer Select2 change handler so it bails early
  when #customer has not been initialized as Select2 yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:46:19 +01:00
LucaandClaude Sonnet 4.6 daf1ce3401 feat: prefill calendar form when setting workorder appointment
When a WorkorderCompany user sets or reschedules an appointment date,
the calendar is now opened in a new tab pre-filled with workorder data
(subject, type, location, times, description, customer contact, Pusnik
as calendar user, Ziga Harc as attendee).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:32:00 +01:00
Luca Haid ee3f4694c2 re added FCP prepaired 2026-02-27 12:30:59 +00:00
Luca be9e6ae4e5 feat: clean up and optimize FCP filter and highlighting logic 2026-02-27 12:36:06 +01:00
Luca ad8c6eaeca feat: FCP filter counts now respect all active search filters 2026-02-27 12:36:06 +01:00
Luca b4f3c4260a fix: correct syntax error in FCP sorting callback 2026-02-27 12:36:06 +01:00
11 changed files with 408 additions and 60 deletions
+66 -26
View File
@@ -117,8 +117,47 @@ if (isset($_GET['returnto']) && $_GET['returnto'] == "pop-detail") {
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label">Adresssuche</label>
<div class="col-lg-8">
<?php
$addrDisplay = '';
if ($pop->street) {
$addrDisplay = $pop->street;
if ($pop->number) $addrDisplay .= ' ' . $pop->number;
if ($pop->zip || $pop->city) $addrDisplay .= ', ' . trim($pop->zip . ' ' . $pop->city);
}
?>
<select class="form-control" id="address_search">
<?php if($addrDisplay): ?>
<option value="preset" selected><?= htmlspecialchars($addrDisplay) ?></option>
<?php endif; ?>
</select>
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="zip">Adresse</label>
<div class="col-lg-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Straße</span>
</div>
<input type="text" class="form-control" name="street" id="street"
value="<?= $pop->street ?>">
</div>
</div>
<div class="col-lg-1">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Nr.</span>
</div>
<input type="text" class="form-control" name="number" id="number"
value="<?= $pop->number ?>">
</div>
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="zip"></label>
<div class="col-lg-1">
<div class="input-group">
<div class="input-group-prepend">
@@ -139,32 +178,7 @@ if (isset($_GET['returnto']) && $_GET['returnto'] == "pop-detail") {
</div>
</div>
<div class="form-group row">
<!-- <label class="col-lg-2 col-form-label" for="street">Straße</label>-->
<!-- <div class="col-lg-3">-->
<!-- <input type="text" class="form-control" name="street" id="street"-->
<!-- value="--><?php //= $pop->street ?><!--">-->
<!-- </div>-->
<label class="col-lg-2 col-form-label" for=""></label>
<div class="col-lg-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Straße</span>
</div>
<input type="text" class="form-control" name="street" id="street"
value="<?= $pop->street ?>">
</div>
</div>
<div class="col-lg-1">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Nr.</span>
</div>
<input type="text" class="form-control" name="number" id="number"
value="<?= $pop->number ?>">
</div>
</div>
</div>
</div>
</div>
@@ -280,6 +294,32 @@ if (isset($_GET['returnto']) && $_GET['returnto'] == "pop-detail") {
});
// combined address search with select2
$('#address_search').select2({
placeholder: 'Straße Hausnummer, PLZ Ort eingeben...',
allowClear: true,
minimumInputLength: 2,
ajax: {
url: "<?=self::getUrl('Pop','api')?>",
dataType: 'json',
delay: 300,
data: function(params) { return { do: 'searchAddress', q: params.term }; },
processResults: function(data) { return { results: data.results || [] }; }
}
});
$('#address_search').on('select2:select', function(e) {
var d = e.params.data;
$('#street').val(d.street || '');
$('#number').val(d.number || '');
$('#zip').val(d.zip || '');
$('#city').val(d.city || '');
});
$('#address_search').on('select2:clear', function() {
$('#street, #number, #zip, #city').val('');
});
// network_id // owner_id
// if get parameter network_id is set, select it if found
const urlParams = new URLSearchParams(window.location.search);
+6 -4
View File
@@ -973,7 +973,7 @@ $pagination_entity_name = "Vorbestellungen";
}
}
$requiredFlagIds = [3, 4];
$requiredFlagIds = [3, 4, 5];
$allFlagsChecked = true;
foreach ($requiredFlagIds as $flagId) {
@@ -2505,7 +2505,11 @@ $pagination_entity_name = "Vorbestellungen";
fcpSelect.empty().select2({ data: [], placeholder: "Bitte Kampagne auswählen", allowClear: true });
return;
}
$.get(apiUrl, { do: "getFCPsForCampaign", campaign_id: campaign_id }, (success) => {
const filterData = $(this).closest("form").serializeArray().filter(item => item.name.startsWith("filter[") && !item.name.includes("fcp"));
const params = { do: "getFCPsForCampaign", campaign_id: campaign_id };
filterData.forEach(item => { params[item.name] = item.value; });
$.get(apiUrl, params, (success) => {
let fcpData = [];
let opts = { data: [], placeholder: "Bitte Kampagne auswählen", allowClear: true };
if (success?.status === "OK" && Array.isArray(success.result)) {
@@ -2520,11 +2524,9 @@ $pagination_entity_name = "Vorbestellungen";
fcpData.sort((a, b) => {
if (a.id === "") return -1;
if (b.id === "") return 1;
// Sort by preorder_count descending
if ((b.preorder_count || 0) !== (a.preorder_count || 0)) {
return (b.preorder_count || 0) - (a.preorder_count || 0);
}
// Fallback to name-based numeric/alpha sort
const aN = a.id.replace(/\D/g, ""), bN = b.id.replace(/\D/g, "");
return aN && bN ? parseInt(aN, 10) - parseInt(bN, 10) : a.id.localeCompare(b.id);
});
+96
View File
@@ -56,6 +56,7 @@ class PopController extends mfBaseController
"city" => $pop->city,
"street" => $pop->street,
"number" => $pop->number,
"address" => trim($pop->street . ' ' . $pop->number . ', ' . $pop->zip . ' ' . $pop->city, ', '),
"state" => $pop->state,
"folder_link" => $pop->folder_link,
"doku_date" => $pop->doku_date,
@@ -1411,6 +1412,8 @@ class PopController extends mfBaseController
case "getSplicePlanForElement":
return $this->getSplicePlanForElementAction();
break;
case "searchAddress":
return $this->searchAddressApi();
default:
$return = false;
}
@@ -1965,4 +1968,97 @@ class PopController extends mfBaseController
'connections' => $result
]));
}
private function searchAddressApi()
{
$q = trim($this->request->q);
if (strlen($q) < 2) {
$this->returnJson(["results" => []]);
}
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
// split input into parts, any order allowed: "graz alte 5" or "Alte Poststraße 5, 8010 Graz"
$parts = preg_split('/[\s,]+/', $q, -1, PREG_SPLIT_NO_EMPTY);
// categorize: 4-digit = PLZ, short number = hausnummer, text = street or city
$text_parts = [];
$hausnummer = null;
$plz = null;
foreach ($parts as $part) {
if (preg_match('/^\d{4}$/', $part)) {
$plz = $part;
} elseif ($hausnummer === null && preg_match('/^\d+[a-zA-Z]?$/', $part)) {
$hausnummer = $part;
} else {
$text_parts[] = $part;
}
}
// Pre-query Gemeinde (tiny table, instant) to resolve text→gemeinde_id
$gemeinde_ids_per_part = [];
foreach ($text_parts as $tp) {
$escaped = $db->escape($tp);
$gres = $db->query("SELECT id FROM Gemeinde WHERE name LIKE '%$escaped%'");
$ids = [];
if ($gres && $db->num_rows($gres)) {
while ($grow = $db->fetch_object($gres)) {
$ids[] = (int)$grow->id;
}
}
$gemeinde_ids_per_part[$tp] = $ids;
}
// Build conditions: text parts match Strasse.name OR pre-resolved gemeinde_id (uses index)
$conditions = [];
foreach ($text_parts as $tp) {
$escaped = $db->escape($tp);
if (!empty($gemeinde_ids_per_part[$tp])) {
$ids_str = implode(',', $gemeinde_ids_per_part[$tp]);
$conditions[] = "(Strasse.name LIKE '%$escaped%' OR Strasse.gemeinde_id IN ($ids_str))";
} else {
$conditions[] = "Strasse.name LIKE '%$escaped%'";
}
}
if ($hausnummer !== null) {
$hn = $db->escape($hausnummer);
$conditions[] = "Hausnummer.hausnummer LIKE '$hn%'";
}
if ($plz !== null) {
$pz = $db->escape($plz);
$conditions[] = "Plz.plzstring LIKE '$pz%'";
}
$where = count($conditions) ? implode(' AND ', $conditions) : '1=0';
$sql = "SELECT Strasse.name as strasse, Hausnummer.hausnummer, Plz.plz, Gemeinde.name as gemeinde
FROM Strasse
STRAIGHT_JOIN Hausnummer ON (Hausnummer.strasse_id = Strasse.id)
LEFT JOIN Plz ON (Plz.id = Hausnummer.plz_id)
LEFT JOIN Gemeinde ON (Gemeinde.id = Strasse.gemeinde_id)
WHERE $where
GROUP BY Strasse.name, Hausnummer.hausnummer, Plz.plz, Gemeinde.name
ORDER BY Strasse.name, Hausnummer.hausnummer + 0, Plz.plz
LIMIT 25";
$results = [];
$res = $db->query($sql);
if ($db->num_rows($res)) {
while ($row = $db->fetch_object($res)) {
$text = $row->strasse;
if ($row->hausnummer) $text .= ' ' . $row->hausnummer;
if ($row->plz || $row->gemeinde) $text .= ', ' . trim($row->plz . ' ' . $row->gemeinde);
$results[] = [
"id" => $text,
"text" => $text,
"street" => $row->strasse,
"number" => $row->hausnummer,
"zip" => $row->plz,
"city" => $row->gemeinde,
];
}
}
$this->returnJson(["results" => $results]);
}
}
+8 -7
View File
@@ -1451,13 +1451,14 @@ class PreorderController extends mfBaseController {
$fcps = ADBRimoFcp::getAll(["netzgebiet_id" => intval($campaign->network->adb_netzgebiet_id)]) ?? [];
if (empty($fcps)) return [];
$fcpIds = array_map(fn($fcp) => $fcp->id, $fcps);
$stats = ADBRimoFcp::getRimoFcpStatistics($fcpIds);
$statsMap = [];
foreach ($stats as $stat) {
$statsMap[$stat['fcp_id']] = $stat['total_active_preorders'];
$filter = $this->request->filter ?? [];
if (isset($filter['fcp'])) {
unset($filter['fcp']);
}
$filter = $this->getPreparedFilter($filter);
$statsMap = PreorderModel::countActiveGroupedByFcp($filter);
$result = array_map(
fn($fcp) => [
"real_id" => $fcp->id,
@@ -1465,12 +1466,12 @@ class PreorderController extends mfBaseController {
"text" => $fcp->name ?? null,
'lat' => $fcp->gps_lat ?? null,
'lng' => $fcp->gps_long ?? null,
'preorder_count' => $statsMap[$fcp->id] ?? 0
'preorder_count' => $statsMap[$fcp->name] ?? 0
],
$fcps
);
usort($result, fn($a, b) => $b['preorder_count'] <=> $a['preorder_count']);
usort($result, fn($a, $b) => $b['preorder_count'] <=> $a['preorder_count']);
return $result;
}
+35
View File
@@ -482,6 +482,41 @@ class PreorderModel
return self::count($filter);
}
public static function countActiveGroupedByFcp($filter = [])
{
if (!is_array($filter)) return false;
if (!array_key_exists("deleted", $filter)) {
$filter["deleted"] = null;
}
if (!array_key_exists("<status_code", $filter) && !array_key_exists(">status_code", $filter) && !array_key_exists("status_code", $filter)
&& !array_key_exists("<status_id", $filter) && !array_key_exists(">status_id", $filter) && !array_key_exists("status_id", $filter)) {
$filter["<status_code"] = 899;
$filter["!status_code"] = 20;
}
$db = FronkDB::singleton();
$where = self::getSqlFilter($filter);
$sql = "SELECT hn.rimo_fcp_name, COUNT(*) as cnt FROM `" . FRONKDB_DBNAME . "`.Preorder tt_preorder
LEFT JOIN `" . FRONKDB_DBNAME . "`.Preorderstatus tt_preorderstatus ON (tt_preorder.status_id = tt_preorderstatus.id)
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.view_hausnummer as adb_hausnummer ON (tt_preorder.adb_hausnummer_id = adb_hausnummer.hausnummer_id)
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Hausnummer as hn ON (adb_hausnummer.hausnummer_id = hn.id)
LEFT JOIN `" . ADDRESSDB_DBNAME . "`.Wohneinheit as adb_wohneinheit ON (tt_preorder.adb_wohneinheit_id = adb_wohneinheit.id)
WHERE $where AND hn.rimo_fcp_name IS NOT NULL
GROUP BY hn.rimo_fcp_name
";
$res = $db->query($sql);
$counts = [];
if ($db->num_rows($res)) {
while ($row = $db->fetch_object($res)) {
$counts[$row->rimo_fcp_name] = (int)$row->cnt;
}
}
return $counts;
}
/**
* @param $filter
* @param $limit
+11
View File
@@ -201,6 +201,17 @@ class WorkorderModel extends TTCrudBaseModel
return $result ? $result->fetch_assoc()['count'] : 0;
}
public static function getPreorderIdsByCampaigns(array $campaignIds): array {
if (empty($campaignIds)) return [];
$db = self::getDB();
$campaignIdList = implode(',', array_map('intval', $campaignIds));
$result = $db->query("SELECT id FROM `" . FRONKDB_DBNAME . "`.`Preorder` WHERE preordercampaign_id IN ($campaignIdList)");
if (!$result || $result->num_rows === 0) return [];
$ids = array_column($result->fetch_all(MYSQLI_ASSOC), 'id');
$result->free();
return $ids;
}
public static function getTechnicalData(int $workorderId): ?array {
$workorder = self::get($workorderId);
if (!$workorder || !$workorder->preorderId) return null;
@@ -253,12 +253,8 @@ class WorkorderBaseController extends TTCrud
$statusesToCheck = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved'];
// Get ALL preorders for tenant (including deleted/cancelled) to ensure their workorders get archived
// Note: Not passing 'deleted' filter means all preorders are returned regardless of deleted status
$allTenantPreorders = PreorderModel::search(['preordercampaign_id' => $tenantCampaignIds]);
if(empty($allTenantPreorders)) continue;
$allTenantPreorderIds = array_map(fn($p) => $p->id, $allTenantPreorders);
$allTenantPreorderIds = WorkorderModel::getPreorderIdsByCampaigns($tenantCampaignIds);
if (empty($allTenantPreorderIds)) continue;
$workordersToCheck = WorkorderModel::getAll([
'status' => $statusesToCheck,
+27
View File
@@ -1474,6 +1474,31 @@ $(document).ready(function () {
{
$('#customer-info-type').val('1').trigger('change');
}
if (localsorageEvent.calendar_user_name) {
const calUserName = localsorageEvent.calendar_user_name.toLowerCase();
const calUserOption = $('#calendar-users option').filter(function () {
return $(this).text().trim().toLowerCase().includes(calUserName);
}).first();
if (calUserOption.length) {
$('#calendar-users').val(calUserOption.val()).trigger('change');
}
}
if (localsorageEvent.attendee_names && localsorageEvent.attendee_names.length) {
const attendeeValues = [];
localsorageEvent.attendee_names.forEach(function (attendeeName) {
const nameLower = attendeeName.toLowerCase();
const opt = $('#calendar-attendees option').filter(function () {
return $(this).text().trim().toLowerCase().includes(nameLower);
}).first();
if (opt.length) {
attendeeValues.push(opt.val());
} else {
$('#calendar-attendees').append(new Option(attendeeName, attendeeName, true, true));
attendeeValues.push(attendeeName);
}
});
$('#calendar-attendees').val(attendeeValues).trigger('change');
}
}
});
@@ -2353,6 +2378,8 @@ $(document).ready(function () {
$('body').on('change', '#customer,#customer-info-type', function () {
if (!$('#customer').hasClass('select2-hidden-accessible')) return;
if ($('#name').val() == "") {
$('#name').val('IBN: ' + $("#customer").select2('data')[0].name);
}
@@ -64,16 +64,19 @@ Vue.component('workorder-admin', {
</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 v-if="editingAdditionalInfoId === row.id" class="wo-notiz-edit">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" :rows="editTextareaRows" 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 v-else>
<div :class="['wo-notiz-text', expandedNotes.includes(row.id) ? 'wo-notiz-expanded' : '']">{{ row.additionalInfo || '-' }}</div>
<div class="wo-notiz-actions">
<button v-if="isNoteLong(row.additionalInfo)" class="wo-notiz-toggle" @click="toggleNoteExpand(row.id)">{{ expandedNotes.includes(row.id) ? '▴ weniger' : '▾ mehr' }}</button>
<a class="wo-notiz-edit-btn" @click="startAdditionalInfoEdit(row)" title="Zusatz-Info bearbeiten"><i class="fas fa-edit"></i></a>
</div>
</div>
</template>
@@ -122,7 +125,7 @@ Vue.component('workorder-admin', {
data() {
return {
window, workordersToAssign: [], editingWorkorderId: null, editingDeadlineId: null, editingAdditionalInfoId: null,
civilEngineeringData: null, tempAdditionalInfo: '', companiesByTenant: {}, companiesLoading: false, massAssignCompanyId: null,
civilEngineeringData: null, tempAdditionalInfo: '', expandedNotes: [], companiesByTenant: {}, companiesLoading: false, massAssignCompanyId: null,
cancelWorkorderModalData: null, problemSolvedModalData: null, massAssignModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG, selectable: false, expandable: true,
@@ -155,7 +158,11 @@ Vue.component('workorder-admin', {
const firstWorkorder = rows.find(r => r.id === this.workordersToAssign[0]);
if (!firstWorkorder) return [];
return this.companiesByTenant[firstWorkorder.tenantId] || [];
}
},
editTextareaRows() {
const lines = (this.tempAdditionalInfo || '').split('\n').length;
return String(Math.max(4, lines + 1));
},
},
methods: {
async chargeWorkorder(row) {
@@ -273,6 +280,15 @@ Vue.component('workorder-admin', {
this.problemSolvedModalData = null;
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
},
isNoteLong(text) {
if (!text) return false;
return text.split('\n').length > 4 || text.length > 180;
},
toggleNoteExpand(rowId) {
const idx = this.expandedNotes.indexOf(rowId);
if (idx === -1) this.expandedNotes.push(rowId);
else this.expandedNotes.splice(idx, 1);
},
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';
@@ -41,4 +41,58 @@
.workorder-button {
padding: 2px !important;
}
}
.wo-notiz-text {
white-space: pre-wrap;
word-break: break-word;
max-width: 250px;
max-height: calc(5 * 1.5em);
overflow: hidden;
transition: max-height 0.3s ease;
}
.wo-notiz-text.wo-notiz-expanded {
max-height: 2000px;
}
.wo-notiz-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.wo-notiz-toggle {
font-size: 0.8rem;
color: #007bff;
cursor: pointer;
padding: 0;
border: none;
background: none;
white-space: nowrap;
}
.wo-notiz-toggle:hover {
color: #0056b3;
text-decoration: underline;
}
.wo-notiz-edit-btn {
color: #007bff;
cursor: pointer;
padding: 0;
}
.wo-notiz-edit-btn:hover {
color: #0056b3;
}
.wo-notiz-edit {
min-width: 300px;
}
.wo-notiz-edit textarea {
min-height: 80px;
resize: vertical;
}
@@ -35,18 +35,20 @@ Vue.component('workorder-company', {
</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 v-if="editingAdditionalInfoId === row.id" class="wo-notiz-edit">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" :rows="editTextareaRows" 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 v-if="!['completed', 'cancelled', 'documented'].includes(row.status)"
icon="fas fa-edit" @click="startAdditionalInfoEdit(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Zusatz-Info bearbeiten"/>
<div v-else>
<div :class="['wo-notiz-text', expandedNotes.includes(row.id) ? 'wo-notiz-expanded' : '']">{{ row.additionalInfo || '-' }}</div>
<div class="wo-notiz-actions">
<button v-if="isNoteLong(row.additionalInfo)" class="wo-notiz-toggle" @click="toggleNoteExpand(row.id)">{{ expandedNotes.includes(row.id) ? '▴ weniger' : '▾ mehr' }}</button>
<a v-if="!['completed', 'cancelled', 'documented'].includes(row.status)"
class="wo-notiz-edit-btn" @click="startAdditionalInfoEdit(row)" title="Zusatz-Info bearbeiten"><i class="fas fa-edit"></i></a>
</div>
</div>
</template>
@@ -104,6 +106,7 @@ Vue.component('workorder-company', {
rescheduleModalData: null,
editingAdditionalInfoId: null,
tempAdditionalInfo: '',
expandedNotes: [],
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
expandable: true,
@@ -121,6 +124,12 @@ Vue.component('workorder-company', {
}
}
},
computed: {
editTextareaRows() {
const lines = (this.tempAdditionalInfo || '').split('\n').length;
return String(Math.max(4, lines + 1));
},
},
methods: {
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
@@ -130,19 +139,66 @@ Vue.component('workorder-company', {
if (!timestamp) return '–';
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
},
getCalendarType(networkOwnerName) {
if (!networkOwnerName) return '1';
const name = networkOwnerName.toLowerCase();
if (name.includes('xinon')) return '2';
if (name.includes('sbidi')) return '7';
if (name.includes('estmk')) return '3';
return '1';
},
getCampaignName(preordercampaignId) {
const col = this.crudConfig.columns.find(c => c.key === 'preordercampaign_id');
const opt = col?.table?.filterOptions?.find(o => o.value == preordercampaignId);
return opt?.text || '';
},
openCalendarWithPrefill(workorder, dateUnix, win) {
const m = window.moment.unix(dateUnix);
const zeitraum = m.hour() < 12 ? 'VM' : 'NM';
const campaignName = this.getCampaignName(workorder.preordercampaign_id);
const locationParts = [workorder.street, workorder.hausnummer];
if (workorder.stiege) locationParts.push('/' + workorder.stiege);
const location = locationParts.join(' ') + ', ' + workorder.plz + ' ' + workorder.city;
const descLines = [];
if (workorder.oaid || campaignName || workorder.city) {
descLines.push([workorder.oaid, campaignName, workorder.city].filter(Boolean).join(' - '));
}
descLines.push('Zeitraum: ' + zeitraum);
if (workorder.phone) descLines.push('Tel.: ' + workorder.phone);
if (workorder.additionalInfo) descLines.push(workorder.additionalInfo);
const calendarData = {
type: this.getCalendarType(workorder.networkOwnerName),
subject: workorder.customerCompany || workorder.customerName || '',
location: location,
cstart: m.format('DD.MM.YYYY HH:mm'),
cend: m.clone().add(90, 'minutes').format('DD.MM.YYYY HH:mm'),
description: descLines.join('<br>'),
customer_phone: workorder.phone || null,
customer_email: workorder.email || null,
calendar_user_name: 'Pusnik',
attendee_names: ['Ziga Harc'],
};
localStorage.setItem('Calendar_create', JSON.stringify(calendarData));
win.location.href = '/Calendar/View';
},
async setAppointment(workorder, date) {
if (!date) return;
if (moment.unix(date).hour() >= 23 || moment.unix(date).hour() < 1) {
this.$refs.table.$refs.table.refreshTable();
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
const calWin = window.open('about:blank', '_blank');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/scheduleAppointment`, {
workorderId: workorder.id, appointmentDate: date
});
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.openCalendarWithPrefill(workorder, date, calWin);
} else {
if (calWin) calWin.close();
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
},
openRescheduleModal(row) {
this.rescheduleModalData = { workorder: row, newDate: row.appointmentDate, reason: '' };
@@ -151,14 +207,19 @@ Vue.component('workorder-company', {
const { workorder, newDate, reason } = this.rescheduleModalData;
if (!newDate || !reason) return window.notify('error', 'Bitte geben Sie ein neues Datum und einen Grund an.');
if (moment.unix(newDate).hour() >= 23 || moment.unix(newDate).hour() < 1) return window.notify('error', 'Bitte Uhrzeit angeben!');
const calWin = window.open('about:blank', '_blank');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/rescheduleAppointment`, {
workorderId: workorder.id, appointmentDate: newDate, reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.openCalendarWithPrefill(workorder, newDate, calWin);
this.rescheduleModalData = null;
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} else {
if (calWin) calWin.close();
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
},
async clearAppointment(workorder) {
if (!confirm('Möchten Sie den Termin wirklich löschen?')) return;
@@ -170,6 +231,15 @@ Vue.component('workorder-company', {
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
},
isNoteLong(text) {
if (!text) return false;
return text.split('\n').length > 4 || text.length > 180;
},
toggleNoteExpand(rowId) {
const idx = this.expandedNotes.indexOf(rowId);
if (idx === -1) this.expandedNotes.push(rowId);
else this.expandedNotes.splice(idx, 1);
},
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';