added technical data to xinon workorder and workordermph now has a unassign button

This commit is contained in:
Luca Haid
2026-01-18 17:42:11 +00:00
parent a35b865fad
commit 6ab41a9169
13 changed files with 508 additions and 24 deletions

View File

@@ -826,4 +826,69 @@ class ManualInvoiceController extends TTCrud
'vatrate' => $vatrate->rate 'vatrate' => $vatrate->rate
]); ]);
} }
protected function getCustomerBillingInfoAction() {
$addressId = $_GET['address_id'] ?? null;
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
if (!$addressId) {
self::returnJson(['success' => false, 'message' => 'Address ID required']);
return;
}
$address = new Address($addressId);
if (!$address->id) {
self::returnJson(['success' => false, 'message' => 'Address not found']);
return;
}
$vatarea = 'domestic';
if ($address->country_id) {
$country = new Country($address->country_id);
if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) {
$vatarea = $country->is_eu ? 'eu' : 'other';
}
}
if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') {
$vatarea = 'domestic';
}
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
$taxText = $vatrate ? $vatrate->invoice_text : '';
$db = $this->db();
$sepaLimit = null;
$res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId));
if ($res && $row = $res->fetch_assoc()) {
$sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null;
}
self::returnJson([
'success' => true,
'billing_type' => $address->billing_type ?: 'invoice',
'manual_invoice_sepa_limit' => $sepaLimit,
'vatarea' => $vatarea,
'tax_text' => $taxText,
'bank_account_bank' => $address->bank_account_bank,
'bank_account_owner' => $address->bank_account_owner,
'bank_account_iban' => $address->bank_account_iban,
'bank_account_bic' => $address->bank_account_bic,
'sepa_date' => $address->sepa_date,
'sepa_id' => $address->sepa_id
]);
}
protected function getTaxTextAction() {
$vatgroupId = $_GET['vatgroup_id'] ?? 2;
$vatarea = $_GET['vatarea'] ?? 'domestic';
$vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
self::returnJson([
'success' => true,
'tax_text' => $vatrate ? $vatrate->invoice_text : '',
'vatrate' => $vatrate ? $vatrate->rate : 20
]);
}
} }

View File

@@ -164,6 +164,7 @@ class WorkorderHandler extends MobileAppBaseHandler {
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [], 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
'requireCableLength' => (bool)$tenantConfig->requireCableLength, 'requireCableLength' => (bool)$tenantConfig->requireCableLength,
'requireCableType' => (bool)$tenantConfig->requireCableType, 'requireCableType' => (bool)$tenantConfig->requireCableType,
'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
]; ];
} }
@@ -241,6 +242,11 @@ class WorkorderHandler extends MobileAppBaseHandler {
]; ];
} }
$technicalData = null;
if ($tenantConfigData && !empty($tenantConfigData['showTechnicalData'])) {
$technicalData = $this->getTechnicalData($id);
}
self::returnJson([ self::returnJson([
'success' => true, 'success' => true,
'workorder' => $workorder, 'workorder' => $workorder,
@@ -252,7 +258,8 @@ class WorkorderHandler extends MobileAppBaseHandler {
'completed' => $completedCount, 'completed' => $completedCount,
'total' => count($docTypes), 'total' => count($docTypes),
'allRequired' => $this->allRequiredCompleted($checklist) 'allRequired' => $this->allRequiredCompleted($checklist)
] ],
'technicalData' => $technicalData,
]); ]);
} }
@@ -893,6 +900,51 @@ class WorkorderHandler extends MobileAppBaseHandler {
return true; return true;
} }
/**
* Get technical data (patchposition and AHA Blatt) for a workorder
*/
private function getTechnicalData($workorderId) {
$workorder = WorkorderModel::get($workorderId);
if (!$workorder || !$workorder->preorderId) return null;
$preorder = new Preorder($workorder->preorderId);
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
$wohneinheit = $preorder->adb_wohneinheit;
if (!$wohneinheit) return null;
$defaultCluster = '';
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
}
$patchposition = [
'equipmentName' => $wohneinheit->getPatchEqString(),
'equipmentPort' => $wohneinheit->patch_port,
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
'shelf' => $wohneinheit->patch_shelf,
'module' => $wohneinheit->patch_module,
];
$rimoWorkorders = [];
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
foreach ($wohneinheit->rimo_workorders as $wo) {
$rimoWorkorders[] = [
'id' => $wo->id,
'rimoName' => $wo->rimo_name,
'rimoId' => $wo->rimo_id,
'rimoStatus' => $wo->rimo_status,
'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
];
}
}
return [
'patchposition' => $patchposition,
'rimoWorkorders' => $rimoWorkorders,
];
}
/** /**
* Get single workorder with full joined data (same structure as getCompanyWorkorders) * Get single workorder with full joined data (same structure as getCompanyWorkorders)
*/ */

