added tenant configuration

This commit is contained in:
2025-09-02 15:01:14 +02:00
parent 179d711bbd
commit 69eef1c3dc
4 changed files with 533 additions and 0 deletions

View File

@@ -8,4 +8,23 @@ class WorkorderCompanyModel extends TTCrudBaseModel {
public ?string $visibleForAddressId;
public int $create;
public int $createBy;
public static function getCompanyWorkers(int $companyId): array {
if (!$company = self::get($companyId)) {
return [];
}
$db = self::getDB();
$addressId = $db->real_escape_string($company->addressId);
$sql = "SELECT w.id, w.name, w.email
FROM `" . FRONKDB_DBNAME . "`.`Worker` w
JOIN `" . FRONKDB_DBNAME . "`.`WorkerPermission` wp ON w.id = wp.worker_id
WHERE w.address_id = '$addressId' AND wp.canRMLCompany = 'true' AND w.active = 1
ORDER BY w.name ASC";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}

View File

@@ -0,0 +1,93 @@
<?php
class WorkorderTenantConfigController extends TTCrud {
protected string $headerTitle = 'Mandanten & Firmen Konfiguration';
protected bool $createText = false;
protected array $columns = [];
protected function indexAction() {
Helper::renderVue($this, 'WorkorderTenantConfig', $this->headerTitle, []);
}
protected function getTenantConfigsAction() {
$configs = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
self::returnJson($configs);
}
protected function saveTenantConfigAction() {
$data = $this->postData;
$data['documentationTypes'] = json_encode($data['documentationTypes'] ?? []);
$data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
$data['workorderCreationFilters'] ??= '{}';
if (empty($data['id'])) {
$data['create'] = time();
$data['createBy'] = $this->user->id;
WorkorderTenantConfigModel::create($data);
} else {
WorkorderTenantConfigModel::update($data);
}
self::returnJson(['success' => true, 'message' => 'Mandanten-Konfiguration gespeichert.']);
}
protected function deleteTenantConfigAction() {
if (empty($this->postData['id'])) self::sendError("ID fehlt.");
WorkorderTenantConfigModel::delete($this->postData['id']);
self::returnJson(['success' => true, 'message' => 'Mandanten-Konfiguration gelöscht.']);
}
protected function getCompaniesAction() {
$companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
foreach ($companies as $company) {
$company->workers = WorkorderCompanyModel::getCompanyWorkers($company->id);
}
self::returnJson($companies);
}
protected function saveCompanyAction() {
$data = $this->postData;
if (empty($data['name']) || empty($data['addressId'])) self::sendError("Name und Adresse sind erforderlich.");
unset($data['workers']);
$data['visibleForAddressId'] = json_encode($data['visibleForAddressId'] ?? []);
if (empty($data['id'])) {
$data['create'] = time();
$data['createBy'] = $this->user->id;
WorkorderCompanyModel::create($data);
} else {
WorkorderCompanyModel::update($data);
}
self::returnJson(['success' => true, 'message' => 'Firma gespeichert.']);
}
protected function deleteCompanyAction() {
if (empty($this->postData['id'])) self::sendError("ID fehlt.");
WorkorderCompanyModel::delete($this->postData['id']);
self::returnJson(['success' => true, 'message' => 'Firma gelöscht.']);
}
protected function addressAutocompleteAction() {
$search = trim($this->request->q ?? '');
$searchedID = $this->request->searchedID ?? null;
$addresses = [];
if ($searchedID) {
$ids = array_filter(explode(',', $searchedID));
if ($ids) $addresses = AddressModel::search(['id' => $ids]);
} elseif (strlen($search) >= 2) {
$addresses = array_slice(AddressModel::search(["company" => $search]), 0, 15);
}
$results = array_map(function($address) {
return [
'value' => $address->id,
'text' => "{$address->getCompanyOrName()} ({$address->zip} {$address->city})"
];
}, $addresses);
self::returnJson($results);
}
}

View File

@@ -0,0 +1,88 @@
/* WorkorderTenantConfig.css */
.config-card .card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.config-card .card-body h6 {
font-weight: 600;
color: #495057;
margin-bottom: 0.75rem;
}
.config-card .list-group-item {
font-size: 0.9rem;
border-color: #f1f1f1;
padding-left: 0;
padding-right: 0;
}
.code-block {
background-color: #e9ecef;
padding: 10px;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.type-list .list-group-item {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: .4rem;
padding-bottom: .4rem;
}
.type-list .list-group-item span {
font-size: 0.95rem;
font-weight: 500;
}
.type-list .list-group-item small.code-font {
font-family: monospace;
color: #6c757d;
background-color: #e9ecef;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-top: 0.25rem;
}
.tenant-tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 0.25rem;
min-height: 38px;
}
.tenant-tag {
display: flex;
align-items: center;
padding: 0.3em 0.75em;
font-size: 0.9em;
}
.tenant-tag i.fa-times-circle {
margin-left: 0.5em;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.tenant-tag i.fa-times-circle:hover {
opacity: 1;
}
.tenant-edit-list .list-group-item {
padding: 0.5rem 0.75rem;
}
.tenant-edit-list .btn-link {
padding: 0;
}

View File

@@ -0,0 +1,333 @@
// WorkorderTenantConfig.js
Vue.component('workorder-tenant-config', {
template: `
<tt-card>
<div class="d-flex justify-content-between align-items-center mb-3">
<ul class="nav nav-pills">
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'configs'}" href="#"
@click.prevent="activeTab = 'configs'"><i class="fas fa-cogs mr-1"></i>
Mandanten-Konfigurationen</a></li>
<li class="nav-item"><a class="nav-link" :class="{active: activeTab === 'companies'}" href="#"
@click.prevent="activeTab = 'companies'"><i class="fas fa-building mr-1"></i>
Firmenverwaltung</a></li>
</ul>
<tt-button :text="activeTab === 'configs' ? 'Neue Konfiguration' : 'Neue Firma'" @click="openCreateModal"
icon="fas fa-plus" additional-class="btn-primary"/>
</div>
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-show="!loading && activeTab === 'configs'">
<div v-for="config in configs" :key="config.id" class="card mb-3 config-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-file-alt mr-2"></i>
<tt-autocomplete v-if="editingId === config.id" :api-url="addressApiUrl"
v-model="editableItem.addressId" @input="editableItem.name = $event.text" no-form-group
sm/>
<strong v-else>{{ config.name }}</strong>
</h5>
<div>
<tt-button v-if="editingId !== config.id" @click="startEdit(config)" icon="fas fa-edit"
additional-class="btn-sm btn-outline-primary mr-2"/>
<tt-button v-if="editingId === config.id" @click="saveItem" icon="fas fa-save"
additional-class="btn-sm btn-success mr-2"/>
<tt-button v-if="editingId === config.id" @click="cancelEdit" icon="fas fa-times"
additional-class="btn-sm btn-secondary mr-2"/>
<tt-button @click="deleteItem(config, 'configs')" icon="fas fa-trash"
additional-class="btn-sm btn-outline-danger"/>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Dokumentationstypen</h6>
<tt-positions-manager :config="docTypesConfig" v-model="editableItem.documentationTypes"
v-if="editingId === config.id"/>
<ul v-else class="list-group list-group-flush type-list">
<li v-for="doc in JSON.parse(config.documentationTypes || '[]')" class="list-group-item">
<span>{{ doc.text }}</span><small class="code-font">{{ doc.value }}</small></li>
</ul>
</div>
<div class="col-md-6">
<h6>Interventionstypen</h6>
<tt-positions-manager :config="interventionTypesConfig" v-model="editableItem.interventionTypes"
v-if="editingId === config.id"/>
<ul v-else class="list-group list-group-flush type-list">
<li v-for="intervention in JSON.parse(config.interventionTypes || '[]')" class="list-group-item">
<span>{{ intervention.text }}</span><small class="code-font">{{ intervention.value }}</small></li>
</ul>
</div>
</div>
<hr>
<div class="row mt-3">
<div class="col-md-4">
<h6>Filter für Auftragserstellung</h6>
<pre v-if="editingId !== config.id"
class="code-block">{{ JSON.stringify(JSON.parse(config.workorderCreationFilters || '{}'), null, 2) }}</pre>
<tt-textarea v-else v-model="editableJsonFilter" rows="6"/>
</div>
<div class="col-md-4">
<h6 class="mb-3">Optionen</h6>
<tt-checkbox label="Dokumentation für Tiefbau erforderlich"
v-model="editableItem.civilEngineeringDocsRequired" sm v-if="editingId === config.id"/>
<p v-else>Tiefbau-Doku: {{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}</p>
</div>
<div class="col-md-4">
<h6>Zugeordnete Firmen</h6>
<ul v-if="companiesByTenantMap[config.addressId] && companiesByTenantMap[config.addressId].length"
class="list-group">
<li v-for="company in companiesByTenantMap[config.addressId]" class="list-group-item py-1"><i
class="fas fa-building mr-2 text-muted"></i>{{ company.name }}
</li>
</ul>
<div v-else class="text-muted p-3">Keine Firmen zugeordnet.</div>
</div>
</div>
</div>
</div>
</div>
<div v-show="!loading && activeTab === 'companies'">
<div v-for="company in companies" :key="company.id" class="card mb-3 config-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-building mr-2"></i><strong>{{ company.name }}</strong></h5>
<div>
<tt-button v-if="editingId !== company.id" @click="startEdit(company)" icon="fas fa-edit"
additional-class="btn-sm btn-outline-primary mr-2"/>
<tt-button v-if="editingId === company.id" @click="saveItem" icon="fas fa-save"
additional-class="btn-sm btn-success mr-2"/>
<tt-button v-if="editingId === company.id" @click="cancelEdit" icon="fas fa-times"
additional-class="btn-sm btn-secondary mr-2"/>
<tt-button @click="deleteItem(company, 'companies')" icon="fas fa-trash"
additional-class="btn-sm btn-outline-danger"/>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Sichtbar für Mandanten</h6>
<div v-if="editingId === company.id">
<ul class="list-group tenant-edit-list mb-2">
<li v-for="(tenantId, index) in editableItem.visibleForAddressId" :key="tenantId"
class="list-group-item d-flex justify-content-between align-items-center">
<tt-resolver :value="tenantId" reference="/WorkorderTenantConfig/addressAutocomplete"
:autocomplete="true"/>
<tt-button icon="fas fa-trash" @click="removeTenant(index)"
additional-class="btn-sm btn-link text-danger"/>
</li>
</ul>
<tt-autocomplete :api-url="addressApiUrl" v-model="tenantToAdd" @input="addTenant"
placeholder="Mandant hinzufügen..." sm no-form-group/>
</div>
<ul v-else class="list-group list-group-flush">
<li v-for="tenantId in JSON.parse(company.visibleForAddressId || '[]')"
class="list-group-item py-1">
<tt-resolver :value="tenantId" reference="/WorkorderTenantConfig/addressAutocomplete"
:autocomplete="true"/>
</li>
</ul>
</div>
<div class="col-md-6">
<h6>Zugehörige Mitarbeiter <small>(mit RMLCompany-Recht)</small></h6>
<ul v-if="company.workers && company.workers.length" class="list-group list-group-flush">
<li v-for="worker in company.workers" class="list-group-item py-1">{{ worker.name }} (
{{ worker.email }})
</li>
</ul>
<div v-else class="text-muted p-3">Keine Mitarbeiter für diese Firma gefunden.</div>
</div>
</div>
</div>
</div>
</div>
<tt-modal v-if="showModal" :show.sync="showModal" :title="modalTitle" @submit="saveNewItem" :delete="false">
<div v-if="activeTab === 'configs'">
<tt-autocomplete label="Mandant" :api-url="addressApiUrl" v-model="newItem.addressId"
@input="newItem.name = $event.text" sm row required/>
</div>
<div v-if="activeTab === 'companies'">
<tt-autocomplete label="Firma" :api-url="addressApiUrl" v-model="newItem.addressId"
@input="newItem.name = $event.text" sm row required/>
<div class="form-group row">
<label class="col-form-label col-sm-4 col-form-label-sm">Sichtbar für Mandanten</label>
<div class="col-sm-8">
<div class="tenant-tags-container mb-2">
<span v-for="(tenantId, index) in newItem.visibleForAddressId" :key="tenantId"
class="badge badge-primary tenant-tag">
<tt-resolver :value="tenantId" reference="/WorkorderTenantConfig/addressAutocomplete"
:autocomplete="true"/>
<i class="fas fa-times-circle" @click="removeTenant(index, true)"></i>
</span>
</div>
<tt-autocomplete :api-url="addressApiUrl" v-model="tenantToAdd" @input="addTenant($event, true)"
placeholder="Mandant hinzufügen..." sm no-form-group/>
</div>
</div>
</div>
</tt-modal>
</tt-card>
`,
data() {
return {
loading: true, activeTab: 'configs', configs: [], companies: [], editingId: null, editableItem: {},
editableJsonFilter: '', showModal: false, newItem: {}, tenantToAdd: null,
addressApiUrl: `${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/addressAutocomplete`,
docTypesConfig: {
fields: {
text: {type: 'input', label: 'Anzeigename'},
value: {type: 'input', label: 'Schlüssel'}
}
},
interventionTypesConfig: {
fields: {
text: {type: 'input', label: 'Anzeigename'},
value: {type: 'input', label: 'Schlüssel'}
}
},
};
},
computed: {
modalTitle() {
return this.activeTab === 'configs' ? 'Neue Konfiguration erstellen' : 'Neue Firma anlegen';
},
companiesByTenantMap() {
const map = {};
this.configs.forEach(config => {
map[config.addressId] = [];
});
this.companies.forEach(company => {
try {
const visibleFor = JSON.parse(company.visibleForAddressId || '[]');
if (Array.isArray(visibleFor)) {
visibleFor.forEach(tenantId => {
if (map[tenantId]) map[tenantId].push(company);
});
}
} catch (e) {
}
});
return map;
}
},
methods: {
async fetchData() {
this.loading = true;
try {
const [configsRes, companiesRes] = await Promise.all([
axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/getTenantConfigs`),
axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/getCompanies`),
]);
this.configs = configsRes.data;
this.companies = companiesRes.data;
} catch (e) {
window.notify('error', 'Daten konnten nicht geladen werden.');
} finally {
this.loading = false;
}
},
startEdit(item) {
this.editingId = item.id;
this.editableItem = JSON.parse(JSON.stringify(item));
if (this.activeTab === 'configs') {
this.editableItem.documentationTypes = JSON.parse(this.editableItem.documentationTypes || '[]');
this.editableItem.interventionTypes = JSON.parse(this.editableItem.interventionTypes || '[]');
this.editableJsonFilter = JSON.stringify(JSON.parse(this.editableItem.workorderCreationFilters || '{}'), null, 2);
} else {
try {
this.editableItem.visibleForAddressId = JSON.parse(this.editableItem.visibleForAddressId || '[]');
} catch (e) {
this.editableItem.visibleForAddressId = [];
}
}
},
cancelEdit() {
this.editingId = null;
this.editableItem = {};
this.editableJsonFilter = '';
},
async saveItem() {
const endpoint = this.activeTab === 'configs' ? 'saveTenantConfig' : 'saveCompany';
let payload = {...this.editableItem};
if (this.activeTab === 'configs') {
try {
JSON.parse(this.editableJsonFilter);
payload.workorderCreationFilters = this.editableJsonFilter;
} catch (e) {
return window.notify('error', 'Filter für Auftragserstellung ist kein valides JSON.');
}
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/${endpoint}`, payload);
if (data.success) {
window.notify('success', data.message);
this.cancelEdit();
await this.fetchData();
} else {
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
async deleteItem(item, type) {
if (!confirm(`Soll der Eintrag "${item.name}" wirklich gelöscht werden?`)) return;
const endpoint = type === 'configs' ? 'deleteTenantConfig' : 'deleteCompany';
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/${endpoint}`, {id: item.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', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
openCreateModal() {
this.newItem = this.activeTab === 'configs'
? {
documentationTypes: [],
interventionTypes: [],
workorderCreationFilters: '{}',
civilEngineeringDocsRequired: 0
}
: {visibleForAddressId: []};
this.showModal = true;
},
async saveNewItem() {
const endpoint = this.activeTab === 'configs' ? 'saveTenantConfig' : 'saveCompany';
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderTenantConfig/${endpoint}`, this.newItem);
if (data.success) {
window.notify('success', data.message);
this.showModal = false;
await this.fetchData();
} else {
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
addTenant(tenant, isNewItem = false) {
const tenantId = typeof tenant === 'object' && tenant !== null ? tenant.value : tenant;
const targetArray = isNewItem ? this.newItem.visibleForAddressId : this.editableItem.visibleForAddressId;
if (tenantId && !targetArray.includes(tenantId)) targetArray.push(tenantId);
this.$nextTick(() => {
this.tenantToAdd = null;
});
},
removeTenant(index, isNewItem = false) {
const targetArray = isNewItem ? this.newItem.visibleForAddressId : this.editableItem.visibleForAddressId;
targetArray.splice(index, 1);
},
},
async mounted() {
await this.fetchData();
}
});