Files
thetool/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js
2026-01-07 18:23:56 +01:00

562 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ADBNetzgebiet - Netzgebietverwaltung (Vue 3 + TT-Core)
*/
const ADBNetzgebiet = {
name: 'ADBNetzgebiet',
template: `
<div class="tt-scope netzgebiet-container">
<section class="card card-in">
<!-- Header -->
<div class="pane-header">
<div class="title">
<span class="logo-dot"></span>
<span>Netzgebietverwaltung</span>
</div>
<button class="primary-btn" @click="openCreateModal">
<i class="fa-duotone fa-plus"></i> Neues Netzgebiet
</button>
</div>
<hr class="content-divider" />
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-center">
<div class="input-wrap filter-main">
<i class="fa-duotone fa-magnifying-glass input-icon"></i>
<input class="ri" v-model.trim="filters.name" placeholder="Name suchen..." @input="debouncedFilter">
</div>
<div class="input-wrap filter-md">
<i class="fa-duotone fa-key input-icon"></i>
<input class="ri" v-model.trim="filters.extref" placeholder="ExtRef..." @input="debouncedFilter">
</div>
<div class="select filter-sm">
<select v-model="filters.source" @change="applyFilter">
<option value="">Alle Quellen</option>
<option v-for="source in availableSources" :key="source" :value="source">{{ source }}</option>
</select>
</div>
<div class="select filter-sm">
<select v-model="filters.hasNetwork" @change="applyFilter">
<option value="">Netzwerk</option>
<option value="yes">Mit Netzwerk</option>
<option value="no">Ohne Netzwerk</option>
</select>
</div>
<div class="select filter-sm">
<select v-model="filters.hasCampaign" @change="applyFilter">
<option value="">Kampagne</option>
<option value="yes">Mit Kampagne</option>
<option value="no">Ohne Kampagne</option>
</select>
</div>
<div class="select filter-sm">
<select v-model="filters.hasConsent" @change="applyFilter">
<option value="">Zustimmung</option>
<option value="yes">Mit Zustimmung</option>
<option value="no">Ohne Zustimmung</option>
</select>
</div>
<button v-if="hasActiveFilters" class="icon-btn" @click="clearFilters" title="Filter zurücksetzen">
<i class="fa-duotone fa-xmark"></i>
</button>
</div>
</div>
<!-- Data Table -->
<div class="table-container">
<table class="tt-table netzgebiet-table" v-if="!isLoading && paginatedItems.length">
<thead>
<tr>
<th class="col-name">Name / ExtRef</th>
<th class="col-source">Quelle</th>
<th class="col-freigabe">Freigaben</th>
<th class="col-network">Netzwerk</th>
<th class="col-campaign">Kampagne</th>
<th class="col-consent">Zustimmung</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="item in paginatedItems" :key="item.netzgebiet.id">
<td class="col-name">
<a class="link name-link" href="#" @click.prevent="openEditModal(item)">{{ item.netzgebiet.name || '(Ohne Name)' }}</a>
<div v-if="item.netzgebiet.extref" class="sub-text mono">{{ item.netzgebiet.extref }}</div>
</td>
<td class="col-source">
<span class="source-badge">{{ item.netzgebiet.source || '—' }}</span>
<div v-if="item.netzgebiet.source_id" class="sub-text mono truncate" :title="item.netzgebiet.source_id">{{ item.netzgebiet.source_id }}</div>
</td>
<td class="col-freigabe">
<div class="freigabe-badges">
<span v-for="f in parsedFreigabe(item.netzgebiet.freigabe)" :key="f" class="freigabe-badge" :class="'f-' + f" :title="freigabeLabels[f]">{{ f.charAt(0).toUpperCase() }}</span>
<span v-if="!parsedFreigabe(item.netzgebiet.freigabe).length" class="muted">—</span>
</div>
</td>
<td class="col-network">
<template v-if="item.related.networks.length">
<a v-for="net in item.related.networks.slice(0, 2)" :key="net.id"
:href="window.TT_CONFIG.NETWORK_URL + '?id=' + net.id"
target="_blank" class="related-link">
{{ net.name }}
</a>
<span v-if="item.related.networks.length > 2" class="more-badge">+{{ item.related.networks.length - 2 }}</span>
</template>
<a v-else :href="window.TT_CONFIG.NETWORK_CREATE_URL + '?adb_netzgebiet_id=' + item.netzgebiet.id" class="create-link" title="Netzwerk erstellen">
<i class="fa-duotone fa-plus-circle"></i> Erstellen
</a>
</td>
<td class="col-campaign">
<template v-if="item.related.campaigns.length">
<a v-for="camp in item.related.campaigns.slice(0, 1)" :key="camp.id"
:href="window.TT_CONFIG.CAMPAIGN_URL + '?id=' + camp.id"
target="_blank" class="related-link">
{{ camp.name }}
</a>
<span v-if="item.related.campaigns.length > 1" class="more-badge">+{{ item.related.campaigns.length - 1 }}</span>
</template>
<a v-else-if="item.related.networks.length" :href="window.TT_CONFIG.CAMPAIGN_CREATE_URL + '?network_id=' + item.related.networks[0].id" class="create-link" title="Kampagne erstellen">
<i class="fa-duotone fa-plus-circle"></i> Erstellen
</a>
<span v-else class="muted">—</span>
</td>
<td class="col-consent">
<template v-if="item.related.consent_projects.length">
<a v-for="cons in item.related.consent_projects.slice(0, 1)" :key="cons.id"
:href="window.TT_CONFIG.CONSENT_URL + '?id=' + cons.id"
target="_blank" class="related-link">
{{ cons.name }}
</a>
<span v-if="item.related.consent_projects.length > 1" class="more-badge">+{{ item.related.consent_projects.length - 1 }}</span>
</template>
<a v-else :href="window.TT_CONFIG.CONSENT_CREATE_URL + '?adb_netzgebiet_id=' + item.netzgebiet.id" class="create-link" title="Zustimmungsprojekt erstellen">
<i class="fa-duotone fa-plus-circle"></i> Erstellen
</a>
</td>
<td class="col-actions">
<button class="icon-btn" @click.prevent="openEditModal(item)" title="Bearbeiten"><i class="fa-duotone fa-pen"></i></button>
<button class="icon-btn" @click.prevent="copyNetzgebiet(item)" title="Kopieren"><i class="fa-duotone fa-copy"></i></button>
<button class="icon-btn" @click.prevent="openHistoryModal(item)" title="Verlauf"><i class="fa-duotone fa-clock-rotate-left"></i></button>
</td>
</tr>
</tbody>
</table>
<!-- Loading State -->
<div v-if="isLoading" class="table-placeholder">
<i class="fa-duotone fa-spinner fa-spin"></i>
<span>Lade Netzgebiete...</span>
</div>
<!-- Empty State -->
<div v-if="!isLoading && !filteredNetzgebiete.length" class="table-placeholder">
<i class="fa-duotone fa-database"></i>
<span>Keine Netzgebiete gefunden.</span>
</div>
</div>
<!-- Pagination -->
<div class="pagination-bar" v-if="!isLoading && filteredNetzgebiete.length">
<div class="pagination-info">
{{ paginationStart }}{{ paginationEnd }} von {{ filteredNetzgebiete.length }} Netzgebieten
</div>
<div class="pagination-controls">
<select v-model.number="pageSize" @change="currentPage = 1" class="page-size-select">
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
<button class="icon-btn" :disabled="currentPage <= 1" @click="currentPage--"><i class="fa-duotone fa-chevron-left"></i></button>
<span class="page-indicator">{{ currentPage }} / {{ totalPages }}</span>
<button class="icon-btn" :disabled="currentPage >= totalPages" @click="currentPage++"><i class="fa-duotone fa-chevron-right"></i></button>
</div>
</div>
</section>
<!-- Edit/Create Modal -->
<tt-dialog :show="showEditModal" :title="editItem && editItem.id ? 'Netzgebiet bearbeiten' : 'Neues Netzgebiet'" size="wide" @close="showEditModal = false">
<div v-if="editItem" class="modal-form">
<!-- Copy From Section -->
<div class="copy-from-section">
<div class="copy-from-row">
<div class="select copy-select">
<select v-model="copyFromId">
<option value="">Kopieren von...</option>
<option v-for="item in copyableNetzgebiete" :key="item.netzgebiet.id" :value="item.netzgebiet.id">
{{ item.netzgebiet.name }} {{ item.netzgebiet.extref ? '(' + item.netzgebiet.extref + ')' : '' }}
</option>
</select>
</div>
<button class="ghost-btn copy-btn" @click="copyFromNetzgebiet" :disabled="!copyFromId" title="Felder kopieren">
<i class="fa-duotone fa-copy"></i> Kopieren
</button>
</div>
<div class="copy-hint">Kopiert: Quelle, Freigaben und Optionen</div>
</div>
<hr class="form-divider" />
<div class="form-grid">
<div class="field span-2">
<label>Name *</label>
<input class="ri" v-model="editItem.name" placeholder="Name des Netzgebiets">
</div>
<div class="field">
<label>Externe Referenz</label>
<input class="ri" v-model="editItem.extref" placeholder="ExtRef">
</div>
<div class="field">
<label>Quelle</label>
<div class="select">
<select v-model="editItem.source">
<option value="">Bitte wählen...</option>
<option value="rimo-rest-api">rimo-rest-api</option>
<option value="csv">csv</option>
<option value="csv-rimo">csv-rimo</option>
<option value="manual">manual</option>
<option value="xinon_qgis">xinon_qgis</option>
<option value="citycom-oan-api">citycom-oan-api</option>
<option value="test">test</option>
</select>
</div>
</div>
<div class="field">
<label>Source ID</label>
<input class="ri" v-model="editItem.source_id" placeholder="Source ID">
</div>
</div>
<div class="form-section">
<label class="section-label">Freigaben</label>
<div class="checkbox-row">
<label v-for="f in freigabeOptions" :key="f.key" class="checkbox-field">
<input type="checkbox" v-model="editItem.freigabe[f.key]">
<span>{{ f.label }}</span>
</label>
</div>
</div>
<div class="form-section">
<label class="section-label">Optionen</label>
<div class="options-grid">
<label v-for="opt in optionsConfig" :key="opt.key" class="checkbox-field" :title="opt.tooltip">
<input type="checkbox" v-model="editItem.options[opt.key]" :true-value="1" :false-value="0">
<span>{{ opt.label }}</span>
</label>
</div>
<div class="form-grid mt-3">
<div class="field">
<label>MPH Min Homes (Auto-Zählung)</label>
<input class="ri" type="number" v-model.number="editItem.options.mph_min_homes_tool_automatic_count" min="0">
</div>
</div>
</div>
</div>
<template #footer>
<button class="ghost-btn" @click="showEditModal = false" :disabled="isSaving">Abbrechen</button>
<button class="primary-btn" @click="saveNetzgebiet" :disabled="isSaving || !editItem?.name">
<span v-if="!isSaving">Speichern</span>
<span v-else class="btn-loader"></span>
</button>
</template>
</tt-dialog>
<!-- History Modal -->
<tt-dialog :show="showHistoryModal" :title="historyTitle" size="wide" @close="showHistoryModal = false">
<div class="history-container">
<div v-if="historyLoading" class="table-placeholder compact">
<i class="fa-duotone fa-spinner fa-spin"></i>
</div>
<div v-else-if="!filteredHistory.length" class="table-placeholder compact">
<i class="fa-duotone fa-clock-rotate-left"></i>
<span>Kein Verlauf vorhanden.</span>
</div>
<div v-else class="history-list">
<div v-for="entry in filteredHistory" :key="entry.id" class="history-entry" :class="'action-' + entry.action">
<div class="history-icon">
<i v-if="entry.action === 'update'" class="fa-duotone fa-pen-to-square"></i>
<i v-else-if="entry.action === 'create'" class="fa-duotone fa-plus-circle"></i>
<i v-else-if="entry.action === 'delete'" class="fa-duotone fa-trash-can"></i>
</div>
<div class="history-content">
<div class="history-header">
<strong>{{ translateAction(entry.action) }}</strong>
<span v-if="entry.action === 'update'" class="field-label">{{ translateField(entry.field) }}</span>
<span class="history-meta">{{ entry.user_name || 'System' }} · {{ formatTimestamp(entry.timestamp) }}</span>
</div>
<div v-if="entry.action === 'update'" class="history-diff">
<span class="diff-old" :class="{ expandable: isLongValue(entry.field, entry.old_value), expanded: expandedIds[entry.id + '_old'] }" @click="toggleExpand(entry.id + '_old')">{{ formatValue(entry.field, entry.old_value) }}</span>
<i class="fa-duotone fa-arrow-right"></i>
<span class="diff-new" :class="{ expandable: isLongValue(entry.field, entry.new_value), expanded: expandedIds[entry.id + '_new'] }" @click="toggleExpand(entry.id + '_new')">{{ formatValue(entry.field, entry.new_value) }}</span>
</div>
</div>
</div>
</div>
</div>
</tt-dialog>
</div>
`,
data() {
return {
window: window,
isLoading: true,
isSaving: false,
netzgebiete: [],
currentPage: 1,
pageSize: 50,
filters: { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' },
filterDebounce: null,
showEditModal: false,
editItem: null,
copyFromId: '',
showHistoryModal: false,
historyLoading: false,
historyItems: [],
historyTitle: 'Verlauf',
expandedIds: {},
freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' },
freigabeOptions: [
{ key: 'interest', label: 'Interest' },
{ key: 'provision', label: 'Provision' },
{ key: 'order', label: 'Order' },
{ key: 'reorder', label: 'Reorder' }
],
optionsConfig: [
{ key: 'create_address_parts', label: 'create_address_parts', tooltip: 'Neue Straßen/PLZ/Ort anlegen' },
{ key: 'update_freigabe', label: 'update_freigabe', tooltip: 'Setzt Freigabe auf Basis Netzgebiet' },
{ key: 'update_address', label: 'update_address', tooltip: 'Straßennamen ändern' },
{ key: 'hausnummer_dont_overwrite_netzgebiet', label: 'dont_overwrite_netzgebiet', tooltip: 'Netzgebiete nicht überschreiben' },
{ key: 'create_preorder', label: 'create_preorder', tooltip: 'Bestellungen erstellen (SBIDI)' },
{ key: 'preorder_only_oaid', label: 'preorder_only_oaid', tooltip: 'SBIDI OAID aus RIMO' },
{ key: 'wo_ignore_status', label: 'wo_ignore_status', tooltip: 'Status ignorieren' },
{ key: 'delete_units', label: 'delete_units', tooltip: 'Homes löschen die nicht in RIMO sind' },
{ key: 'unit_create_oaid', label: 'unit_create_oaid', tooltip: 'OAID bei Unit erstellen' }
],
defaultOptions: {
create_address_parts: 0, update_freigabe: 1, update_address: 1,
hausnummer_dont_overwrite_netzgebiet: 0, create_preorder: 0,
preorder_only_oaid: 0, wo_ignore_status: 0, delete_units: 0,
mph_min_homes_tool_automatic_count: 3, unit_create_oaid: 0
}
};
},
computed: {
availableSources() {
const sources = new Set();
this.netzgebiete.forEach(item => {
if (item.netzgebiet?.source) sources.add(item.netzgebiet.source);
});
return Array.from(sources).sort();
},
hasActiveFilters() {
return Object.values(this.filters).some(v => v);
},
filteredNetzgebiete() {
return this.netzgebiete.filter(item => {
const n = item.netzgebiet;
if (!n) return false;
if (this.filters.name && !n.name?.toLowerCase().includes(this.filters.name.toLowerCase())) return false;
if (this.filters.extref && !n.extref?.toLowerCase().includes(this.filters.extref.toLowerCase())) return false;
if (this.filters.source && n.source !== this.filters.source) return false;
const hasNetwork = item.related?.networks?.length > 0;
const hasCampaign = item.related?.campaigns?.length > 0;
const hasConsent = item.related?.consent_projects?.length > 0;
if (this.filters.hasNetwork === 'yes' && !hasNetwork) return false;
if (this.filters.hasNetwork === 'no' && hasNetwork) return false;
if (this.filters.hasCampaign === 'yes' && !hasCampaign) return false;
if (this.filters.hasCampaign === 'no' && hasCampaign) return false;
if (this.filters.hasConsent === 'yes' && !hasConsent) return false;
if (this.filters.hasConsent === 'no' && hasConsent) return false;
return true;
});
},
totalPages() { return Math.ceil(this.filteredNetzgebiete.length / this.pageSize) || 1; },
paginatedItems() {
const start = (this.currentPage - 1) * this.pageSize;
return this.filteredNetzgebiete.slice(start, start + this.pageSize);
},
paginationStart() { return this.filteredNetzgebiete.length ? (this.currentPage - 1) * this.pageSize + 1 : 0; },
paginationEnd() { return Math.min(this.currentPage * this.pageSize, this.filteredNetzgebiete.length); },
filteredHistory() {
return this.historyItems.filter(e => !['edit', 'create'].includes(e.field));
},
copyableNetzgebiete() {
return this.netzgebiete.filter(item => {
if (!item.netzgebiet?.id) return false;
if (this.editItem?.id && item.netzgebiet.id === this.editItem.id) return false;
return true;
});
}
},
watch: {
filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; }
},
async mounted() { await this.fetchNetzgebiete(); },
methods: {
debouncedFilter() {
clearTimeout(this.filterDebounce);
this.filterDebounce = setTimeout(() => this.currentPage = 1, 300);
},
applyFilter() { this.currentPage = 1; },
clearFilters() {
this.filters = { name: '', extref: '', source: '', hasNetwork: '', hasCampaign: '', hasConsent: '' };
this.currentPage = 1;
},
async fetchNetzgebiete() {
this.isLoading = true;
try {
const response = await axios.get(window.TT_CONFIG.GET_URL);
this.netzgebiete = response.data.success ? (response.data.data || []) : (response.data || []);
} catch (error) {
console.error('Fehler:', error);
window.notify?.('error', 'Netzgebiete konnten nicht geladen werden.');
} finally {
this.isLoading = false;
}
},
parsedFreigabe(json) {
try { return JSON.parse(json || '[]') || []; }
catch { return []; }
},
openCreateModal() {
this.copyFromId = '';
this.editItem = {
id: null, name: '', extref: '', source: '', source_id: '',
freigabe: { interest: true, provision: true, order: true, reorder: true },
options: { ...this.defaultOptions }
};
this.showEditModal = true;
},
openEditModal(item) {
this.copyFromId = '';
const n = item.netzgebiet;
let options = {};
try { options = JSON.parse(n.options || '{}'); } catch {}
let freigabeArr = [];
try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {}
const freigabeObj = {};
['interest', 'provision', 'order', 'reorder'].forEach(f => freigabeObj[f] = freigabeArr.includes(f));
this.editItem = {
id: n.id, name: n.name || '', extref: n.extref || '',
source: n.source || '', source_id: n.source_id || '',
freigabe: freigabeObj,
options: { ...this.defaultOptions, ...options }
};
this.showEditModal = true;
},
copyFromNetzgebiet() {
if (!this.copyFromId || !this.editItem) return;
const source = this.netzgebiete.find(item => item.netzgebiet?.id == this.copyFromId);
if (!source) return;
const n = source.netzgebiet;
// Copy source
if (n.source) this.editItem.source = n.source;
// Copy freigabe
let freigabeArr = [];
try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {}
['interest', 'provision', 'order', 'reorder'].forEach(f => {
this.editItem.freigabe[f] = freigabeArr.includes(f);
});
// Copy options
let options = {};
try { options = JSON.parse(n.options || '{}'); } catch {}
this.editItem.options = { ...this.defaultOptions, ...options };
window.notify?.('success', `Felder von "${n.name}" kopiert.`);
this.copyFromId = '';
},
async copyNetzgebiet(item) {
if (!confirm(`Wollen Sie das Netzgebiet "${item.netzgebiet.name}" kopieren?`)) return;
this.isSaving = true;
try {
const response = await axios.post(window.TT_CONFIG.COPY_URL, { id: item.netzgebiet.id });
if (response.data.success) {
window.notify?.('success', response.data.message);
await this.fetchNetzgebiete();
} else {
window.notify?.('error', response.data.message || 'Fehler beim Kopieren.');
}
} catch (error) {
console.error('Fehler:', error);
window.notify?.('error', 'Netzwerkfehler beim Kopieren.');
} finally {
this.isSaving = false;
}
},
async saveNetzgebiet() {
if (!this.editItem?.name) return;
this.isSaving = true;
const freigabeArray = Object.keys(this.editItem.freigabe).filter(k => this.editItem.freigabe[k]);
const payload = {
id: this.editItem.id, name: this.editItem.name, extref: this.editItem.extref,
source: this.editItem.source, source_id: this.editItem.source_id,
freigabe: freigabeArray, options: this.editItem.options
};
try {
const response = await axios.post(window.TT_CONFIG.SAVE_URL, payload);
if (response.data.success) {
window.notify?.('success', response.data.message);
this.showEditModal = false;
await this.fetchNetzgebiete();
} else {
window.notify?.('error', response.data.message || 'Fehler beim Speichern.');
}
} catch { window.notify?.('error', 'Netzwerkfehler.'); }
finally { this.isSaving = false; }
},
async openHistoryModal(item) {
this.historyTitle = `Verlauf: ${item.netzgebiet.name}`;
this.showHistoryModal = true;
this.historyLoading = true;
this.historyItems = [];
try {
const response = await axios.get(window.TT_CONFIG.HISTORY_URL + '?id=' + item.netzgebiet.id);
this.historyItems = response.data.success ? (response.data.data || []) : (response.data || []);
} catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); }
finally { this.historyLoading = false; }
},
translateAction(action) { return { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht' }[action] || action; },
translateField(field) {
return { name: 'Name', extref: 'ExtRef', source: 'Quelle', source_id: 'Source ID',
freigabe: 'Freigaben', options: 'Optionen', unit_counts: 'Einheiten' }[field] || field;
},
formatTimestamp(ts) {
if (!ts) return '—';
try { return new Date(ts.replace(' ', 'T')).toLocaleString('de-AT'); }
catch { return ts; }
},
formatValue(field, value) {
if (value === null || value === undefined || value === '') return '—';
if (['freigabe', 'options'].includes(field)) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (field === 'freigabe' && Array.isArray(parsed)) return parsed.join(', ') || '—';
if (field === 'options' && typeof parsed === 'object') {
const entries = Object.entries(parsed).filter(([,v]) => v !== 0 && v !== '0');
return entries.map(([k,v]) => `${k}: ${v}`).join(', ') || '—';
}
} catch {}
}
return String(value);
},
isLongValue(field, value) {
return this.formatValue(field, value).length > 40;
},
toggleExpand(id) {
this.expandedIds[id] = !this.expandedIds[id];
}
}
};
if (window.VueApp) {
window.VueApp.component('a-d-b-netzgebiet', ADBNetzgebiet);
}