View File

@@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController {
protected function downloadAhaAction() { protected function downloadAhaAction() {
$workorder_id = $this->request->id; $workorder_id = $this->request->id;
$inline = !empty($this->request->inline);
if(!$workorder_id || $workorder_id < 1) { if(!$workorder_id || $workorder_id < 1) {
header("HTTP/1.1 400 Bad Request"); header("HTTP/1.1 400 Bad Request");
@@ -34,8 +35,11 @@ class RimoWorkorderController extends mfBaseController {
exit; exit;
} }
header("Content-type: text/pdf"); $filename = $workorder->rimo_name.'_AHA.pdf';
header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"'); $disposition = $inline ? 'inline' : 'attachment';
header("Content-type: application/pdf");
header('Content-disposition: '.$disposition.'; filename="'.$filename.'"');
echo $return; echo $return;
exit; exit;
} }

View File

@@ -141,6 +141,51 @@ class WorkorderBaseController extends TTCrud
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null; return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null;
} }
/**
* Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
*/
protected function getTechnicalData(int $workorderId): ?array {
$workorder = WorkorderModel::get($workorderId);
if (!$workorder || !$workorder->preorderId) return null;
$preorder = new Preorder($workorder->preorderId);
if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
$wohneinheit = $preorder->adb_wohneinheit;
if (!$wohneinheit) return null;
$defaultCluster = '';
if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
$defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
}
$patchposition = [
'equipmentName' => $wohneinheit->getPatchEqString(),
'equipmentPort' => $wohneinheit->patch_port,
'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
'shelf' => $wohneinheit->patch_shelf,
'module' => $wohneinheit->patch_module,
];
$rimoWorkorders = [];
if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
foreach ($wohneinheit->rimo_workorders as $wo) {
$rimoWorkorders[] = [
'id' => $wo->id,
'rimoName' => $wo->rimo_name,
'rimoId' => $wo->rimo_id,
'rimoStatus' => $wo->rimo_status,
'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
];
}
}
return [
'patchposition' => $patchposition,
'rimoWorkorders' => $rimoWorkorders,
];
}
//region BACKGROUND TASKS //region BACKGROUND TASKS
/** /**
* Creates new workorders from preorders based on tenant configurations. * Creates new workorders from preorders based on tenant configurations.

View File

@@ -167,14 +167,22 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']); self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']);
return; return;
} }
self::returnJson([
$response = [
'success' => true, 'success' => true,
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true), 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true),
'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired, 'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired,
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true), 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true),
'requireCableLength' => $tenantConfig->requireCableLength, 'requireCableLength' => $tenantConfig->requireCableLength,
'requireCableType' => $tenantConfig->requireCableType 'requireCableType' => $tenantConfig->requireCableType,
]); 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
];
if ($tenantConfig->showTechnicalData) {
$response['technicalData'] = $this->getTechnicalData((int)$this->request->workorderId);
}
self::returnJson($response);
} }
protected function uploadDocumentationAction() { protected function uploadDocumentationAction() {

View File

@@ -375,4 +375,38 @@ class WorkorderMphAdminController extends WorkorderMphBaseController
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']); self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
} }
protected function unassignWorkorderAction()
{
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 === 'new') self::sendError("Arbeitsauftrag ist nicht zugewiesen.");
if (in_array($workorder->status, ['completed', 'cancelled'])) self::sendError("Arbeitsauftrag kann nicht mehr geändert werden.");
$oldStatus = $workorder->status;
$oldCompany = $workorder->companyId ? WorkorderCompanyModel::get($workorder->companyId) : null;
$oldCompanyName = $oldCompany ? $oldCompany->name : 'Unbekannt';
$workorder->status = 'new';
$workorder->companyId = null;
$workorder->assignmentDate = null;
$workorder->deadlineDate = null;
$workorder->appointmentDate = null;
WorkorderMphModel::update((array)$workorder);
$reason = !empty($this->postData['reason']) ? " Grund: " . $this->postData['reason'] : '';
WorkorderMphJournalModel::create([
'workorderMphId' => $workorder->id,
'text' => "Zuweisung aufgehoben (vorher: $oldCompanyName).$reason",
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'),
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Zuweisung wurde aufgehoben.']);
}
} }

View File

@@ -12,6 +12,7 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel {
public int $civilEngineeringDocsRequired; public int $civilEngineeringDocsRequired;
public int $requireCableLength; public int $requireCableLength;
public int $requireCableType; public int $requireCableType;
public int $showTechnicalData = 0;
public int $enableWorkorder; public int $enableWorkorder;
public int $enableWorkorderMph; public int $enableWorkorderMph;
public int $create; public int $create;

View File

@@ -0,0 +1,26 @@
<?php
use Phinx\Migration\AbstractMigration;
class AddShowTechnicalDataToTenantConfig extends AbstractMigration {
public function up() {
if ($this->getEnvironment() !== "thetool") return;
$table = $this->table('WorkorderTenantConfig');
if (!$table->hasColumn('showTechnicalData')) {
$table->addColumn('showTechnicalData', 'boolean', [
'default' => false,
'after' => 'requireCableType'
])->update();
}
}
public function down() {
if ($this->getEnvironment() !== "thetool") return;
$table = $this->table('WorkorderTenantConfig');
if ($table->hasColumn('showTechnicalData')) {
$table->removeColumn('showTechnicalData')->update();
}
}
}

View File

@@ -161,9 +161,18 @@ Vue.component('manual-invoice-modal', {
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingaddress_id" sm row /> <tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingaddress_id" sm row />
</tt-card> </tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template> <tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
<div class="form-grid"> <div class="form-row mb-2">
<tt-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/> <div class="col-md-6">
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/> <label class="small text-muted">Zahlungsart</label>
<div class="d-flex align-items-center">
<span :class="['badge', effectiveBillingType === 'sepa' ? 'badge-info' : 'badge-secondary']">
{{ effectiveBillingType === 'sepa' ? 'SEPA' : 'Rechnung' }}
</span>
<small v-if="customerBillingInfo.billing_type === 'sepa' && effectiveBillingType === 'invoice'" class="text-warning ml-2">
<i class="fas fa-exclamation-triangle"></i> Brutto überschreitet SEPA-Limit ({{ formatPrice(customerBillingInfo.manual_invoice_sepa_limit) }})
</small>
</div>
</div>
</div> </div>
<tt-input label="Leistungszeitraum" v-model="invoiceData.leistungszeitraum" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/> <tt-input label="Leistungszeitraum" v-model="invoiceData.leistungszeitraum" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/>
<tt-input label="Externe Referenz" v-model="invoiceData.externe_referenz" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/> <tt-input label="Externe Referenz" v-model="invoiceData.externe_referenz" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/>
@@ -172,9 +181,8 @@ Vue.component('manual-invoice-modal', {
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template> <tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" @updateField-article_id="onArticleSelected" /> <tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" @updateField-article_id="onArticleSelected" />
</tt-card> </tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template> <tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Rabatt</h5></template>
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/> <tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/>
<tt-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
</tt-card> </tt-card>
</div> </div>
</div> </div>
@@ -197,6 +205,12 @@ Vue.component('manual-invoice-modal', {
pdfLoading: false, pdfLoading: false,
pdfPreviewUrl: '', pdfPreviewUrl: '',
previewDebounceTimer: null, previewDebounceTimer: null,
customerBillingInfo: {
billing_type: 'invoice',
manual_invoice_sepa_limit: null,
vatarea: 'domestic',
tax_text: ''
},
invoiceData: { invoiceData: {
id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'), id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'),
billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0, billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0,
@@ -205,7 +219,6 @@ Vue.component('manual-invoice-modal', {
leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0, leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0,
positions: [], total: 0, total_gross: 0 positions: [], total: 0, total_gross: 0
}, },
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: { positionsConfig: {
fields: { fields: {
article_id: { article_id: {
@@ -270,16 +283,31 @@ Vue.component('manual-invoice-modal', {
}); });
return { subtotal, net, vat, gross }; return { subtotal, net, vat, gross };
},
effectiveBillingType() {
if (this.customerBillingInfo.billing_type !== 'sepa') return 'invoice';
if (this.customerBillingInfo.manual_invoice_sepa_limit === null) return 'sepa';
return this.totals.gross <= this.customerBillingInfo.manual_invoice_sepa_limit ? 'sepa' : 'invoice';
} }
}, },
watch: { watch: {
'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true }, 'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true },
effectiveBillingType: {
handler(newType) {
this.invoiceData.billing_type = newType;
},
immediate: true
},
'invoiceData.billingaddress_id': { 'invoiceData.billingaddress_id': {
async handler(newId) { async handler(newId) {
if (!newId) return Object.assign(this.invoiceData, { if (!newId) {
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', Object.assign(this.invoiceData, {
country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0 company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
}); country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
});
this.customerBillingInfo = { billing_type: 'invoice', manual_invoice_sepa_limit: null, vatarea: 'domestic', tax_text: '' };
return;
}
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`); const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (data.status === 'OK' && data.result.address) { if (data.status === 'OK' && data.result.address) {
@@ -291,6 +319,8 @@ Vue.component('manual-invoice-modal', {
fibu_account_number: a.fibu_account_number || 0, owner_id: newId fibu_account_number: a.fibu_account_number || 0, owner_id: newId
}); });
} }
await this.fetchCustomerBillingInfo(newId);
} }
} }
}, },
@@ -327,10 +357,35 @@ Vue.component('manual-invoice-modal', {
methods: { methods: {
close() { this.$emit('close'); }, close() { this.$emit('close'); },
saveInvoice() { saveInvoice() {
this.invoiceData.invoice_date = moment().format('YYYY-MM-DD');
this.invoiceData.billing_type = this.effectiveBillingType;
this.invoiceData.tax_text = this.customerBillingInfo.tax_text;
if (!this.invoiceData.billingaddress_id) return window.notify('error', 'Bitte wählen Sie einen Kunden aus.'); if (!this.invoiceData.billingaddress_id) return window.notify('error', 'Bitte wählen Sie einen Kunden aus.');
if (!this.invoiceData.positions?.length) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.'); if (!this.invoiceData.positions?.length) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
this.$emit('save', this.invoiceData); this.$emit('save', this.invoiceData);
}, },
formatPrice(value) {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value);
},
async fetchCustomerBillingInfo(addressId) {
if (!addressId) return;
try {
const vatgroupId = this.invoiceData.vatgroup_id || 2;
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getCustomerBillingInfo?address_id=${addressId}&vatgroup_id=${vatgroupId}`);
if (data.success) {
this.customerBillingInfo = {
billing_type: data.billing_type || 'invoice',
manual_invoice_sepa_limit: data.manual_invoice_sepa_limit,
vatarea: data.vatarea || 'domestic',
tax_text: data.tax_text || ''
};
this.invoiceData.tax_text = data.tax_text || '';
}
} catch (e) {
console.error('Error fetching customer billing info:', e);
}
},
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; }, handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
handleGlobalKeydown(e) { handleGlobalKeydown(e) {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); } if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
@@ -339,9 +394,9 @@ Vue.component('manual-invoice-modal', {
async onArticleSelected(articleId) { async onArticleSelected(articleId) {
if (!articleId) return; if (!articleId) return;
try { try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}`); const vatarea = this.customerBillingInfo.vatarea || 'domestic';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}&vatarea=${vatarea}`);
if (data.success && this.$refs.positionsManager) { if (data.success && this.$refs.positionsManager) {
// Update the formData in the positions manager
const pm = this.$refs.positionsManager; const pm = this.$refs.positionsManager;
if (data.article) { if (data.article) {
pm.$set(pm.formData, 'product_name', data.article.title); pm.$set(pm.formData, 'product_name', data.article.title);
@@ -351,13 +406,26 @@ Vue.component('manual-invoice-modal', {
pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account); pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account);
pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy); pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy);
pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode); pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode);
// Store vatgroup_id on invoice level if needed
this.invoiceData.vatgroup_id = data.vatgroup_id; this.invoiceData.vatgroup_id = data.vatgroup_id;
await this.updateTaxText(data.vatgroup_id);
} }
} catch (e) { } catch (e) {
console.error('Error fetching article VAT info:', e); console.error('Error fetching article VAT info:', e);
} }
}, },
async updateTaxText(vatgroupId) {
if (!vatgroupId) return;
try {
const vatarea = this.customerBillingInfo.vatarea || 'domestic';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getTaxText?vatgroup_id=${vatgroupId}&vatarea=${vatarea}`);
if (data.success) {
this.customerBillingInfo.tax_text = data.tax_text || '';
this.invoiceData.tax_text = data.tax_text || '';
}
} catch (e) {
console.error('Error fetching tax text:', e);
}
},
debouncedPreviewUpdate() { debouncedPreviewUpdate() {
clearTimeout(this.previewDebounceTimer); clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000); this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);

View File

@@ -273,7 +273,44 @@ Vue.component('workorder-details-manager', {
/> />
</div> </div>
</div> </div>
<div v-if="showTechnicalData && technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)" class="card mb-3">
<div class="card-header bg-purple text-white">
<h5 class="mb-0"><i class="fas fa-microchip mr-2"></i>Technische Daten</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6" v-if="technicalData.patchposition?.equipmentName">
<h6>Patchposition</h6>
<table class="table table-sm table-striped mb-0">
<tr>
<th class="border-top-0">Equipment Name:</th>
<td class="border-top-0 text-monospace">{{ technicalData.patchposition.equipmentName }}</td>
</tr>
<tr v-if="technicalData.patchposition.equipmentPort">
<th>Equipment Port:</th>
<td class="text-monospace">{{ technicalData.patchposition.equipmentPort }}</td>
</tr>
</table>
</div>
<div class="col-md-6" v-if="technicalData.rimoWorkorders?.length">
<h6>AHA Blätter</h6>
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="mb-2">
<div class="d-flex align-items-center justify-content-between border rounded p-2">
<div>
<strong>{{ wo.rimoName }}</strong>
<small class="text-muted ml-2">{{ wo.rimoStatus }}</small>
</div>
<a :href="wo.downloadUrl" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fas fa-file-pdf mr-1"></i> AHA Blatt
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3" v-if="isAdmin && selectedDocs.length > 0"> <div class="card mb-3" v-if="isAdmin && selectedDocs.length > 0">
<div class="card-header bg-warning"><h5><i class="fas fa-exclamation-triangle mr-2"></i>Korrektur anfordern</h5></div> <div class="card-header bg-warning"><h5><i class="fas fa-exclamation-triangle mr-2"></i>Korrektur anfordern</h5></div>
<div class="card-body"> <div class="card-body">
@@ -328,6 +365,9 @@ Vue.component('workorder-details-manager', {
requireCableLength: false, requireCableLength: false,
requireCableType: false, requireCableType: false,
savingData: false, savingData: false,
// Technical data
showTechnicalData: false,
technicalData: null,
// Admin state // Admin state
selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false, selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false,
}), }),
@@ -394,6 +434,8 @@ Vue.component('workorder-details-manager', {
this.interventionTypes = data.interventionTypes; this.interventionTypes = data.interventionTypes;
this.requireCableLength = data.requireCableLength || false; this.requireCableLength = data.requireCableLength || false;
this.requireCableType = data.requireCableType || false; this.requireCableType = data.requireCableType || false;
this.showTechnicalData = data.showTechnicalData || false;
this.technicalData = data.technicalData || null;
} }
} catch (e) { console.error("Mandantenkonfiguration nicht geladen", e); } } catch (e) { console.error("Mandantenkonfiguration nicht geladen", e); }
finally { this.loadingConfig = false; } finally { this.loadingConfig = false; }

View File

@@ -30,10 +30,13 @@ Vue.component('workorder-mph-admin', {
</div> </div>
<div v-else><span>{{ row.companyName || 'N/A' }}</span></div> <div v-else><span>{{ row.companyName || 'N/A' }}</span></div>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(2, auto); gap: 0px; padding-left: 8px;"> <div style="display: grid; grid-template-columns: repeat(3, auto); gap: 0px; padding-left: 8px;">
<tt-button v-if="!['completed', 'new'].includes(row.status)" icon="fas fa-edit" <tt-button v-if="!['completed', 'new', 'cancelled'].includes(row.status)" icon="fas fa-edit"
@click="startCompanyEdit(row)" additional-class="btn-link workorder-mph-button" @click="startCompanyEdit(row)" additional-class="btn-link workorder-mph-button"
title="Zuweisung ändern"/> title="Zuweisung ändern"/>
<tt-button v-if="!['completed', 'new', 'cancelled'].includes(row.status)" icon="fas fa-user-slash text-warning"
@click="unassignWorkorderModalData = row" additional-class="btn-link workorder-mph-button"
title="Zuweisung aufheben"/>
<tt-button v-if="!['completed', 'cancelled'].includes(row.status)" icon="fas fa-ban text-danger" <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" @click="cancelWorkorderModalData = row" additional-class="btn-link workorder-mph-button"
title="Auftrag stornieren"/> title="Auftrag stornieren"/>
@@ -101,6 +104,13 @@ Vue.component('workorder-mph-admin', {
<p>Soll der Auftrag <strong>#{{ cancelWorkorderModalData.id }}</strong> wirklich storniert werden?</p> <p>Soll der Auftrag <strong>#{{ cancelWorkorderModalData.id }}</strong> wirklich storniert werden?</p>
<tt-textarea label="Grund (optional)" v-model="cancelWorkorderModalData.reason" sm row/> <tt-textarea label="Grund (optional)" v-model="cancelWorkorderModalData.reason" sm row/>
</tt-modal> </tt-modal>
<tt-modal v-if="unassignWorkorderModalData" :show.sync="unassignWorkorderModalData"
title="Zuweisung aufheben" @submit="unassignWorkorder">
<p>Soll die Zuweisung für Auftrag <strong>#{{ unassignWorkorderModalData.id }}</strong> aufgehoben werden?</p>
<p class="text-muted small">Aktuell zugewiesen an: <strong>{{ unassignWorkorderModalData.companyName }}</strong></p>
<tt-textarea label="Grund (optional)" v-model="unassignWorkorderModalData.reason" sm row/>
</tt-modal>
</tt-card> </tt-card>
`, `,
data() { data() {
@@ -113,6 +123,7 @@ Vue.component('workorder-mph-admin', {
companies: [], companies: [],
companiesLoading: false, companiesLoading: false,
cancelWorkorderModalData: null, cancelWorkorderModalData: null,
unassignWorkorderModalData: null,
crudConfig: { crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG, ...window.TT_CONFIG.CRUD_CONFIG,
selectable: false, selectable: false,
@@ -237,6 +248,20 @@ Vue.component('workorder-mph-admin', {
} else { } else {
window.notify('error', data.message || 'Stornierung fehlgeschlagen.'); window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
} }
},
async unassignWorkorder() {
const { id, reason } = this.unassignWorkorderModalData;
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/unassignWorkorder`, {
workorderId: id,
reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.unassignWorkorderModalData = null;
} else {
window.notify('error', data.message || 'Aufheben der Zuweisung fehlgeschlagen.');
}
} }
} }
}); });

View File

@@ -89,6 +89,8 @@ Vue.component('workorder-tenant-config', {
v-model="editableItem.requireCableLength" sm/> v-model="editableItem.requireCableLength" sm/>
<tt-checkbox label="Kabeltyp erforderlich" <tt-checkbox label="Kabeltyp erforderlich"
v-model="editableItem.requireCableType" sm/> v-model="editableItem.requireCableType" sm/>
<tt-checkbox label="Technische Daten anzeigen (Patchposition, AHA Blatt)"
v-model="editableItem.showTechnicalData" sm/>
</div> </div>
<div v-else> <div v-else>
<p>Workorder: <strong>{{ config.enableWorkorder ? 'Aktiviert' : 'Deaktiviert' }}</strong></p> <p>Workorder: <strong>{{ config.enableWorkorder ? 'Aktiviert' : 'Deaktiviert' }}</strong></p>
@@ -97,6 +99,7 @@ Vue.component('workorder-tenant-config', {
<p>Tiefbau-Doku: <strong>{{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}</strong></p> <p>Tiefbau-Doku: <strong>{{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}</strong></p>
<p>Kabellänge-Doku: <strong>{{ config.requireCableLength ? 'Ja' : 'Nein' }}</strong></p> <p>Kabellänge-Doku: <strong>{{ config.requireCableLength ? 'Ja' : 'Nein' }}</strong></p>
<p>Kabeltyp-Doku: <strong>{{ config.requireCableType ? 'Ja' : 'Nein' }}</strong></p> <p>Kabeltyp-Doku: <strong>{{ config.requireCableType ? 'Ja' : 'Nein' }}</strong></p>
<p>Technische Daten: <strong>{{ config.showTechnicalData ? 'Ja' : 'Nein' }}</strong></p>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -333,6 +336,7 @@ Vue.component('workorder-tenant-config', {
civilEngineeringDocsRequired: 0, civilEngineeringDocsRequired: 0,
requireCableLength: 0, requireCableLength: 0,
requireCableType: 0, requireCableType: 0,
showTechnicalData: 0,
enableWorkorder: 1, enableWorkorder: 1,
enableWorkorderMph: 1 enableWorkorderMph: 1
} }

View File

@@ -31,6 +31,7 @@ export default {
const documentation = ref({ docs: [], journals: [] }); const documentation = ref({ docs: [], journals: [] });
const tenantConfig = ref(null); const tenantConfig = ref(null);
const checklist = ref([]); const checklist = ref([]);
const technicalData = ref(null);
// Expanded cards state // Expanded cards state
const expandedCards = ref({ const expandedCards = ref({
@@ -39,7 +40,8 @@ export default {
documentation: false, documentation: false,
notes: false, notes: false,
journal: false, journal: false,
cableData: false cableData: false,
technical: true
}); });
// Edit states // Edit states
@@ -52,6 +54,9 @@ export default {
const showDocUploadSheet = ref(false); const showDocUploadSheet = ref(false);
const showProblemSheet = ref(false); const showProblemSheet = ref(false);
const showCompleteSheet = ref(false); const showCompleteSheet = ref(false);
const showPdfViewer = ref(false);
const pdfViewerUrl = ref('');
const pdfViewerTitle = ref('');
// Upload state // Upload state
const uploadDocType = ref(''); const uploadDocType = ref('');
@@ -198,7 +203,7 @@ export default {
const openDetail = async (workorder) => { const openDetail = async (workorder) => {
selectedWorkorder.value = workorder; selectedWorkorder.value = workorder;
isDetailLoading.value = true; isDetailLoading.value = true;
expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false }; expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false, technical: true };
emit('detail-open', workorder.id); emit('detail-open', workorder.id);
try { try {
@@ -215,6 +220,7 @@ export default {
documentation.value = { docs: data.docs, journals: data.journals }; documentation.value = { docs: data.docs, journals: data.journals };
tenantConfig.value = data.tenantConfig; tenantConfig.value = data.tenantConfig;
checklist.value = data.checklist; checklist.value = data.checklist;
technicalData.value = data.technicalData || null;
} else { } else {
emit('toast', data.message || 'Fehler beim Laden', 'error'); emit('toast', data.message || 'Fehler beim Laden', 'error');
} }
@@ -231,6 +237,7 @@ export default {
documentation.value = { docs: [], journals: [] }; documentation.value = { docs: [], journals: [] };
tenantConfig.value = null; tenantConfig.value = null;
checklist.value = []; checklist.value = [];
technicalData.value = null;
isEditingNotes.value = false; isEditingNotes.value = false;
emit('detail-close'); emit('detail-close');
}; };
@@ -617,6 +624,13 @@ export default {
// Button is disabled when not complete, so this won't be called // Button is disabled when not complete, so this won't be called
}; };
// Open PDF in viewer
const openPdfViewer = (url, title) => {
pdfViewerUrl.value = url;
pdfViewerTitle.value = title || 'PDF';
showPdfViewer.value = true;
};
// Initialize // Initialize
onMounted(() => { onMounted(() => {
fetchWorkorders(); fetchWorkorders();
@@ -636,6 +650,7 @@ export default {
documentation, documentation,
tenantConfig, tenantConfig,
checklist, checklist,
technicalData,
expandedCards, expandedCards,
isEditingNotes, isEditingNotes,
tempNotes, tempNotes,
@@ -644,6 +659,9 @@ export default {
showDocUploadSheet, showDocUploadSheet,
showProblemSheet, showProblemSheet,
showCompleteSheet, showCompleteSheet,
showPdfViewer,
pdfViewerUrl,
pdfViewerTitle,
uploadDocType, uploadDocType,
isUploading, isUploading,
fileInputRef, fileInputRef,
@@ -678,6 +696,7 @@ export default {
openNavigation, openNavigation,
callCustomer, callCustomer,
handleComplete, handleComplete,
openPdfViewer,
handleTouchStart, handleTouchStart,
handleTouchMove, handleTouchMove,
handleTouchEnd, handleTouchEnd,
@@ -906,6 +925,63 @@ export default {
</div> </div>
</div> </div>
<!-- Technical Data Card -->
<div v-if="technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)"
class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
<button
@click="toggleCard('technical')"
class="w-full flex items-center justify-between p-4 text-left"
>
<div class="flex items-center">
<div class="w-8 h-8 bg-purple-500/10 rounded-lg flex items-center justify-center mr-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
</div>
<span class="font-semibold text-slate-800 dark:text-white">Technische Daten</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.technical ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div v-if="expandedCards.technical" class="px-4 pb-4 space-y-3">
<!-- Patchposition -->
<div v-if="technicalData.patchposition?.equipmentName" class="space-y-2">
<div class="text-sm font-medium text-slate-500 dark:text-slate-400">Patchposition</div>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-1">
<div class="flex justify-between">
<span class="text-slate-500 dark:text-slate-400 text-sm">Equipment Name:</span>
<span class="font-mono text-slate-900 dark:text-white">{{ technicalData.patchposition.equipmentName }}</span>
</div>
<div v-if="technicalData.patchposition.equipmentPort" class="flex justify-between">
<span class="text-slate-500 dark:text-slate-400 text-sm">Equipment Port:</span>
<span class="font-mono text-slate-900 dark:text-white">{{ technicalData.patchposition.equipmentPort }}</span>
</div>
</div>
</div>
<!-- AHA Blätter -->
<div v-if="technicalData.rimoWorkorders?.length" class="space-y-2">
<div class="text-sm font-medium text-slate-500 dark:text-slate-400">AHA Blätter</div>
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id"
class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 flex items-center justify-between">
<div>
<div class="font-medium text-slate-900 dark:text-white">{{ wo.rimoName }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">Status: {{ wo.rimoStatus }}</div>
</div>
<button @click="openPdfViewer(wo.downloadUrl, wo.rimoName)"
class="flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-lg text-sm font-medium active:scale-95 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
AHA
</button>
</div>
</div>
</div>
</div>
<!-- Checklist Card --> <!-- Checklist Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast"> <div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
<button <button
@@ -1373,6 +1449,40 @@ export default {
</transition> </transition>
</teleport> </teleport>
<!-- PDF Viewer Modal -->
<teleport to="body">
<transition name="fade">
<div v-if="showPdfViewer" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex-shrink-0" style="padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));">
<h3 class="text-lg font-semibold text-slate-800 dark:text-white truncate flex-1 mr-4">{{ pdfViewerTitle }}</h3>
<button @click="showPdfViewer = false" class="p-2 -mr-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- PDF Content -->
<div class="flex-1 overflow-hidden bg-slate-100 dark:bg-slate-800">
<iframe
:src="pdfViewerUrl + (pdfViewerUrl.includes('?') ? '&' : '?') + 'inline=1'"
class="w-full h-full border-0"
style="min-height: 100%;"
></iframe>
</div>
<!-- Footer with download option -->
<div class="flex-shrink-0 px-4 py-3 bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700" style="padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));">
<a :href="pdfViewerUrl" download class="w-full py-3 bg-primary text-white rounded-xl font-medium flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</a>
</div>
</div>
</transition>
</teleport>
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"