Merge branch 'master' into 'spidev'

# Conflicts:
#   Layout/default/Device/Detail.php
This commit is contained in:
Daniel Spitzer
2025-12-27 18:33:02 +00:00
213 changed files with 24020 additions and 2713 deletions
@@ -0,0 +1,530 @@
/**
* ADBNetzgebiet - Netzgebietverwaltung Styles
* Optimized for ~1720px width (50% of 21:9 1440p)
*/
/* ===== Container ===== */
.tt-scope.netzgebiet-container {
background: transparent;
color: var(--tt-text);
display: grid;
gap: 16px;
max-width: 90vw;
margin: 24px auto 0;
padding: 20px 0;
}
.tt-scope.netzgebiet-container .card {
margin: 0;
border-radius: var(--tt-radius, 10px);
border: none;
box-shadow: var(--tt-shadow, 0 8px 24px rgba(0, 83, 132, .08));
}
/* ===== Header ===== */
.tt-scope.netzgebiet-container .pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
background: linear-gradient(135deg, #f8fbff 0%, #f0f7fd 100%);
padding: 16px 20px;
margin: -14px -14px 14px -14px;
border-radius: var(--tt-radius, 10px) var(--tt-radius, 10px) 0 0;
border-bottom: 2px solid #e3f0f8;
}
.tt-scope.netzgebiet-container .pane-header .title {
display: flex;
align-items: center;
gap: 12px;
font-weight: 800;
letter-spacing: .4px;
font-size: 22px;
user-select: none;
color: var(--tt-accent, #005384);
text-shadow: 0 1px 2px rgba(0,83,132,.1);
}
.tt-scope.netzgebiet-container .logo-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%);
box-shadow: 0 0 0 3px rgba(15,157,88,.15);
display: inline-block;
}
.tt-scope.netzgebiet-container .content-divider {
border: none;
height: 1px;
background-color: var(--tt-border);
margin: 16px 0;
}
/* ===== Filter Bar ===== */
.tt-scope.netzgebiet-container .filter-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid var(--tt-border);
}
.tt-scope.netzgebiet-container .filter-center {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.tt-scope.netzgebiet-container .filter-main { width: 200px; }
.tt-scope.netzgebiet-container .filter-md { width: 140px; }
.tt-scope.netzgebiet-container .filter-sm { width: 120px; }
.tt-scope.netzgebiet-container .filter-bar .ri,
.tt-scope.netzgebiet-container .filter-bar select {
height: 34px;
font-size: 13px;
}
.tt-scope.netzgebiet-container .filter-bar .ri {
padding: 6px 10px 6px 34px;
}
.tt-scope.netzgebiet-container .filter-bar .select select {
padding: 6px 28px 6px 10px;
}
.tt-scope.netzgebiet-container .filter-bar .input-icon {
font-size: 13px;
left: 11px;
}
/* ===== Table Container ===== */
.tt-scope .table-container {
overflow-x: auto;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
/* ===== Table ===== */
.tt-scope .netzgebiet-table {
width: 100%;
min-width: 900px;
table-layout: fixed;
border-collapse: collapse;
font-size: 13px;
}
.tt-scope .netzgebiet-table th,
.tt-scope .netzgebiet-table td {
padding: 10px 12px;
vertical-align: top;
border-bottom: 1px solid #eef1f5;
}
.tt-scope .netzgebiet-table thead th {
position: sticky;
top: 0;
background: #f6f9fc;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #667085;
z-index: 10;
vertical-align: middle;
}
.tt-scope .netzgebiet-table tbody tr:hover {
background: #fafbfc;
}
/* Column Widths - optimized for 1720px */
.tt-scope .col-name { width: 20%; }
.tt-scope .col-source { width: 14%; }
.tt-scope .col-freigabe { width: 10%; }
.tt-scope .col-network { width: 18%; }
.tt-scope .col-campaign { width: 16%; }
.tt-scope .col-consent { width: 16%; }
.tt-scope .col-actions { width: 6%; text-align: right; }
/* ===== Name Cell ===== */
.tt-scope .name-link {
font-weight: 600;
color: var(--tt-accent);
}
.tt-scope .name-link:hover {
text-decoration: underline;
}
.tt-scope .sub-text {
font-size: 11px;
color: var(--tt-muted);
margin-top: 2px;
}
.tt-scope .truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: block;
}
/* ===== Source Badge ===== */
.tt-scope .source-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 4px;
background: #e8f0f6;
color: #3a5a70;
}
/* ===== Freigabe Badges ===== */
.tt-scope .freigabe-badges {
display: flex;
gap: 4px;
}
.tt-scope .freigabe-badge {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
border-radius: 4px;
color: #fff;
cursor: default;
}
.tt-scope .freigabe-badge.f-interest { background: #1565c0; }
.tt-scope .freigabe-badge.f-provision { background: #e65100; }
.tt-scope .freigabe-badge.f-order { background: #2e7d32; }
.tt-scope .freigabe-badge.f-reorder { background: #7b1fa2; }
/* ===== Related Links ===== */
.tt-scope .related-link {
display: block;
font-size: 12px;
color: var(--tt-accent);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.5;
}
.tt-scope .related-link:hover {
text-decoration: underline;
}
.tt-scope .more-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
background: var(--tt-accent);
color: #fff;
margin-left: 4px;
}
.tt-scope .create-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--tt-muted);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.15s, color 0.15s;
}
.tt-scope .create-link:hover {
opacity: 1;
color: var(--tt-accent);
}
.tt-scope .create-link i {
font-size: 11px;
}
/* ===== Action Buttons ===== */
.tt-scope .col-actions {
white-space: nowrap;
}
.tt-scope .col-actions .icon-btn {
padding: 5px 7px;
}
/* ===== Pagination Bar ===== */
.tt-scope .pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: #f8fafc;
border-top: 1px solid var(--tt-border);
font-size: 13px;
}
.tt-scope .pagination-info {
color: var(--tt-muted);
}
.tt-scope .pagination-controls {
display: flex;
align-items: center;
gap: 10px;
}
.tt-scope .page-size-select {
height: 30px;
font-size: 12px;
padding: 4px 24px 4px 8px;
border-radius: 6px;
border: 1px solid var(--tt-border);
background: #fff;
}
.tt-scope .page-indicator {
font-size: 12px;
color: var(--tt-muted);
min-width: 60px;
text-align: center;
}
/* ===== Table Placeholder ===== */
.tt-scope .table-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
color: var(--tt-muted);
font-size: 14px;
}
.tt-scope .table-placeholder.compact {
padding: 30px 16px;
}
.tt-scope .table-placeholder i {
font-size: 28px;
color: var(--tt-brand-blue);
opacity: 0.5;
}
/* ===== Modal Form ===== */
.tt-scope .modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.tt-scope .form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.tt-scope .form-grid .field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tt-scope .form-grid .span-2 {
grid-column: span 2;
}
.tt-scope .form-grid label,
.tt-scope .form-section label:not(.checkbox-field) {
font-size: 11px;
font-weight: 600;
color: var(--tt-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tt-scope .form-section {
border-top: 1px solid var(--tt-border);
padding-top: 16px;
}
.tt-scope .section-label {
display: block;
font-size: 13px;
font-weight: 700;
margin-bottom: 12px;
color: var(--tt-text);
}
/* ===== Checkbox Fields ===== */
.tt-scope .checkbox-row {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.tt-scope .checkbox-field {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
.tt-scope .checkbox-field input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--tt-brand-blue);
}
.tt-scope .options-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
/* ===== History ===== */
.tt-scope .history-container {
max-height: 60vh;
overflow-y: auto;
}
.tt-scope .history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.tt-scope .history-entry {
display: flex;
gap: 10px;
padding: 8px 12px;
background: #fff;
border-radius: 6px;
border: 1px solid var(--tt-border);
border-left-width: 3px;
}
.tt-scope .history-entry.action-update { border-left-color: #f59f0b; }
.tt-scope .history-entry.action-create { border-left-color: var(--tt-ok); }
.tt-scope .history-entry.action-delete { border-left-color: var(--tt-bad); }
.tt-scope .history-icon {
font-size: 12px;
width: 18px;
text-align: center;
padding-top: 1px;
color: var(--tt-muted);
}
.tt-scope .history-entry.action-update .history-icon { color: #f59f0b; }
.tt-scope .history-entry.action-create .history-icon { color: var(--tt-ok); }
.tt-scope .history-entry.action-delete .history-icon { color: var(--tt-bad); }
.tt-scope .history-content {
flex: 1;
min-width: 0;
}
.tt-scope .history-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.tt-scope .history-header strong {
font-weight: 600;
}
.tt-scope .history-header .field-label {
background: #f1f3f5;
padding: 1px 6px;
border-radius: 3px;
font-family: var(--tt-mono);
font-size: 10px;
}
.tt-scope .history-meta {
margin-left: auto;
font-size: 10px;
color: var(--tt-muted);
}
.tt-scope .history-diff {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 11px;
}
.tt-scope .history-diff i {
color: var(--tt-muted);
font-size: 9px;
}
.tt-scope .history-diff .diff-old,
.tt-scope .history-diff .diff-new {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-family: var(--tt-mono);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all 0.15s ease;
}
.tt-scope .history-diff .diff-old.expandable,
.tt-scope .history-diff .diff-new.expandable {
cursor: pointer;
}
.tt-scope .history-diff .diff-old.expandable:hover,
.tt-scope .history-diff .diff-new.expandable:hover {
filter: brightness(0.97);
}
.tt-scope .history-diff .diff-old.expanded,
.tt-scope .history-diff .diff-new.expanded {
max-width: 400px;
white-space: normal;
word-break: break-word;
}
.tt-scope .history-diff .diff-old {
background: #fff5f5;
color: #c92a2a;
border: 1px solid #ffc9c9;
}
.tt-scope .history-diff .diff-new {
background: #eaf7ef;
color: #15803d;
border: 1px solid #c9e6d8;
}
/* ===== Utilities ===== */
.tt-scope .mono { font-family: var(--tt-mono); }
.tt-scope .muted { color: var(--tt-muted); }
.tt-scope .mt-3 { margin-top: 12px; }
@@ -0,0 +1,489 @@
/**
* 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="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">
<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,
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));
}
},
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.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) {
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;
},
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);
}
@@ -1,13 +1,15 @@
Vue.component('AddressTickets', {
template: `
<div>
<tt-card class="mb-4">
<h3 class="text-center mb-3">Tickets</h3>
<div class="table-responsive">
<tt-card class="mb-4 mt-4">
<h3 class="text-center mb-3">Tickets - {{ customerName }} ({{ customerNumber }})</h3>
<div v-if="tickets.length === 0" class="alert alert-info text-center">
Keine Tickets gefunden.
</div>
<div v-else class="table-responsive">
<table class="table table-striped table-hover table-sm">
<thead class="thead-light">
<tr>
<th>Kundennummer</th>
<th>Erstellt am</th>
<th>Betreff</th>
<th>Letztes Update</th>
@@ -16,7 +18,6 @@ Vue.component('AddressTickets', {
</thead>
<tbody>
<tr v-for="ticket in tickets" :key="ticket.id">
<td>{{ ticket.customField7 }}</td>
<td>{{ formatDate(ticket.createdAt) }}</td>
<td>{{ ticket.subject }}</td>
<td>{{ formatDate(ticket.updatedAt) }}</td>
@@ -30,7 +31,10 @@ Vue.component('AddressTickets', {
<tt-card>
<h3 class="text-center mb-3">Lieferscheine</h3>
<div class="table-responsive">
<div v-if="shippingNotes.length === 0" class="alert alert-info text-center">
Keine Lieferscheine gefunden.
</div>
<div v-else class="table-responsive">
<table class="table table-striped table-hover table-sm">
<thead class="thead-light">
<tr>
@@ -65,10 +69,15 @@ Vue.component('AddressTickets', {
return {window: window};
},
computed: {
customerName() {
return this.window.TT_CONFIG?.CUSTOMER_NAME || '';
},
customerNumber() {
return this.window.TT_CONFIG?.CUSTOMER_NUMBER || '';
},
tickets() {
return (this.window.TT_CONFIG?.TICKETS || []).map(t => ({
id: t.id,
customField7: t.customField7,
createdAt: t.createdAt,
subject: t.subject,
updatedAt: t.updatedAt,
@@ -1,10 +1,22 @@
window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [
{
"key": "openHistory",
"title": "Historie",
"class": "fas fa-history text-info",
"condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1',
},
{
"key": "reserve",
"title": "Reservieren",
"class": "fas fa-calendar-alt btn-outline-warning",
"class": "fas fa-calendar-alt text-warning",
"condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1',
},
{
"key": "print",
"title": "Label drucken",
"class": "fas fa-print text-secondary",
"condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1',
}
];
// =================================================================================
// Main Asset Management Component
@@ -24,6 +36,10 @@ Vue.component('asset-management', {
v-if="reservationModalAsset"
:asset="reservationModalAsset"
@close="reservationModalAsset = null; $refs.table.$refs.table.refreshTable()"/>
<asset-print-label-modal
v-if="printModalAsset"
:asset="printModalAsset"
@close="printModalAsset = null"/>
<button v-if="window.TT_CONFIG.ASSET_ADMIN === '1'" @click="modalId = 'create'" class="btn btn-primary">Anlage erstellen</button>
@@ -31,7 +47,9 @@ Vue.component('asset-management', {
ref="table"
emit-edit
@edit="modalId = $event.id"
@openHistory="journalModalAssetId = $event.id"
@reserve="reservationModalAsset = $event"
@print="printModalAsset = $event"
:crud-config="crudConfig">
<template v-slot:assetdetails="{ row }">
@@ -51,15 +69,6 @@ Vue.component('asset-management', {
@update="updateTableWithoutModalsOpening"/>
</template>
<template v-slot:journal="{ row }">
<tt-button
sm
icon="fas fa-history"
title="Historie"
@click="journalModalAssetId = row.id"
additional-class="btn-outline-info"/>
</template>
<template v-slot:serviceduedate="{ row }">
<span v-if="row.serviceDueDate" :class="{'text-danger font-weight-bold': isDatePast(row.serviceDueDate)}">
{{ formatDate(row.serviceDueDate, 'DD.MM.YYYY') }}
@@ -75,6 +84,7 @@ Vue.component('asset-management', {
modalId: null,
journalModalAssetId: null,
reservationModalAsset: null,
printModalAsset: null,
crudConfig: window.TT_CONFIG.CRUD_CONFIG,
}
},
@@ -97,6 +107,29 @@ Vue.component('asset-management', {
}
});
// =================================================================================
// Asset Print Label Modal
// =================================================================================
Vue.component('asset-print-label-modal', {
props: { asset: { type: Object, required: true } },
template: `
<tt-modal :show="true" :title="'Label für ' + asset.name + ' drucken'" :save="false" :delete="false" @update:show="$emit('close')">
<div class="text-center">
<p>Wählen Sie die gewünschte Label-Größe:</p>
<tt-button text="25mm" @click="printLabel(25)" additional-class="btn-primary mr-2"/>
<tt-button text="50mm" @click="printLabel(50)" additional-class="btn-primary"/>
</div>
</tt-modal>
`,
methods: {
printLabel(size) {
const url = window.TT_CONFIG.BASE_PATH + "/AssetManagement/printLabel?id=" + this.asset.id + "&size=" +size;
window.open(url, '_blank');
this.$emit('close');
}
}
});
// =================================================================================
// Asset Image Component
// =================================================================================
@@ -652,8 +685,8 @@ Vue.component('asset-reservation-modal', {
<div class="card-body">
<h5 class="card-title">Neue Reservierung</h5>
<tt-autocomplete label="Mitarbeiter" :api-url="userAutoCompleteUrl" v-model="newReservation.userId" sm row/>
<tt-date-picker label="Startdatum" v-model="newReservation.startDate" :date-range="false" sm row/>
<tt-date-picker label="Enddatum" v-model="newReservation.endDate" :date-range="false" :disabled="isPermanent === 1" sm row/>
<tt-date-picker label="Startdatum" v-model="newReservation.startDate" :date-range="false" :time-picker="false" sm row/>
<tt-date-picker label="Enddatum" v-model="newReservation.endDate" :date-range="false" :time-picker="false" :disabled="isPermanent === 1" sm row/>
<tt-checkbox label="Dauerhaft" v-model="isPermanent" sm row/>
<tt-textarea label="Notizen" v-model="newReservation.notes" sm row/>
<tt-button text="Reservierung speichern" @click="saveReservation" additional-class="btn-primary float-right"/>
@@ -1,20 +1,44 @@
/* Cpeprovisioning.css */
body {
overflow: hidden;
overflow-y: auto;
}
/* --- Page & Filter Layout --- */
.cpe-provisioning-page {
padding-bottom: 2rem;
}
.cpe-provisioning-page .form-group {
margin-bottom: 0;
}
.cpe-provisioning-page .filter-wrapper {
background: #fff;
padding: 0.75rem;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.cpe-provisioning-page .filter-wrapper .form-control,
.cpe-provisioning-page .filter-wrapper .custom-select {
height: 31px;
}
.cpe-provisioning-page .filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
align-items: end;
}
.cpe-provisioning-page .filter-actions {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
align-items: flex-end;
padding-bottom: 2px;
}
.loading-indicator, .no-results-indicator {
text-align: center;
padding: 4rem 2rem;
@@ -23,138 +47,264 @@ body {
/* --- Cards Container --- */
.cpe-cards-container {
display: grid;
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
/* --- Single Card Styling --- */
.cpe-card {
background-color: #fff;
border: 1px solid #dee2e6;
border-left: 5px solid transparent;
border-radius: 0.5rem;
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
border-left: 4px solid #adb5bd;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
transition: all 0.2s ease;
}
.cpe-card.is-dirty {
border-left-color: #f7c423; /* Yellow accent for dirty */
border-left-color: #fcc419; /* Yellow accent for dirty */
}
.cpe-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
/* --- Card Header --- */
.cpe-card-header {
display: grid;
grid-template-columns: minmax(200px, 1.5fr) 2fr auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1rem;
padding: 0.5rem 1rem;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
gap: 1rem;
flex-wrap: wrap;
}
.cpe-card-header .customer-info strong {
color: #005384;
font-size: 1.1rem;
.customer-info {
flex: 1;
min-width: 250px;
}
.cpe-card-header .customer-info small {
display: block;
font-size: 0.8rem;
.customer-info .name {
color: #1864ab;
font-weight: 600;
font-size: 1.05rem;
margin-right: 0.5rem;
}
.location-contact-header {
.customer-info .meta {
font-size: 0.85rem;
color: #495057;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.1rem 1rem;
}
.location-info {
flex: 2;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
font-size: 0.85rem;
color: #495057;
}
.location-info div {
display: flex;
align-items: center;
}
.location-info i {
margin-right: 0.4rem;
color: #adb5bd;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
font-size: 1.1rem;
color: #005384;
gap: 0.75rem;
font-size: 1rem;
}
.header-actions a { color: inherit; transition: color 0.2s; }
.header-actions a:hover { color: #f7c423; }
.header-actions a, .header-actions span {
color: #868e96;
transition: color 0.2s;
cursor: pointer;
}
.header-actions a:hover, .header-actions span:hover { color: #228be6; }
.header-actions .text-purple { color: #be4bdb; }
/* --- Card Content Grid --- */
.cpe-card-content {
padding: 0.75rem 1rem;
padding: 1rem;
display: grid;
grid-template-columns: minmax(280px, 1.25fr) minmax(280px, 1.25fr) minmax(280px, 1.5fr);
gap: 1rem 1.5rem;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
.content-column {
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.content-column.action-column {
justify-content: space-between;
gap: 0.5rem;
min-width: 0; /* Prevent overflow */
}
.content-column h5 {
font-size: 0.8rem;
font-weight: 600;
color: #005384;
font-size: 0.75rem;
font-weight: 700;
color: #868e96;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
border-bottom: 1px solid #f1f3f5;
padding-bottom: 0.25rem;
border-bottom: 1px solid #e9ecef;
}
.content-column p {
font-size: 0.9rem;
margin-bottom: 0.25rem;
line-height: 1.4;
.content-column .form-group {
margin-bottom: 0.5rem;
}
.content-column .form-group, .content-column .tt-select-modern {
margin-bottom: 0;
.content-column label {
font-size: 0.75rem;
color: #495057;
margin-bottom: 0.1rem;
font-weight: 500;
}
.action-column .btn {
width: 100%;
margin-top: 0.5rem;
.content-column .form-control, .content-column .custom-select {
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
height: auto;
}
/* Specific Column Tweaks */
.shipping-dims {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem 0.75rem;
gap: 0.5rem;
}
.finish-wrapper {
.product-info {
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.product-name {
font-weight: 600;
color: #343a40;
display: block;
margin-bottom: 0.25rem;
}
.product-badges {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.vlans-container {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
}
.vlan-chip {
font-size: 0.75rem;
padding: 2px 8px;
background: #e7f5ff;
color: #1971c2;
border: 1px solid #d0ebff;
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.1s;
}
.vlan-chip:hover {
background: #d0ebff;
}
.vlan-chip input {
margin: 0;
}
/* Action Column */
.action-column {
justify-content: space-between;
}
.finish-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem;
background-color: #f1f3f5;
border-radius: 0.25rem;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
margin-top: auto;
margin-bottom: 0.5rem;
}
.mt-auto {
margin-top: auto !important;
.finish-toggle label { margin: 0; }
/* --- Custom Modal --- */
.custom-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
}
.custom-modal-container {
background: #fff;
border-radius: 6px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
width: 90%;
max-width: 500px;
overflow: hidden;
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.custom-modal-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.custom-modal-header h3 {
margin: 0;
font-size: 1.1rem;
color: #343a40;
}
.custom-modal-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #adb5bd;
cursor: pointer;
}
.custom-modal-close:hover { color: #495057; }
.custom-modal-body {
padding: 1.5rem;
}
.custom-modal-footer {
padding: 1rem;
border-top: 1px solid #e9ecef;
text-align: right;
background: #f8f9fa;
}
/* --- VLAN Chip Styling --- */
.vlans-container { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tt-chip { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 16px; background-color: #f1f3f5; border: 1px solid #dee2e6; font-size: 0.8em; white-space: nowrap; }
.tt-chip.is-checked { background-color: #e7f5ff; border-color: #a5d8ff; color: #1c7ed6; font-weight: 500; }
.tt-chip > * + * { margin-left: 6px; }
.tt-chip input[type="checkbox"] { margin: 0; cursor: pointer; }
/* Responsive adjustments */
/* Responsive */
@media (max-width: 1400px) {
.cpe-card-content {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 992px) {
.cpe-card-header {
@media (max-width: 768px) {
.cpe-card-content {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.tt-switch {
margin-bottom: 0 !important;
.cpe-card-header {
flex-direction: column;
align-items: flex-start;
}
}
@@ -8,18 +8,18 @@ Vue.component('tt-chip', {
Vue.component('Cpeprovisioning', {
template: `
<div class="cpe-provisioning-page">
<tt-card>
<div class="filter-grid">
<tt-select label="Netzgebiet" :options="networkOptions" v-model="filters.network_id" @input="debouncedFetchData" sm/>
<tt-select label="Provisioningstatus" :options="statusOptions" v-model="filters.routerconfig_finished" @input="debouncedFetchData" sm/>
<tt-select label="Verzögerte Herstellung" :options="delayOptions" v-model="filters.hide_delayed_finish" @input="debouncedFetchData" sm/>
<tt-input label="Suche" v-model="filters.owner" sm placeholder="Kunde, SPIN, Adresse..." @input="debouncedFetchData"/>
<div class="filter-actions">
<tt-button text="Anwenden" @click="fetchData(true)" additional-class="btn-primary" sm/>
<tt-button text="Zurücksetzen" @click="resetFilters" additional-class="btn-secondary" sm/>
<div class="filter-wrapper">
<div class="filter-grid">
<tt-select label="Netzgebiet" :options="networkOptions" v-model="filters.network_id" @input="fetchData(true)" sm/>
<tt-select label="Provisioningstatus" :options="statusOptions" v-model="filters.routerconfig_finished" @input="fetchData(true)" sm/>
<tt-select label="Verzögerte Herstellung" :options="delayOptions" v-model="filters.hide_delayed_finish" @input="fetchData(true)" sm/>
<tt-input label="Suche" v-model="filters.owner" sm placeholder="Kunde, SPIN, Adresse..." @input="handleSearchInput" @keyup.native.enter="fetchData(true)"/>
<div class="filter-actions">
<tt-button text="Anwenden" @click="fetchData(true)" additional-class="btn-primary" sm/>
<tt-button text="Zurücksetzen" @click="resetFilters" additional-class="btn-secondary" sm/>
</div>
</div>
</div>
</tt-card>
</div>
<div v-if="loading && items.length === 0" class="loading-indicator">
<div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div>
@@ -35,17 +35,20 @@ Vue.component('Cpeprovisioning', {
<div v-for="item in filteredItems" :key="item.orderproduct_id" class="cpe-card" :class="{ 'is-dirty': item.isDirty }">
<div class="cpe-card-header">
<div class="customer-info">
<span style="display: ruby;">
<strong>{{ item.customer }}<small v-if="item.owner_customer_number" class="text-muted ml-2">#{{ item.owner_customer_number }}</small></strong>
</span>
<small v-if="item.spin" class="text-muted">SPIN: <span class="text-pink">{{ item.spin }}</span></small>
<div>
<span class="name">{{ item.customer }}</span>
<span v-if="item.owner_customer_number" class="badge badge-light text-muted">#{{ item.owner_customer_number }}</span>
</div>
<small v-if="item.spin" class="meta">SPIN: <span class="text-primary">{{ item.spin }}</span></small>
</div>
<div class="location-contact-header">
<div><strong>Netzgebiet:</strong> {{ item.network || 'N/A' }}</div>
<div v-if="item.owner_phone"><i class="fas fa-phone mr-2 text-muted"></i>{{ item.owner_phone }}</div>
<div><strong>Adresse:</strong> {{ item.owner_full_address || 'N/A' }}</div>
<div v-if="item.owner_email"><i class="fas fa-envelope mr-2 text-muted"></i>{{ item.owner_email }}</div>
<div class="location-info">
<div><i class="fas fa-network-wired"></i> {{ item.network || 'N/A' }}</div>
<div><i class="fas fa-map-marker-alt"></i> {{ item.owner_full_address || 'N/A' }}</div>
<div v-if="item.owner_phone"><i class="fas fa-phone"></i> {{ item.owner_phone }}</div>
<div v-if="item.owner_email"><i class="fas fa-envelope"></i> {{ item.owner_email }}</div>
</div>
<div class="header-actions">
<a target="_blank" :href="window.TT_CONFIG.ORDER_URL + '/Index/?id=' + item.order_id + '&addJournal=1'">
<tt-tooltip text="Bestelljournal" position="top"><i class="fas fa-scroll"></i></tt-tooltip>
@@ -66,65 +69,131 @@ Vue.component('Cpeprovisioning', {
</div>
<div class="cpe-card-content">
<!-- Column 1: Configuration -->
<div class="content-column">
<h5>Router Konfiguration</h5>
<tt-select label="Router" :options="routerOptions" v-model="item.cpe_data.routertype" @input="markDirty(item); checkShipping(item)" sm no-form-group/>
<tt-select label="Router Modell" :options="routerOptions" v-model="item.cpe_data.routertype"
@input="markDirty(item); checkShipping(item); handleRouterTypeChange(item)" sm no-form-group/>
<div class="d-flex align-items-end" style="gap: 5px;">
<tt-input label="Router MAC (Scan)" v-model="item.cpe_data.mac"
@input="val => handleMacInput(item, val)" sm no-form-group style="flex-grow: 1;"/>
<button @click="copyToClipboard(item.cpe_data.mac)" :disabled="!item.cpe_data.mac"
class="btn btn-sm btn-light border" style="height: 31px;" title="MAC kopieren">
<i class="fas fa-copy"></i>
</button>
</div>
<tt-input label="WLAN SSID" v-model="item.cpe_data.wifi_ssid" @input="markDirty(item)" sm no-form-group/>
<tt-input label="WPA Key" v-model="item.cpe_data.wifi_pass" @input="markDirty(item)" sm no-form-group/>
<tt-input label="Router MAC" v-model="item.cpe_data.mac" @input="markDirty(item)" sm no-form-group/>
<tt-input v-if="item.termination_id" label="ONT SN" v-model="item.ont_sn" @input="markDirty(item)" sm no-form-group :additional-props="{ placeholder: item.ont_deployed ? 'ONT montiert' : 'ONT nicht montiert' }"/>
<tt-input v-if="item.termination_id" label="ONT Seriennummer" v-model="item.ont_sn"
@input="markDirty(item)" sm no-form-group
:additional-props="{ placeholder: item.ont_deployed ? 'ONT montiert' : 'ONT nicht montiert' }"/>
</div>
<!-- Column 2: Shipping -->
<div class="content-column">
<h5>Versand & Abschluss</h5>
<tt-checkbox label="Versandauftrag" v-model="item.cpe_data.shipping" @input="markDirty(item);checkShipping(item)" sm/>
<div class="shipping-dims">
<tt-input label="Gewicht" v-model="item.cpe_data.ship_weight" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping" placeholder="kg"/>
<tt-input label="Länge" v-model="item.cpe_data.ship_length" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping" placeholder="cm"/>
<tt-input label="Breite" v-model="item.cpe_data.ship_width" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping" placeholder="cm"/>
<tt-input label="Höhe" v-model="item.cpe_data.ship_height" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping" placeholder="cm"/>
<h5>Versand & Logistik</h5>
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="mb-0">Versandauftrag</label>
<tt-switch v-model="item.cpe_data.shipping" @input="markDirty(item);checkShipping(item)"/>
</div>
<tt-textarea label="Kommentar" v-model="item.cpe_data.note" @input="markDirty(item)" sm no-form-group/>
<div class="shipping-dims">
<tt-input label="Gewicht (kg)" v-model="item.cpe_data.ship_weight" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping"/>
<tt-input label="Länge (cm)" v-model="item.cpe_data.ship_length" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping"/>
<tt-input label="Breite (cm)" v-model="item.cpe_data.ship_width" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping"/>
<tt-input label="Höhe (cm)" v-model="item.cpe_data.ship_height" @input="markDirty(item)" sm type="number" :disabled="!item.cpe_data.shipping"/>
</div>
<tt-textarea label="Interner Kommentar" v-model="item.cpe_data.note" @input="markDirty(item)" sm no-form-group rows="2"/>
</div>
<div class="content-column action-column">
<h5>Produkt & VLANs</h5>
<p><strong>{{ item.product_name }}</strong> <small class="text-muted">{{ item.product_code }}</small></p>
<p>
<span class="badge badge-info">{{ item.access_type }}</span>
<span class="ml-2"><i class="fas fa-arrow-down"></i> {{ item.access_type_down }}</span>
<span class="ml-2"><i class="fas fa-arrow-up"></i> {{ item.access_type_up }}</span>
</p>
<div class="vlans-container mt-2">
<!-- Column 3: Product Info -->
<div class="content-column">
<h5>Produkt & Services</h5>
<div class="product-info">
<span class="product-name">{{ item.product_name }}</span>
<div class="product-badges">
<span class="badge badge-secondary">{{ item.product_code }}</span>
<span class="badge badge-info">{{ item.access_type }}</span>
</div>
<div class="mt-1 text-muted">
<i class="fas fa-arrow-down"></i> {{ item.access_type_down }} | <i class="fas fa-arrow-up"></i> {{ item.access_type_up }}
</div>
</div>
<div class="vlans-container">
<template v-for="(vlan, key) in item.vlans">
<tt-chip v-if="vlan.tag" class="vlan-chip" :key="key">
<label v-if="vlan.tag" class="vlan-chip" :key="key">
<input type="checkbox" :checked="vlan.checked" @change="markDirty(item); vlan.checked = !vlan.checked"/>
<span>{{ key.charAt(0).toUpperCase() + key.slice(1) }}: {{ vlan.tag }}</span>
</tt-chip>
</label>
</template>
</div>
<div class="finish-wrapper mt-auto">
<label class="col-form-label col-form-label-sm">Konfig abgeschlossen</label>
<tt-switch v-model="item.cpe_data.routerconfig_finished" @input="markDirty(item)"/>
</div>
<!-- Column 4: Actions -->
<div class="content-column action-column">
<h5>Aktionen</h5>
<div>
<tt-button text="Radius User anlegen"
@click="createRadiusUser(item)"
:disabled="!isValidMac(item.cpe_data.mac)"
sm block
additional-class="btn-outline-primary mb-2"
icon="fas fa-user-plus"
title="An Chrome Extension senden" />
</div>
<div class="mt-auto">
<div class="finish-toggle">
<label><strong>Konfiguration fertig</strong></label>
<tt-switch v-model="item.cpe_data.routerconfig_finished" @input="markDirty(item)"/>
</div>
<tt-button text="Speichern" @click="saveCpe(item)"
:loading="item.isSaving" :disabled="!item.isDirty"
:additional-class="item.isDirty ? 'btn-success' : 'btn-secondary'"
block icon="fas fa-save"/>
</div>
<tt-button text="Speichern" @click="saveCpe(item)" :loading="item.isSaving" :disabled="!item.isDirty" :additional-class="item.isDirty ? 'btn-success' : 'btn-secondary'"/>
</div>
</div>
</div>
</div>
<div v-if="loading && items.length > 0" class="text-center mt-4 mb-4">
<div class="card d-inline-block p-3 shadow-sm">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
<span class="ml-2">Lade weitere Einträge...</span>
</div>
<div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div>
</div>
<div class="text-center mt-4 mb-4" v-if="!loading && pagination.total_pages && page <= pagination.total_pages">
<tt-button text="Weitere laden" @click="fetchData(false)" additional-class="btn-primary" />
</div>
<!-- Custom Extension ID Modal -->
<div class="custom-modal-overlay" v-if="showExtensionIdModal" @click.self="showExtensionIdModal=false">
<div class="custom-modal-container">
<div class="custom-modal-header">
<h3>Extension ID Konfigurieren</h3>
<button class="custom-modal-close" @click="showExtensionIdModal=false">&times;</button>
</div>
<div class="custom-modal-body">
<div class="form-group">
<label>Chrome Extension ID</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fas fa-puzzle-piece"></i></span>
</div>
<input type="text" class="form-control" v-model.trim="extensionId" placeholder="z.B. jglijfiddilckddlmbnlojmmlahboffh">
</div>
<small class="form-text text-muted">Die ID der 'TheTool Helper' Chrome Extension.</small>
</div>
</div>
<div class="custom-modal-footer">
<button class="btn btn-secondary mr-2" @click="showExtensionIdModal=false">Abbrechen</button>
<button class="btn btn-primary" @click="saveExtensionId">Speichern</button>
</div>
</div>
</div>
</div>
`,
data() {
@@ -143,7 +212,11 @@ Vue.component('Cpeprovisioning', {
delayOptions: [ { value: '1', text: 'Nicht anzeigen' }, { value: '0', text: 'Anzeigen' } ],
page: 1,
pagination: {},
debouncedFetchData: null
searchDebounceTimer: null,
macInputTimers: {},
extensionId: 'jglijfiddilckddlmbnlojmmlahboffh',
showExtensionIdModal: false,
processingMacItems: new Set()
}
},
computed: {
@@ -156,9 +229,60 @@ Vue.component('Cpeprovisioning', {
}
},
created() {
this.debouncedFetchData = _.debounce(this.fetchData.bind(this, true), 400);
// Removed usage of _.debounce
// Load Extension ID from local storage
const savedExtensionId = localStorage.getItem('radiusExtensionId');
if (savedExtensionId) {
this.extensionId = savedExtensionId;
}
window.addEventListener('keydown', this.handleKeydown);
},
beforeDestroy() {
window.removeEventListener('keydown', this.handleKeydown);
Object.keys(this.macInputTimers).forEach(key => {
clearTimeout(this.macInputTimers[key]);
});
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
},
methods: {
copyToClipboard(text) {
if(!text) return;
navigator.clipboard.writeText(text)
.then(() => window.notify('success', 'Kopiert!'))
.catch(() => window.notify('error', 'Fehler beim Kopieren'));
},
handleKeydown(e) {
// CTRL + ALT + E to open Extension Config
if (e.code === 'KeyE' && e.ctrlKey && e.altKey) {
e.preventDefault();
this.openExtensionIdModal();
}
},
openExtensionIdModal() {
this.showExtensionIdModal = true;
},
saveExtensionId() {
if(this.extensionId) {
localStorage.setItem('radiusExtensionId', this.extensionId);
this.showExtensionIdModal = false;
window.notify('success', 'Extension ID gespeichert.');
} else {
window.notify('error', 'Bitte eine ID eingeben.');
}
},
isValidMac(mac) {
if (!mac) return false;
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
return macRegex.test(mac);
},
handleSearchInput() {
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = setTimeout(() => {
this.fetchData(true);
}, 400);
},
async fetchData(isNewSearch = false) {
if (isNewSearch) {
this.page = 1;
@@ -215,24 +339,176 @@ Vue.component('Cpeprovisioning', {
markDirty(item) {
this.$set(item, 'isDirty', true);
},
async checkShipping(item) {
await this.$nextTick();
if (item.cpe_data.shipping && item.cpe_data.routertype) {
const shippingData = this.window.TT_CONFIG.ROUTER_SHIPPING_DATA[item.cpe_data.routertype];
if (shippingData) {
this.$set(item.cpe_data, 'ship_weight', shippingData.weight);
this.$set(item.cpe_data, 'ship_length', shippingData.length);
this.$set(item.cpe_data, 'ship_width', shippingData.width);
this.$set(item.cpe_data, 'ship_height', shippingData.height);
this.window.notify('success', 'Versanddaten wurden automatisch ausgefüllt.');
}
} else if (!item.cpe_data.shipping) {
item.cpe_data.ship_weight = '';
item.cpe_data.ship_length = '';
item.cpe_data.ship_width = '';
item.cpe_data.ship_height = '';
// --- MAC & QR Logic ---
parseMacFromQrCode(qrCode) {
if (!qrCode) return null;
// Remove whitespace and newlines
const cleaned = qrCode.replace(/[\s\n\r]+/g, '');
// Strict Pattern: Must contain a dash separating 6 hex and 12 hex
// Example: "CWMP-ID=00040E-802395709D7C" or just "00040E-802395709D7C"
const matchDash = cleaned.match(/([0-9A-Fa-f]{6})-([0-9A-Fa-f]{12})/);
if (matchDash) {
console.log('[MAC Parser] Found Pattern:', matchDash[0]);
// RETURN ONLY THE PART AFTER THE DASH (Group 2)
return matchDash[2];
}
return null;
},
calculateMacOffset(macAddress, offset) {
// Convert to BigInt to handle 48-bit integer math safely
const macDecimal = BigInt('0x' + macAddress);
const newMacDecimal = macDecimal + BigInt(offset);
let newMacHex = newMacDecimal.toString(16).toUpperCase();
// Ensure 12 chars padding
return newMacHex.padStart(12, '0');
},
formatMacAddress(macAddress) {
// Strip existing delimiters
const cleaned = macAddress.replace(/[:-]/g, '');
// Add colons
return cleaned.match(/.{1,2}/g).join(':').toUpperCase();
},
handleMacInput(item, val) {
if (val !== undefined) {
// Fix encoding issue: replace ß (scharfes s) with - before processing
item.cpe_data.mac = val.replace(/ß/g, '-');
}
const itemKey = item.orderproduct_id;
this.markDirty(item);
// Clear existing timer
if (this.macInputTimers[itemKey]) {
clearTimeout(this.macInputTimers[itemKey]);
}
// Check if input matches QR code pattern (XXXXXX-XXXXXXXXXXXX)
const qrPattern = /[0-9A-Fa-f]{6}-[0-9A-Fa-f]{12}/;
const hasCompleteQr = qrPattern.test(item.cpe_data.mac || '');
if (hasCompleteQr) {
// Complete QR code detected - short delay to ensure full scan is received
this.macInputTimers[itemKey] = setTimeout(() => {
this.processMacAddress(item);
}, 150);
} else {
// Manual entry or incomplete scan - longer debounce
this.macInputTimers[itemKey] = setTimeout(() => {
this.processMacAddress(item);
}, 600);
}
},
handleRouterTypeChange(item) {
// Re-process MAC if router type changes (offset might change)
if (item.cpe_data.mac) {
this.processMacAddress(item);
}
},
processMacAddress(item) {
let inputValue = item.cpe_data.mac;
const routerType = item.cpe_data.routertype;
if (!inputValue) return;
// Fix encoding issue: replace ß (scharfes s) with - before processing
if (inputValue.includes('ß')) {
inputValue = inputValue.replace(/ß/g, '-');
this.$set(item.cpe_data, 'mac', inputValue); // Update field with corrected value
}
// Only process QR codes for FritzBox 4050 and 7690
if (routerType === 'FritzBox 4050' || routerType === 'FritzBox 7690') {
const parsedMac = this.parseMacFromQrCode(inputValue);
if (parsedMac) {
// QR code pattern found, calculate offset and format
let offset = 0;
if (routerType === 'FritzBox 4050') offset = -3;
else if (routerType === 'FritzBox 7690') offset = 2;
try {
const newMac = (offset !== 0) ? this.calculateMacOffset(parsedMac, offset) : parsedMac;
const formatted = this.formatMacAddress(newMac);
if (item.cpe_data.mac !== formatted) {
this.$set(item.cpe_data, 'mac', formatted);
const offsetStr = offset > 0 ? `+${offset}` : `${offset}`;
window.notify('success', `MAC berechnet (${offsetStr}): ${formatted}`);
}
} catch (e) {
console.error('MAC Calculation error', e);
window.notify('error', 'Fehler bei MAC Berechnung');
}
return; // Exit after QR processing
}
}
// For all router types (including 4050/7690 without QR): format manual entry
const cleanInput = inputValue.replace(/[:-]/g, '').replace(/\s/g, '');
if (cleanInput.length === 12 && /^[0-9A-Fa-f]{12}$/.test(cleanInput)) {
const formatted = this.formatMacAddress(cleanInput);
if (item.cpe_data.mac !== formatted) {
this.$set(item.cpe_data, 'mac', formatted);
}
}
},
checkShipping(item) {
if (item.cpe_data.shipping && item.cpe_data.routertype) {
const shippingData = this.window.TT_CONFIG.ROUTER_SHIPPING_DATA ? this.window.TT_CONFIG.ROUTER_SHIPPING_DATA[item.cpe_data.routertype] : null;
if (shippingData) {
item.cpe_data.ship_weight = shippingData.weight;
item.cpe_data.ship_length = shippingData.length;
item.cpe_data.ship_width = shippingData.width;
item.cpe_data.ship_height = shippingData.height;
item.cpe_data = { ...item.cpe_data }; // Trigger reactivity
this.window.notify('success', 'Versanddaten übernommen.');
}
}
},
createRadiusUser(item) {
window.notify('info', 'Sende Daten an Chrome Extension...');
const message = {
type: "CREATE_RADIUS_USER",
payload: {
customerNumber: item.owner_customer_number || 'N/A',
macAddress: item.cpe_data.mac,
address: item.owner_full_address || 'N/A',
customerName: item.customer || 'N/A',
productName: item.product_name || 'N/A'
}
};
if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) {
try {
chrome.runtime.sendMessage(this.extensionId, message, (response) => {
if (chrome.runtime.lastError) {
console.warn(chrome.runtime.lastError.message);
window.notify('warning', 'Kommunikation fehlgeschlagen. Extension installiert?');
} else {
window.notify('success', 'Daten gesendet!');
}
});
} catch (e) {
window.notify('error', 'Fehler: ' + e.message);
}
} else {
window.notify('warning', 'Chrome Messaging API nicht verfügbar.');
}
},
_buildSavePayload(item) {
return {
id: item.cpe_id,
@@ -242,10 +518,12 @@ Vue.component('Cpeprovisioning', {
ont_sn: item.ont_sn,
vlans: item.vlans,
...item.cpe_data,
routertype: item.cpe_data.routertype || '', // Ensure empty string instead of null
shipping: item.cpe_data.shipping ? 1 : 0,
routerconfig_finished: item.cpe_data.routerconfig_finished ? 1 : 0,
};
},
async saveCpe(item) {
this.$set(item, 'isSaving', true);
const payload = this._buildSavePayload(item);
@@ -276,4 +554,4 @@ Vue.component('Cpeprovisioning', {
mounted() {
this.fetchData(true);
}
});
});
@@ -28,10 +28,40 @@
.invoice-preview-pane {
flex: 1 1 auto;
background-color: #525659;
padding: 2rem;
overflow-y: auto;
padding: 0;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
.pdf-preview-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.pdf-preview-container object {
width: 100%;
height: 100%;
}
.pdf-loading {
text-align: center;
color: white;
padding: 2rem;
}
.pdf-loading i {
margin-bottom: 1rem;
}
.pdf-loading p {
font-size: 1.2rem;
margin-top: 1rem;
}
.info-bar {
@@ -240,4 +270,15 @@
.preview-footer .page-number {
text-align: right;
}
/* Fix tt-select label to match tt-input styling */
.manual-invoice-overlay .tt-select-modern label.col-form-label {
font-weight: normal;
margin-bottom: 0.5rem;
}
/* Ensure consistent form-group spacing */
.manual-invoice-overlay .tt-select-modern.form-group {
margin-bottom: 1rem;
}
+542 -266
View File
@@ -3,44 +3,38 @@ Vue.component('manual-invoice', {
<tt-card>
<div class="d-flex justify-content-between align-items-center mb-3">
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
<tt-button text="Test Prefill & Reload" icon="fas fa-magic" @click="testPrefill" additional-class="btn-info"/>
</div>
<tt-table-crud
ref="table"
emit-edit
@edit="openModal($event)">
<template v-slot:totalamount="{ row }">
{{ formatPrice(row.totalAmount) }}
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)" @pdfPreview="handlePdfPreview($event)" @sendInvoice="handleSendInvoice($event)">
<template v-slot:total="{ row }">{{ formatPrice(row.total) }}</template>
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
<template v-slot:customerName="{ row }">{{ row.customerName }}</template>
<template v-slot:status="{ row }">
<span :class="getStatusClass(row.status)">{{ getStatusText(row.status) }}</span>
</template>
<template v-slot:invoicedate="{ row }">
{{ formatDate(row.invoiceDate) }}
<template v-slot:actions="{ row }">
<button class="btn btn-sm btn-primary" @click="downloadPdf(row.id)" title="PDF herunterladen"><i class="fas fa-file-pdf"></i></button>
</template>
</tt-table-crud>
<manual-invoice-modal
v-if="isModalOpen"
:initial-data="editingInvoiceData"
@close="closeModal"
@save="handleSave"
/>
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" :shipping-note-import="shippingNoteImportData" @close="closeModal" @save="handleSave"/>
<gutschrift-modal v-if="isGutschriftModalOpen" :invoice-id="gutschriftInvoiceId" @close="closeGutschriftModal" @created="handleGutschriftCreated"/>
<send-invoice-modal v-if="isSendModalOpen" :invoice-id="sendInvoiceId" @close="closeSendModal" @sent="handleInvoiceSent"/>
</tt-card>
`,
data() {
return {
isModalOpen: false,
editingInvoiceData: null,
}
},
data: () => ({ isModalOpen: false, editingInvoiceData: null, shippingNoteImportData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }),
mounted() {
const prefillData = localStorage.getItem('ManualInvoice_create');
if (prefillData) {
// Check for shipping note import data
const shippingNoteData = localStorage.getItem('ManualInvoice_create');
if (shippingNoteData) {
try {
this.editingInvoiceData = JSON.parse(prefillData);
this.isModalOpen = true;
// Parse and store the data
this.shippingNoteImportData = JSON.parse(shippingNoteData);
// Delete from localStorage immediately so it doesn't auto-open again on reload
localStorage.removeItem('ManualInvoice_create');
// Auto-open modal for import
this.openModal();
} catch (e) {
console.error("Failed to parse prefill data:", e);
} finally {
console.error('Error parsing shipping note data:', e);
localStorage.removeItem('ManualInvoice_create');
}
}
@@ -53,320 +47,602 @@ Vue.component('manual-invoice', {
closeModal() {
this.isModalOpen = false;
this.editingInvoiceData = null;
this.shippingNoteImportData = null;
this.$refs.table.$refs.table.refreshTable();
},
handleSave(invoiceData) {
console.log("--- INVOICE SAVED (DEMO) ---");
console.log(JSON.parse(JSON.stringify(invoiceData)));
window.notify('success', 'Rechnung in der Konsole geloggt!');
this.closeModal();
async handleSave(invoiceData) {
try {
const positions = invoiceData.positions.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const priceAfterDiscount = amount * price * (1 - discount / 100);
return {
...p, amount, price, discount, vatrate,
unit: p.unit || 'Stk.',
price_total: priceAfterDiscount,
price_gross: priceAfterDiscount * (1 + vatrate / 100),
product_id: p.product_id || 0,
contract_id: p.contract_id || 0,
billing_id: p.billing_id || null,
matchcode: p.matchcode || null,
fibu_cost_account: p.fibu_cost_account || null,
fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
fibu_taxcode: p.fibu_taxcode || null,
options: p.options || null
};
});
const payload = {
...invoiceData,
positions,
owner_id: invoiceData.owner_id || 0,
billingaddress_id: invoiceData.billingaddress_id || 0,
customer_number: invoiceData.customer_number || 0,
country: invoiceData.country || 'Österreich',
billing_type: invoiceData.billing_type || 'invoice',
billing_delivery: 'email',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
vatgroup_id: 1,
gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0
};
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
const { data } = await axios.post(url, payload);
if (data.success) {
window.notify('success', data.message || 'Rechnung erfolgreich gespeichert!');
this.closeModal();
} else {
window.notify('error', data.message || 'Fehler beim Speichern der Rechnung');
}
} catch (e) {
console.error('Error saving invoice:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
testPrefill() {
const mockInvoice = {
id: null,
invoiceNumber: `RE-${new Date().getFullYear()}-XXXX`,
invoiceDate: moment().unix(),
dueDate: moment().add(14, 'days').unix(),
status: 'draft',
billingAddressId: 1, // Example ID for autocomplete to fetch
customer: {}, // Will be populated by watcher
positions: [
{ product_name: 'Stunden Techniker', product_info: 'Arbeiten an Server-Infrastruktur', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 3.5, price: 95.00, vatrate: 20 },
{ product_name: 'Anfahrtspauschale', product_info: '', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 1, price: 45.00, vatrate: 20 }
],
closingText: 'Wir bedanken uns für die gute Zusammenarbeit.',
taxText: ''
};
localStorage.setItem('ManualInvoice_create', JSON.stringify(mockInvoice));
window.location.reload();
downloadPdf(id) { window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoicePdf?id=${id}`; },
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); },
formatDate(ts) { return ts ? moment.unix(ts).format('DD.MM.YYYY') : ''; },
openGutschriftModal(invoice) {
if (invoice.total < 0) return window.notify('error', 'Kann keine Gutschrift für eine Gutschrift erstellen');
this.gutschriftInvoiceId = invoice.id;
this.isGutschriftModalOpen = true;
},
formatPrice(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; },
handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); },
async handlePdfPreview(invoice) {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/pdfPreview`, { id: invoice.id });
if (data.success && data.url) {
window.open(data.url, '_blank');
} else {
window.notify('error', data.message || 'Fehler beim Öffnen der PDF Vorschau');
}
} catch (e) {
console.error('Error opening PDF preview:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
formatDate(timestamp) {
if (!timestamp) return '';
return moment.unix(timestamp).format('DD.MM.YYYY');
}
handleSendInvoice(invoice) {
this.sendInvoiceId = invoice.id;
this.isSendModalOpen = true;
},
closeSendModal() {
this.isSendModalOpen = false;
this.sendInvoiceId = null;
},
handleInvoiceSent() {
this.closeSendModal();
this.$refs.table.$refs.table.refreshTable();
},
getStatusClass(s) { return { 'erstellt': 'badge badge-secondary', 'gesendet': 'badge badge-success', 'exportiert': 'badge badge-primary' }[s] || 'badge badge-secondary'; },
getStatusText(s) { return { 'erstellt': 'Erstellt', 'gesendet': 'Gesendet', 'exportiert': 'Exportiert' }[s] || s; }
}
});
Vue.component('manual-invoice-modal', {
props: ['initialData'],
props: ['initialData', 'shippingNoteImport'],
template: `
<div class="manual-invoice-overlay" :class="overlayClasses" @keydown.ctrl.q.prevent="togglePreviewVisibility" tabindex="-1" ref="overlay">
<div class="info-bar" v-if="!isLargeScreen">
<i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.
</div>
<div class="manual-invoice-overlay" :class="overlayClasses" tabindex="-1" ref="overlay">
<div class="info-bar" v-if="!isLargeScreen"><i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.</div>
<div class="invoice-editor-pane" v-show="isLargeScreen || !showPreviewOnSmallScreen">
<div class="editor-header">
<h3>{{ isCreateMode ? 'Neue Rechnung' : 'Rechnung bearbeiten' }}</h3>
<div class="editor-actions">
<tt-button text="Speichern" icon="fas fa-save" @click="$emit('save', invoiceData)" additional-class="btn-success"/>
<tt-button text="Speichern" icon="fas fa-save" @click="saveInvoice" additional-class="btn-success"/>
<tt-button text="Schließen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
</div>
</div>
<div class="editor-content">
<tt-card>
<template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingAddressId" sm row />
<tt-card><template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingaddress_id" sm row />
</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">
<tt-input label="Rechnungsnr." v-model="invoiceData.invoiceNumber" sm/>
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoiceDate" :date-range="false" sm/>
<tt-date-picker label="Fälligkeitsdatum" v-model="invoiceData.dueDate" :date-range="false" sm/>
<tt-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/>
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
</div>
<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-textarea label="Einleitender Text" v-model="invoiceData.einleitender_text" rows="3" sm row/>
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
<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" />
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
<tt-textarea label="Schlusstext" v-model="invoiceData.closingText" rows="4"/>
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.taxText" rows="2"/>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template>
<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>
</div>
</div>
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
<div class="invoice-preview-document">
<div style="height: 50px; margin-bottom: 32px">
<img alt="Xinon Logo" src="/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
</div>
<table class="preview-header-table">
<tr>
<td class="customer-details">
<div>{{ invoiceData.customer.company }}</div>
<div>{{ invoiceData.customer.name }}</div>
<div>{{ invoiceData.customer.street }}</div>
<div>{{ invoiceData.customer.zip }} {{ invoiceData.customer.city }}</div>
<div v-if="invoiceData.customer.country !== 'Österreich'">{{ invoiceData.customer.country }}</div>
</td>
<td class="invoice-details-cell">
<table class="invoice-details-box">
<tr><td>Kundennummer:</td><td>{{ selectedCustomerObject.customer_number || '-' }}</td></tr>
<tr><td>Rechnungsnummer:</td><td>{{ invoiceData.invoiceNumber }}</td></tr>
<tr><td>Belegdatum:</td><td>{{ formatDate(invoiceData.invoiceDate) }}</td></tr>
<tr v-if="invoiceData.customer.uid"><td>Ihre UID:</td><td>{{ invoiceData.customer.uid }}</td></tr>
</table>
</td>
</tr>
</table>
<div class="separator"></div>
<div class="preview-main">
<h2 style="text-align: center; color: #005384; font-size: 1.5rem; margin-bottom: 1.5rem;">Ihre Rechnung vom {{ formatDate(invoiceData.invoiceDate) }}</h2>
<table class="positions-table">
<thead>
<tr class="uneven">
<th style="text-align: left; padding-left: 4pt;">Leistung / Produkt</th>
<th style="text-align: center;">Zeitraum</th>
<th style="text-align: right;">Preis</th>
<th style="text-align: center;">Menge</th>
<th style="text-align: right;">Netto €</th>
<th style="text-align: right;">Ust. %</th>
<th style="text-align: right; padding-right: 4pt;">Brutto €</th>
</tr>
</thead>
<tbody>
<template v-for="(p, index) in invoiceData.positions">
<tr :class="{'uneven': index % 2 === 0}"> <td style="vertical-align: top; padding-left: 4pt;">
<strong>{{ p.product_name }}</strong>
<div v-if="p.product_info" class="matchcode">{{ p.product_info }}</div>
</td>
<td style="text-align: center; vertical-align: top;">{{ formatPeriod(p.start_date, p.end_date) }}</td>
<td style="text-align: right; vertical-align: top;">{{ formatPrice(p.price) }}</td>
<td style="text-align: center; vertical-align: top;">{{ p.amount }}</td>
<td style="text-align: right; vertical-align: top;">{{ formatPrice((p.amount || 0) * (p.price || 0)) }}</td>
<td style="text-align: right; vertical-align: top;">{{ p.vatrate }}%</td>
<td style="text-align: right; padding-right: 4pt; vertical-align: top;">{{ formatPrice(((p.amount || 0) * (p.price || 0)) * (1 + (p.vatrate || 0) / 100)) }}</td>
</tr>
</template>
</tbody>
</table>
<div class="totals-section">
<table class="totals-table">
<tr class="netto">
<th>Gesamtbetrag Netto:</th>
<td>{{ formatPrice(totals.net) }} €</td>
</tr>
<tr class="ust" v-for="(vatValue, vatRate) in totals.vat" :key="vatRate">
<th>+ Umsatzsteuer {{ vatRate }}%:</th>
<td>{{ formatPrice(vatValue) }} €</td>
</tr>
<tr class="brutto">
<th>Gesamtbetrag Brutto:</th>
<td>{{ formatPrice(totals.gross) }} €</td>
</tr>
</table>
</div>
<div class="payment-info">
<p v-if="invoiceData.taxText" style="font-weight: bold;">{{invoiceData.taxText}}</p>
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b>{{ formatDate(invoiceData.dueDate) }}</b> auf folgendes Konto:<br />
<b style="padding-left: 4pt;">IBAN: {{ bankDetails.iban }}</b><br />
<b style="padding-left: 4pt;">BIC: {{ bankDetails.bic }}</b><br /><br />
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an.
</div>
</div>
<div class="preview-footer">
<div style="color:grey;text-align: center; width: 100%;">
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
<span>IBAN: {{ bankDetails.iban }} | BIC: {{ bankDetails.bic }}</span><br>
</div>
<div class="page-number">Seite 1 von 1</div>
</div>
<div class="pdf-preview-container">
<div v-if="pdfLoading" class="pdf-loading"><i class="fas fa-spinner fa-spin fa-3x"></i><p>PDF wird generiert...</p></div>
<object v-else :data="pdfPreviewUrl" type="application/pdf" width="100%" height="100%">
<p>PDF Vorschau kann nicht angezeigt werden. <a :href="pdfPreviewUrl" target="_blank">Hier klicken</a></p>
</object>
</div>
</div>
</div>
`,
data() {
return {
isCreateMode: !this.initialData || !this.initialData.id,
customerApiUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
selectedCustomerObject: {},
isCreateMode: !this.initialData?.id,
customerApiUrl: window.TT_CONFIG.BASE_PATH + '/Address/Api?do=findAddress&fibu_primary_account=1',
isLargeScreen: window.innerWidth >= 1920,
showPreviewOnSmallScreen: false,
pdfLoading: false,
pdfPreviewUrl: '',
previewDebounceTimer: null,
invoiceData: {
id: null,
invoiceNumber: `RE-${new Date().getFullYear()}-`,
invoiceDate: moment().unix(),
dueDate: moment().add(14, 'days').unix(),
status: 'draft',
billingAddressId: null,
customer: { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' },
positions: [],
closingText: 'Wir danken für Ihren Auftrag und verbleiben mit freundlichen Grüßen,\nIhr Xinon Team',
taxText: '',
},
bankDetails: {
iban: 'ATXX XXXX XXXX XXXX XXXX',
bic: 'XXXXXXXX'
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,
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich',
uid: '', email: '', billing_type: 'invoice', tax_text: '',
leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0,
positions: [], total: 0, total_gross: 0
},
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
product_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
start_date: { type: 'input', label: 'Start', inputType: 'date' },
end_date: { type: 'input', label: 'Ende', inputType: 'date' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
unit: {
type: 'select',
label: 'Einheit',
options: [
{ value: 'Pau.', text: 'Pau.' },
{ value: 'Stk.', text: 'Stk.' },
{ value: 'h', text: 'h' },
{ value: 'm', text: 'm' }
]
},
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (formData) => {
if (!formData.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (!formData.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (formData.price === null || formData.price === undefined) { window.notify('error', 'Preis ist erforderlich.'); return false; }
validateForm: (d) => {
if (!d.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (!d.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (d.price == null) { window.notify('error', 'Preis ist erforderlich.'); return false; }
return true;
}
}
};
},
computed: {
overlayClasses() {
return {
'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen,
'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen,
};
},
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
totals() {
let net = 0;
const vat = {};
if (!Array.isArray(this.invoiceData.positions)) return { net: 0, vat: {}, gross: 0 };
this.invoiceData.positions.forEach(p => {
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
const vatRate = parseInt(p.vatrate) || 0;
net += lineTotal;
if (!vat[vatRate]) { vat[vatRate] = 0; }
vat[vatRate] += lineTotal * (vatRate / 100);
let subtotal = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const lineTotal = amount * price * (1 - discount / 100);
subtotal += lineTotal;
});
const gross = net + Object.values(vat).reduce((sum, v) => sum + v, 0);
return { net, vat, gross };
// Apply gesamtrabatt
const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0;
const net = subtotal * (1 - gesamtrabatt / 100);
// Calculate VAT
let vat = {}, gross = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const r = parseInt(p.vatrate) || 0;
const lineNet = amount * price * (1 - discount / 100) * (1 - gesamtrabatt / 100);
const lineVat = lineNet * (r / 100);
vat[r] = (vat[r] || 0) + lineVat;
gross += lineNet + lineVat;
});
return { subtotal, net, vat, gross };
}
},
watch: {
'invoiceData.billingAddressId': {
'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true },
'invoiceData.billingaddress_id': {
async handler(newId) {
if (!newId) {
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
this.selectedCustomerObject = {};
return;
if (!newId) return Object.assign(this.invoiceData, {
company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
});
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (data.status === 'OK' && data.result.address) {
const a = data.result.address;
Object.assign(this.invoiceData, {
company: a.company || '', firstname: a.firstname || '', lastname: a.lastname || '',
street: a.street || '', zip: a.zip || '', city: a.city || '', country: 'Österreich',
uid: a.uid || '', email: a.email || '', customer_number: a.customer_number || 0,
fibu_account_number: a.fibu_account_number || 0, owner_id: newId
});
}
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (response.data.status === 'OK' && response.data.result.address) {
const addr = response.data.result.address;
this.selectedCustomerObject = addr;
this.invoiceData.customer = {
company: addr.company,
name: `${addr.firstname} ${addr.lastname}`,
street: addr.street,
zip: addr.zip,
city: addr.city,
country: 'Österreich',
uid: addr.uid
};
}
},
immediate: true
}
}
},
created() {
if (this.initialData) {
// FIX: Merge initial data with default structure to ensure all keys, especially nested ones, exist.
this.invoiceData = {
...this.invoiceData, // Start with default structure
...JSON.parse(JSON.stringify(this.initialData)) // Overwrite with passed data
};
// Explicitly ensure nested objects exist if they weren't in initialData
if (!this.invoiceData.customer) {
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
this.invoiceData = { ...this.invoiceData, ...JSON.parse(JSON.stringify(this.initialData)) };
if (typeof this.invoiceData.positions === 'string') {
try { this.invoiceData.positions = JSON.parse(this.invoiceData.positions); } catch { this.invoiceData.positions = []; }
}
// Ensure positions is an array
if (!Array.isArray(this.invoiceData.positions)) {
try {
const parsed = JSON.parse(this.invoiceData.positions);
this.invoiceData.positions = Array.isArray(parsed) ? parsed : [];
} catch (e) {
this.invoiceData.positions = [];
}
if (!Array.isArray(this.invoiceData.positions)) this.invoiceData.positions = [];
}
// Check for shipping note import data from prop
if (this.shippingNoteImport && Array.isArray(this.shippingNoteImport) && this.shippingNoteImport.length > 0) {
try {
this.processShippingNoteImport(this.shippingNoteImport);
} catch (e) {
console.error('Error processing shipping note import:', e);
window.notify('error', 'Fehler beim Importieren des Lieferscheins');
}
}
},
mounted() {
window.addEventListener('resize', this.handleResize);
window.addEventListener('keydown', this.handleGlobalKeydown);
this.handleResize();
this.$nextTick(() => {
if (this.$refs.overlay) {
this.$refs.overlay.focus();
}
});
this.$nextTick(() => { this.$refs.overlay?.focus(); this.updatePdfPreview(); });
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('keydown', this.handleGlobalKeydown);
clearTimeout(this.previewDebounceTimer);
},
methods: {
close() { this.$emit('close'); },
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
formatPrice(value) { return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); },
formatDate(timestamp) {
if (!timestamp) return '';
return moment.unix(timestamp).format('DD.MM.YYYY');
saveInvoice() {
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.');
this.$emit('save', this.invoiceData);
},
formatPeriod(start, end) {
if (!start) return '';
const startDate = moment(start);
const endDate = end ? moment(end) : moment(start);
if (!startDate.isValid()) return '';
if (startDate.isSame(endDate, 'day')) return startDate.format('DD.MM.YYYY');
if(startDate.isValid() && endDate.isValid()) {
return `${startDate.format('DD.MM.YYYY')} - ${endDate.format('DD.MM.YYYY')}`;
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
handleGlobalKeydown(e) {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
},
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
debouncedPreviewUpdate() {
clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);
},
async updatePdfPreview() {
this.pdfLoading = true;
try {
const positions = this.invoiceData.positions
.filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions
.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const priceAfterDiscount = amount * price * (1 - discount / 100);
return { ...p, amount, price, discount, vatrate, unit: p.unit || 'Stk.', price_total: priceAfterDiscount, price_gross: priceAfterDiscount * (1 + vatrate / 100) };
});
const payload = {
preview: true, ...this.invoiceData,
total: this.totals.net, total_gross: this.totals.gross, positions
};
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createPDF`, payload, { responseType: 'blob' });
if (this.pdfPreviewUrl) URL.revokeObjectURL(this.pdfPreviewUrl);
this.pdfPreviewUrl = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) + '#view=FitH';
} catch (e) {
console.error('Error preview:', e);
window.notify('error', 'Fehler beim Generieren der PDF-Vorschau');
} finally {
this.pdfLoading = false;
}
return startDate.format('DD.MM.YYYY');
},
processShippingNoteImport(shippingNoteDataArray) {
// Temporarily disable the preview update during import to prevent memory leak
clearTimeout(this.previewDebounceTimer);
const originalWatcher = this.$options.watch['invoiceData'];
delete this.$options.watch['invoiceData'];
try {
for (const shippingNoteData of shippingNoteDataArray) {
// Pre-fill billing address fields
if (shippingNoteData.billingAddress) {
const addr = shippingNoteData.billingAddress;
Object.assign(this.invoiceData, {
billingaddress_id: addr.id,
customer_number: addr.customer_number || 0,
company: addr.company || '',
firstname: addr.firstname || '',
lastname: addr.lastname || '',
street: addr.street || '',
zip: addr.zip || '',
city: addr.city || '',
email: addr.email || '',
uid: addr.uid || '',
fibu_account_number: addr.fibu_account_number || 0,
fibu_payment_due: addr.fibu_payment_due || 14,
fibu_payment_skonto: addr.fibu_payment_skonto || 0,
fibu_payment_skonto_rate: addr.fibu_payment_skonto_rate || 0,
billing_type: addr.billing_type || 'invoice',
owner_id: addr.id
});
// Banking info (if SEPA)
if (addr.billing_type === 'sepa') {
Object.assign(this.invoiceData, {
bank_account_bank: addr.bank_account_bank || '',
bank_account_owner: addr.bank_account_owner || '',
bank_account_iban: addr.bank_account_iban || '',
bank_account_bic: addr.bank_account_bic || '',
sepa_date: addr.sepa_date || ''
});
}
}
// Pre-fill external reference with shipping note reference
this.invoiceData.externe_referenz = `Lieferschein #${shippingNoteData.shippingNoteId}`;
// Add introductory text if shipping note has notes
if (shippingNoteData.note) {
this.invoiceData.einleitender_text = shippingNoteData.note;
}
// Add all positions (batch operation to avoid triggering watcher for each item)
if (shippingNoteData.positions && Array.isArray(shippingNoteData.positions)) {
const newPositions = shippingNoteData.positions.map(position => ({
product_name: position.product_name || '',
product_info: position.product_info || '',
amount: parseFloat(position.amount) || 0,
unit: position.unit || 'Stk.',
price: parseFloat(position.price) || 0,
discount: parseFloat(position.discount) || 0,
vatrate: parseFloat(position.vatrate) || 20
}));
// Add all positions at once instead of one by one
this.invoiceData.positions.push(...newPositions);
}
}
// Notify user
const positionCount = shippingNoteDataArray.reduce((sum, sn) => sum + (sn.positions?.length || 0), 0);
window.notify('success', `Lieferschein erfolgreich importiert (${positionCount} Position(en))`);
} finally {
// Re-enable the watcher
this.$options.watch['invoiceData'] = originalWatcher;
// Trigger one preview update after import is complete
this.$nextTick(() => {
this.debouncedPreviewUpdate();
});
}
}
}
});
Vue.component('gutschrift-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @close="close" size="lg" title="Gutschrift erstellen">
<div v-if="loading" class="text-center py-5"><i class="fas fa-spinner fa-spin fa-3x"></i><p class="mt-3">Lade Rechnungsdaten...</p></div>
<div v-else-if="invoice">
<div class="alert alert-info"><strong>Originalrechnung:</strong> {{ invoice.invoice_number }} - {{ invoice.customer_name }}</div>
<div v-if="!invoice.positions.length" class="alert alert-warning"><i class="fas fa-exclamation-triangle"></i> Alle Positionen gutgeschrieben.</div>
<div v-else>
<p><strong>Positionen wählen:</strong></p>
<table class="table table-sm table-bordered">
<thead><tr>
<th style="width: 50px;"><input type="checkbox" @change="toggleAll" v-model="allSelected"></th>
<th>Bezeichnung</th><th style="width: 100px;">Orig.</th><th style="width: 100px;">Gutschr.</th><th style="width: 100px;">Verfügbar</th>
<th style="width: 120px;">Neu Gutschrift</th><th style="width: 100px;">Einzel</th><th style="width: 100px;">Gesamt</th>
</tr></thead>
<tbody>
<tr v-for="(pos, index) in invoice.positions" :key="index">
<td class="text-center"><input type="checkbox" v-model="selectedPositions[index]"></td>
<td><strong>{{ pos.product_name }}</strong><div v-if="pos.product_info" class="text-muted small">{{ pos.product_info }}</div></td>
<td class="text-right">{{ pos.original_amount }}</td><td class="text-right">{{ pos.credited_amount }}</td>
<td class="text-right">{{ pos.available_amount }}</td>
<td><input type="number" class="form-control form-control-sm" v-model.number="creditAmounts[index]" :max="pos.available_amount" :disabled="!selectedPositions[index]" step="0.001" min="0.001"></td>
<td class="text-right">{{ formatPrice(pos.price) }}</td><td class="text-right">{{ formatPrice(calcTotal(index)) }}</td>
</tr>
</tbody>
<tfoot><tr><td colspan="7" class="text-right"><strong>Gesamt:</strong></td><td class="text-right"><strong>{{ formatPrice(totalCredit) }}</strong></td></tr></tfoot>
</table>
</div>
</div>
<template v-slot:footer>
<tt-button text="Erstellen" icon="fas fa-check" @click="create" additional-class="btn-success" :disabled="!validSelection || creating"/>
<tt-button text="Abbrechen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
</template>
</tt-modal>
`,
data: () => ({ loading: true, creating: false, invoice: null, selectedPositions: {}, creditAmounts: {}, allSelected: false }),
computed: {
validSelection() { return this.invoice && Object.keys(this.selectedPositions).some(i => this.selectedPositions[i] && this.creditAmounts[i] > 0 && this.creditAmounts[i] <= this.invoice.positions[i].available_amount); },
totalCredit() { return Object.keys(this.selectedPositions).reduce((sum, i) => this.selectedPositions[i] ? sum + ((this.creditAmounts[i] || 0) * this.invoice.positions[i].price) : sum, 0); }
},
async mounted() {
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceForGutschrift?id=${this.invoiceId}`);
if (data.success) {
this.invoice = data.invoice;
this.invoice.positions.forEach((p, i) => { this.$set(this.selectedPositions, i, false); this.$set(this.creditAmounts, i, p.available_amount); });
} else {
window.notify('error', data.message || 'Fehler'); this.close();
}
} catch (e) { window.notify('error', 'Fehler'); this.close(); } finally { this.loading = false; }
},
methods: {
toggleAll() { this.invoice.positions.forEach((p, i) => this.$set(this.selectedPositions, i, this.allSelected)); },
calcTotal(i) { return this.selectedPositions[i] ? (this.creditAmounts[i] || 0) * this.invoice.positions[i].price : 0; },
async create() {
const positions = this.invoice.positions
.map((p, i) => ({ p, i })).filter(({ i }) => this.selectedPositions[i])
.map(({ p, i }) => {
const amt = this.creditAmounts[i];
if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.product_name}`);
return amt > 0 ? { ...p, amount: amt } : null;
}).filter(Boolean);
if (!positions.length) return window.notify('error', 'Keine Positionen gewählt');
this.creating = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createGutschrift`, { original_invoice_id: this.invoiceId, positions });
if (data.success) { window.notify('success', 'Gutschrift erstellt'); this.$emit('created', data.credit_invoice_id); }
else window.notify('error', data.message || 'Fehler');
} catch (e) { window.notify('error', e.message || 'Fehler'); } finally { this.creating = false; }
},
close() { this.$emit('close'); },
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); }
}
});
Vue.component('send-invoice-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @close="close" @submit="handleAction" :submit-text="actionButtonText" :is-loading="loading" size="md">
<template v-slot:header>
<h5><i class="fas fa-paper-plane mr-2"></i>Rechnung aussenden</h5>
</template>
<div v-if="!invoice" class="text-center py-4">
<tt-loader />
</div>
<div v-else>
<div class="mb-3">
<strong>Rechnung:</strong> {{ invoice.invoice_number }}<br/>
<strong>Kunde:</strong> {{ invoice.customerName }}
</div>
<hr/>
<div class="form-group">
<label>Aktion auswählen:</label>
<div class="form-check">
<input class="form-check-input" type="radio" id="action-email" value="email" v-model="selectedAction" :disabled="!invoice.email">
<label class="form-check-label" for="action-email">
<i class="fas fa-envelope mr-2"></i>Per E-Mail versenden
<span v-if="invoice.email" class="text-muted d-block ml-4">an {{ invoice.email }}</span>
<span v-else class="text-danger d-block ml-4">Keine E-Mail-Adresse vorhanden</span>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="action-download" value="download" v-model="selectedAction">
<label class="form-check-label" for="action-download">
<i class="fas fa-download mr-2"></i>PDF herunterladen
</label>
</div>
</div>
<div v-if="selectedAction === 'email' && invoice.email" class="mt-3">
<tt-input label="E-Mail-Adresse" v-model="emailAddress" sm/>
</div>
</div>
</tt-modal>
`,
data() {
return {
invoice: null,
loading: false,
selectedAction: 'email',
emailAddress: ''
};
},
computed: {
actionButtonText() {
return this.selectedAction === 'email' ? 'E-Mail versenden' : 'Herunterladen';
}
},
async mounted() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceEmail`, { id: this.invoiceId });
if (data.success) {
this.invoice = data.invoice;
this.emailAddress = data.invoice.email || '';
this.selectedAction = data.invoice.email ? 'email' : 'download';
} else {
window.notify('error', data.message || 'Fehler beim Laden der Rechnung');
this.close();
}
} catch (e) {
window.notify('error', 'Fehler beim Laden der Rechnung');
this.close();
}
},
methods: {
async handleAction() {
if (this.selectedAction === 'email') {
await this.sendEmail();
} else {
await this.downloadPdf();
}
},
async sendEmail() {
if (!this.emailAddress) {
window.notify('error', 'Bitte E-Mail-Adresse eingeben');
return;
}
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/sendInvoiceEmail`, {
id: this.invoiceId,
email: this.emailAddress
});
if (data.success) {
window.notify('success', data.message);
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Versenden');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
async downloadPdf() {
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoice`, {
id: this.invoiceId
});
if (data.success && data.url) {
window.location.href = data.url;
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Download');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
close() {
this.$emit('close');
}
}
});
@@ -33,7 +33,7 @@ Vue.component('PreorderRimoTypeMap', {
],
rimoTypeDefs: {
greenfield: {text: 'Greenfield', icon: 'fas fa-tree', color: '#28a745'},
residential: {text: 'Wohngebiet', icon: 'fas fa-home', color: '#007bff'},
residential: {text: 'Wohnhaus', icon: 'fas fa-home', color: '#007bff'},
company: {text: 'Gewerbe', icon: 'fas fa-building', color: '#ffc107'},
'multiple-dwelling': {text: 'Mehrfamilienhaus', icon: 'fas fa-city', color: '#6f42c1'},
public: {text: 'Öffentlich', icon: 'fas fa-school', color: '#17a2b8'},
@@ -242,6 +242,7 @@ Vue.component('PreorderRimoTypeMap', {
let tooltipInnerClass = '';
if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) tooltipInnerClass = ' marker-label-saturated';
else if (rimoType === 'greenfield' && group.preorder_count > 0) tooltipInnerClass = ' marker-label-highlight';
else if (group.preorder_count > group.wohneinheit_count && group.wohneinheit_count > 0) tooltipInnerClass = ' marker-label-highlight';
return {
lat: group.gps_lat, lng: group.gps_long, rimoType, hausnummerId: group.hausnummer_id,
options: {
+246 -247
View File
@@ -1,247 +1,246 @@
/* ===== Radius.css ===== */
:root{ --brand-blue: #005384; --bg: #ffffff; --card: #ffffff; --card-2: #f8fafc; --muted: #667085; --text: #0b1320; --accent: var(--brand-blue); --accent-2: #1e88c9; --ok: #0f9d58; --bad: #e03131; --ring: rgba(0,83,132,.20); --border: #e6e9ef; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --radius: 10px; --radius-pill: 999px; --shadow: 0 8px 24px rgba(0, 83, 132, .08); }
.radius-scope a.link { color: var(--accent); text-decoration: none; font-weight: 500; transition: color .2s ease; }
.radius-scope a.link:hover { color: var(--accent-2); text-decoration: underline; }
.radius-scope .muted { color: var(--muted); }
.radius-scope .small { font-size: 12px; }
.radius-scope .mini { font-size: 11px; }
.radius-scope .mono { font-family: var(--mono); }
.radius-scope .center { text-align: center; }
.radius-scope .p-sm { padding: .5rem; }
.radius-scope .p-lg { padding: 1.25rem; }
.radius-scope .mt-2 { margin-top: .5rem; }
.radius-scope .mt-3 { margin-top: .75rem; }
.radius-scope .mt-between { margin-top: 12px; }
.radius-scope .nowrap { white-space: nowrap; }
.radius-scope .inline-copy { display:flex; align-items:center; gap:8px; justify-content: flex-end; }
.radius-scope .grid { display:grid; }
.radius-scope .g-2 { gap: 8px; }
.radius-scope .g-4 { gap: 16px; }
.radius-scope .g-6 { gap: 24px; }
.radius-scope .cols-1 { grid-template-columns: 1fr; }
.radius-scope .cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.radius-scope .cols-4 { grid-template-columns: repeat(4, minmax(0,1fr)); }
@media (max-width: 900px){ .radius-scope .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 600px){ .radius-scope .cols-4 { grid-template-columns: 1fr; } }
@media (min-width: 900px){ .radius-scope .cols-2@lg { grid-template-columns: repeat(2, minmax(0,1fr)); } .radius-scope .cols-4@lg { grid-template-columns: repeat(4, minmax(0,1fr)); } }
@media (min-width: 1200px){ .radius-scope .cols-2-xl { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 899.98px){ .radius-scope .cols-1@sm { grid-template-columns: 1fr; } }
.radius-scope .badge { display:inline-block; padding:2px 8px; border-radius:999px; background:#eef6fb; color:#0b3a57; font-size:12px; border:1px solid #d6e8f5; }
.radius-scope .h4 { font-size:18px; font-weight:800; letter-spacing:.2px; user-select: none; }
.radius-scope .h5 { font-size:16px; font-weight:800; letter-spacing:.2px; user-select: none; }
.radius-scope .cluster { display:flex; gap:10px; flex-wrap:wrap; align-items: center; }
.radius-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 0 auto; padding: 20px 0; }
.radius-scope .card, .radius-scope .subcard, .radius-scope .progress-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
.radius-scope .card { padding: 14px; }
.radius-scope .subcard { padding: 12px; }
.radius-scope .pane-header { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap: wrap;}
.radius-scope .pane-header .title { display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.2px; font-size: 18px; user-select: none; }
.radius-scope .logo-dot { width:14px; height:14px; border-radius:50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display:inline-block; }
.radius-scope .view-tabs { display:flex; gap:8px; flex-wrap:wrap; }
.radius-scope .view-select-wrap { display: none; }
.radius-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; }
@media (max-width: 800px) { .radius-scope .view-tabs { display: none; } .radius-scope .view-select-wrap { display: block; } }
.radius-scope .tab-btn, .radius-scope .primary-btn, .radius-scope .ghost-btn, .radius-scope .icon-btn, .radius-scope .link-btn, .radius-scope .danger-btn { appearance:none; outline:none; border:none; cursor:pointer; font-weight:700; letter-spacing:.2px; transition: transform .12s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; user-select: none; }
.radius-scope .tab-btn { padding: 8px 12px; border-radius: var(--radius-pill); background: #f4f7fb; color: var(--text); border: 1px solid var(--border); }
.radius-scope .tab-btn.active, .radius-scope .tab-btn:hover { background: #eef6fb; border-color: #d6e8f5; box-shadow: 0 0 0 4px var(--ring); transform: scale(0.98); }
.radius-scope .tab-btn:disabled { opacity: .6; cursor: not-allowed; background: #f4f7fb; border-color: var(--border); box-shadow: none; transform: none; }
.radius-scope .primary-btn { padding: 8px 14px; border-radius: var(--radius); color:#fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 18px rgba(0,83,132,.25); height: 38px; display: inline-flex; align-items: center; justify-content: center; }
.radius-scope .primary-btn:disabled { opacity:.6; cursor:not-allowed; }
.radius-scope .ghost-btn { padding: 8px 12px; border-radius: var(--radius); color: var(--accent); background: #f8fbff; border:1px dashed #cfe4f3; display: inline-flex; align-items: center; justify-content: center; min-height: 38px; }
.radius-scope .danger-btn { padding: 8px 12px; border-radius: var(--radius); color: #c92a2a; background: #fff5f5; border: 1px dashed #ffc9c9; opacity: .9; transition: opacity .2s ease-in-out, transform .1s ease-in-out; }
.radius-scope .danger-btn:hover { opacity: 1; }
.radius-scope .danger-btn:active { transform: scale(0.97); }
.radius-scope .primary-btn:not(:disabled):hover, .radius-scope .ghost-btn:not(:disabled):hover, .radius-scope .danger-btn:not(:disabled):hover { transform: translateY(-2px); }
.radius-scope .primary-btn:not(:disabled):hover { box-shadow: 0 8px 22px rgba(0,83,132,.3); }
.radius-scope .icon-btn { background: transparent; color: var(--muted); padding: 6px 8px; border-radius:8px; }
.radius-scope .icon-btn.sm { padding: 4px 6px; }
.radius-scope .icon-btn:hover { color: var(--text); background:#f2f6fa; }
.radius-scope .link-btn { background: transparent; color: var(--accent); text-decoration: underline; }
@keyframes copy-feedback-pop { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } }
.radius-scope [data-tooltip="Kopieren"], .radius-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; }
.radius-scope .icon-btn .check-icon { display: none; }
.radius-scope .icon-btn.is-copied, .radius-scope .icon-btn.is-copied:hover { background-color: #eaf7ef; color: var(--ok); animation: copy-feedback-pop 0.3s ease-in-out; }
.radius-scope .icon-btn.is-copied .copy-icon { display: none; }
.radius-scope .icon-btn.is-copied .check-icon { display: inline-block; }
.radius-scope .input-wrap { position: relative; }
.radius-scope .ri { box-sizing: border-box; width: 100%; padding: 8px 38px 8px 36px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; color: var(--text); transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; }
.radius-scope .ac-root .ri { padding: 8px 38px 8px 75px; }
.radius-scope .ri:hover:not(:focus) { border-color: #c4d1de; }
.radius-scope .ri:focus { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; background: #fbfeff; }
.radius-scope .ri::placeholder{ color:#9aa6b2; }
.radius-scope .input-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color:#7997ad; font-size: 14px; pointer-events: none; }
.radius-scope .input-icon-logo { height: 20px; width: auto; opacity: 0.9; }
.radius-scope .btn-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 28px; height: 28px; border-radius: 8px; border: none; background: transparent; color:#5a7891; cursor: pointer; transition: all .2s ease; opacity: 1; }
.radius-scope .btn-clear:not(:disabled):hover { background:#e8f2f9; color:#2b5c7e; }
.radius-scope .btn-clear:disabled { background: transparent; color: #c1cbd5; cursor: not-allowed; transform: scale(0.9); opacity: 0.5; }
.radius-scope .btn-clear:disabled:hover { background: transparent; color: #c1cbd5; }
.radius-scope .logo-switcher { position: absolute; left: 1px; top: 1px; height: calc(100% - 2px); display: flex; align-items: center; gap: 8px; padding: 0 4px 0 8px; cursor: pointer; border-right: 1px solid var(--border); transition: background-color .2s ease; border-radius: 9px 0 0 9px; user-select: none; }
.radius-scope .logo-switcher:hover { background-color: #f8fafc; }
.radius-scope .switcher-caret { font-size: 11px; color: var(--muted); transition: transform .2s ease; }
.radius-scope .logo-switcher.is-open .switcher-caret { transform: rotate(180deg); }
.radius-scope .logo-dropdown { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; width: 180px; }
.radius-scope .logo-option { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
.radius-scope .logo-option:hover { background-color: #f3f8fc; }
.radius-scope .logo-option img { height: 18px; width: auto; }
.radius-scope .select select { width:100%; padding:10px 12px; border-radius: var(--radius); border:1px solid var(--border); background:#fff; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right .5rem center; background-repeat: no-repeat; background-size: 1.5em 1.5em; padding-right: 2.5rem; }
.radius-scope .switch-field { display:flex; flex-direction:column; gap:6px;align-items: center }
.radius-scope .switch { display:inline-flex; align-items:center; cursor:pointer; user-select:none; }
.radius-scope .switch input { display:none; }
.radius-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display:inline-flex; align-items:center; justify-content:space-between; padding: 0 8px; color:#7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; }
.radius-scope .switch .on { opacity: 0; transition: opacity .18s ease; }
.radius-scope .switch .off { opacity: 1; transition: opacity .18s ease; }
.radius-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background:#fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; }
.radius-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color:#fff; }
.radius-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); }
.radius-scope .switch input:checked + .switch-track::after { transform: translateX(26px); }
.radius-scope .switch input:checked + .switch-track .on { opacity: 1; }
.radius-scope .switch input:checked + .switch-track .off { opacity: 0; }
.radius-scope .ac-root { position: relative; }
.radius-scope .ac-panel { position: absolute; left: 0; min-width: 100%; width: auto; margin-top: 6px; z-index: 20; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); padding: 8px; }
.radius-scope .ac-panel.wide, .radius-scope [data-wide="1"] .ac-panel { left: -6px; right: auto; }
.radius-scope .ac-skel .skeleton-line { height: 12px; margin: 8px 0; }
.radius-scope .ac-empty { padding: 10px; }
.radius-scope .ac-list { list-style: none; margin: 0; padding: 0; max-height: 260px; overflow: auto; }
.radius-scope .ac-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: transform .1s ease, background-color .1s ease; white-space: nowrap; }
.radius-scope .ac-item:hover, .radius-scope .ac-item.is-active { background:#f3f8fc; transform: scale(0.99); }
.radius-scope .ac-more-info { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--card-2); font-style: italic; cursor: default; }
.radius-scope .ac-more-info .txt { color: var(--muted); }
.radius-scope .ac-pop-enter-active, .radius-scope .ac-pop-leave-active { transition: opacity .12s ease, transform .12s ease; transform-origin: top center; }
.radius-scope .ac-pop-enter, .radius-scope .ac-pop-leave-to { opacity:0; transform: translateY(-4px) scale(.98); }
.radius-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; }
@media (max-width: 1200px) { .radius-scope .filters-layout { grid-template-columns: 1fr 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } }
@media (max-width: 768px) { .radius-scope .filters-layout { grid-template-columns: 1fr; } .radius-scope .filters-layout .field:nth-child(1), .radius-scope .filters-layout .field:nth-child(4) { grid-column: auto; } }
.radius-scope .field label { display:block; margin: 0 0 6px; color: var(--muted); font-size: 12px; }
.radius-scope .table-wrap { overflow:auto; border-radius: 12px; border:1px solid var(--border); background: var(--card-2); max-height: 65vh; }
.radius-scope .table-wrap::-webkit-scrollbar { width: 8px; height: 8px; }
.radius-scope .table-wrap::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 10px; }
.radius-scope .table-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; border: 2px solid #f1f5f9; }
.radius-scope .table-wrap::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.radius-scope .tt-table { width:100%; min-width: 1000px; border-collapse: collapse; background: #fff; table-layout: fixed; margin-bottom: unset !important; }
.radius-scope .tt-table.no-min-width { min-width: auto; }
.radius-scope .tt-table th, .radius-scope .tt-table td { padding: 10px 12px; border-bottom:1px solid #eef1f5; vertical-align: middle; }
.radius-scope .tt-table thead th { position:sticky; top:0; background:#f6f9fc; font-size:12px; color:#344054; text-transform:uppercase; letter-spacing:.04em; user-select: none; z-index: 10; }
.radius-scope .tt-table.compact th, .radius-scope .tt-table.compact td { padding:8px 10px; }
.radius-scope .tt-table.ultra-compact th, .radius-scope .tt-table.ultra-compact td { padding:6px 8px; font-size:12px; }
.radius-scope .rows-enter-active, .radius-scope .rows-leave-active { transition: opacity .12s ease, transform .12s ease; }
.radius-scope .rows-enter, .radius-scope .rows-leave-to { opacity:0; transform: translateY(2px); }
.radius-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; }
.radius-scope .results-summary { padding: 8px 12px; border: 1px solid var(--border); border-top: none; background: #f6f9fc; font-size: 13px; color: var(--muted); border-radius: 0 0 12px 12px; min-height: 38px; display: flex; align-items: center; }
.radius-scope .table-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--card-2); text-align: center; color: var(--muted); font-size: 16px; }
.radius-scope .table-placeholder i { font-size: 32px; color: var(--brand-blue); }
.radius-scope .row-fade-in { animation: rowIn .22s ease; }
@keyframes rowIn { from { opacity:0; transform: translateY(2px);} to {opacity:1; transform: none;} }
.radius-scope .skeleton-line { --h: 12px; height: var(--h); border-radius: 8px; background: linear-gradient(90deg, #eaeef3, #f3f6fa, #eaeef3); background-size: 300% 100%; animation: shimmer 1.1s infinite linear; }
@keyframes shimmer { 0%{background-position:0% 0} 100%{background-position:100% 0} }
.radius-scope .btn-loader { width: 18px; height: 18px; border: 2px solid #d5e7f4; border-top-color: var(--brand-blue); border-radius:50%; display:inline-block; animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg);} }
.radius-scope.modal-overlay { position: fixed; inset:0; background: rgba(0,0,0,.25); display:flex; align-items:center; justify-content:center; padding: 20px; z-index: 9999; }
.radius-scope .modal-card { width:min(780px, 92vw); max-height: 88vh; overflow:auto; border-radius: 16px; border:1px solid var(--border); background: #fff; }
.radius-scope .modal-head { display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom:1px solid var(--border); position:sticky; top:0; background: #fff; z-index: 10; user-select: none; }
.radius-scope .modal-title { font-weight:800; }
.radius-scope .modal-body { padding: 14px 16px; }
.radius-scope .fade-enter-active, .radius-scope .fade-leave-active { transition: opacity .14s ease; }
.radius-scope .fade-enter, .radius-scope .fade-leave-to { opacity:0; }
.radius-scope .pop { animation: pop .16s ease; }
@keyframes pop { from { transform: scale(.98);} to { transform: none;} }
.radius-scope .kv { display:grid; grid-template-columns: 180px 1fr; gap: 10px 16px; }
.radius-scope .kv > div { display: contents; }
.radius-scope .kv > div > span { color: var(--muted); }
.radius-scope .kv-redesign { display: flex; flex-direction: column; }
.radius-scope .kv-redesign .kv-row { display: flex; align-items: flex-start; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid var(--border); gap: 16px; }
.radius-scope .kv-redesign .kv-row:last-child { border-bottom: none; }
.radius-scope .kv-redesign .kv-label { color: var(--muted); flex-shrink: 0; width: 140px; }
.radius-scope .kv-redesign .kv-value { flex-grow: 1; text-align: right; word-break: break-all; min-width: 0; }
.radius-scope .kv-redesign .chip { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); }
.radius-scope .kv-redesign .chip.ok { background: #eaf7ef; color:#206a42; border-color: #c9e6d8; }
.radius-scope .kv-redesign .chip.bad { background: #fdecec; color:#8a1d1d; border-color: #f6d2d2; }
.radius-scope .ros-wrap { min-height: 28px; display:flex; align-items:center; justify-content:flex-start; width: 170px; }
.radius-scope .ros-chip { display:flex; align-items:center; gap:8px; padding:4px 8px; border-radius: var(--radius); font-size:12px; font-family: var(--mono); border:1px solid var(--border); background:#fff; width: 100%; height: 28px; box-sizing: border-box; }
.radius-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; }
.radius-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; }
.radius-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); }
.radius-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); }
.radius-scope .ros-chip .dot { width:8px; height:8px; border-radius:50%; background: currentColor; color: inherit; flex-shrink: 0; }
.radius-scope .ros-chip.on .dot { background: var(--ok); }
.radius-scope .ros-chip.off .dot { background: var(--bad); }
.radius-scope .ros-chip .ip { flex-grow: 1; text-align: center; }
.radius-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; }
.radius-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; }
.radius-scope .ont-card .block { background: #fff; border:1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); }
.radius-scope .ont-card .block + .block { margin-top: 12px; }
.radius-scope .block-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 10px; flex-wrap: wrap; }
.radius-scope .file-drop { display: flex; align-items: center; justify-content: center; border: 2px dashed #cfe4f3; border-radius: var(--radius); padding: 20px; text-align: center; background: #f8fbff; cursor: pointer; transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease, background-color .2s ease; min-height: 150px; }
.radius-scope .file-drop.is-dragover { transform: scale(1.02); border-color: var(--accent); background-color: #f0f8ff; box-shadow: 0 0 0 5px var(--ring); }
.radius-scope .file-cta { display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; color:#365972; }
.radius-scope .overlay { position:fixed; inset:0; background:rgba(255,255,255,.8); backdrop-filter: blur(4px); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index: 50; text-align: center; }
.radius-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); }
.radius-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; }
.radius-scope .animated-hourglass { animation: hourglass-turn 2s infinite linear; }
@keyframes hourglass-turn { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.radius-scope .progress-bar { height: 8px; background:#eef4f8; border-radius:999px; overflow:hidden; border:1px solid #e2ebf3; }
.radius-scope .progress-bar .bar { height:100%; width:0; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s ease; }
.radius-scope .progress-bar.is-yellow .bar { background: linear-gradient(90deg, #f7b733, #fc4a1a); }
.radius-scope .alert.error { padding:10px 12px; border-radius:10px; border:1px solid #ffd6d6; background:#fff3f3; color:#8a1d1d; }
.radius-scope .card-in { animation: cardIn .18s ease; }
@keyframes cardIn { from{ opacity:0; transform: translateY(4px);} to { opacity:1; transform: none;} }
[data-tooltip] { position: relative; }
[data-tooltip]::before, [data-tooltip]::after { position: absolute; left: 50%; transform: translateX(-50%) translateY(0); opacity: 0; pointer-events: none; transition: all .18s ease-in-out; z-index: 10001; }
[data-tooltip]::before { content: ''; bottom: 100%; border: 5px solid transparent; border-top-color: #0b1320; }
[data-tooltip]::after { content: attr(data-tooltip); bottom: calc(100% + 5px); padding: 4px 8px; border-radius: 6px; background: #0b1320; color: #fff; font-size: 12px; font-weight: 500; white-space: nowrap; }
[data-tooltip]:hover::before, [data-tooltip]:hover::after { opacity: 1; transform: translateX(-50%) translateY(-4px); }
[data-tooltip-align="right"]::after { left: 0; transform: translateX(0); }
[data-tooltip-align="right"]::before { left: 1em; transform: translateX(-50%); }
[data-tooltip-align="right"]:hover::after, [data-tooltip-align="right"]:hover::before { transform: translateX(0) translateY(-4px); }
[data-tooltip-align="right"]:hover::before { transform: translateX(-50%) translateY(-4px); }
[data-tooltip-align="left"]::after { left: auto; right: 0; transform: translateX(0); }
[data-tooltip-align="left"]::before { left: auto; right: 1em; transform: translateX(-50%); }
[data-tooltip-align="left"]:hover::after, [data-tooltip-align="left"]:hover::before { transform: translateX(0) translateY(-4px); }
[data-tooltip-align="left"]:hover::before { transform: translateX(-50%) translateY(-4px); }
[data-tooltip-align="bottom"]::after { top: calc(100% + 5px); bottom: auto; }
[data-tooltip-align="bottom"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; }
[data-tooltip-align="bottom"]:hover::before, [data-tooltip-align="bottom"]:hover::after { transform: translateX(-50%) translateY(4px); }
[data-tooltip-wrap="true"]::after { white-space: normal; max-width: 220px; text-align: center; }
/* NEW RULE FOR BOTTOM-LEFT ALIGNMENT */
[data-tooltip-align="bottom-left"]::after { top: calc(100% + 5px); bottom: auto; left: auto; right: 0; transform: translateX(0); }
[data-tooltip-align="bottom-left"]::before { top: 100%; bottom: auto; border-top-color: transparent; border-bottom-color: #0b1320; left: auto; right: 1em; transform: translateX(50%); }
[data-tooltip-align="bottom-left"]:hover::after, [data-tooltip-align="bottom-left"]:hover::before { transform: translateY(4px); }
[data-tooltip-align="bottom-left"]:hover::before { transform: translateX(50%) translateY(4px); }
.radius-scope .ip-field-wrapper, .radius-scope .ac-root { position: relative; }
.radius-scope .ip-focus-tooltip, .radius-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 4px); left: 0; background: #f8fbff; border: 1px solid #cfe4f3; padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--accent); white-space: nowrap; opacity: 0; transform: translateY(4px); pointer-events: none; transition: all .18s ease-in-out; z-index: 10; }
.radius-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .radius-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); }
.radius-scope .modal-card-wide { width: min(1100px, 92vw); }
.radius-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; }
.radius-scope .unselectable { user-select: none; }
.radius-scope .custom-dropdown { position: relative; width: 120px; }
.radius-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; }
.radius-scope .dropdown-toggle:hover { border-color: #c4d1de; }
.radius-scope .dropdown-toggle:focus, .radius-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; }
.radius-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; }
.radius-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); }
.radius-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; }
.radius-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; }
.radius-scope .dropdown-item:hover, .radius-scope .dropdown-item.is-active { background-color: #f3f8fc; }
.radius-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); }
.radius-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; }
.radius-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; }
.radius-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); }
.radius-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; }
.radius-scope .stat-card-v2.stat-total .stat-value { color: var(--text); }
.radius-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; }
.radius-scope .stat-card-v2.stat-download .stat-value { color: #15803d; }
.radius-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; }
.radius-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); }
.radius-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; }
.radius-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); }
.radius-scope .stat-card-v2-skeleton { display: flex; align-items: center; gap: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); }
.radius-scope .stat-card-v2-skeleton .icon { width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; }
.radius-scope .stat-card-v2-skeleton .text { flex-grow: 1; }
.radius-scope .stat-card-v2-skeleton .label { height: 12px; width: 70%; margin-bottom: 6px; }
.radius-scope .stat-card-v2-skeleton .value { height: 18px; width: 90%; }
.radius-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; }
.radius-scope .chart-card canvas { max-height: calc(250px - 32px); }
.radius-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); }
.radius-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; }
.radius-scope .modal-skeleton .skeleton-line { margin-bottom: 12px; }
.radius-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; }
.radius-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; }
/* ===== Radius Module Styles ===== */
/* General utilities moved to tt-core.css - this file contains ONLY Radius-specific styles */
/* CSS Variables for backwards compatibility */
:root {
--brand-blue: #005384;
--bg: #ffffff;
--card: #ffffff;
--card-2: #f8fafc;
--muted: #667085;
--text: #0b1320;
--accent: var(--brand-blue);
--accent-2: #1e88c9;
--ok: #0f9d58;
--bad: #e03131;
--ring: rgba(0,83,132,.20);
--border: #e6e9ef;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 10px;
--radius-pill: 999px;
--shadow: 0 8px 24px rgba(0, 83, 132, .08);
--line-offset: 32px;
}
/* Radius-specific layouts */
.tt-scope .free-users-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 1100px) { .tt-scope .free-users-grid { grid-template-columns: 1fr; } }
.tt-scope .free-users-column { display: flex; flex-direction: column; gap: 12px; }
.tt-scope .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef6fb; color: #0b3a57; font-size: 12px; border: 1px solid #d6e8f5; }
.tt-scope.radius-container { background: transparent; color: var(--text); display: grid; gap: 16px; max-width: 90vw; margin: 24px auto 0; padding: 20px 0; }
.tt-scope .subcard { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px; }
.tt-scope .pane-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: linear-gradient(135deg, #f8fbff 0%, #f0f7fd 100%); padding: 16px 20px; margin: -14px -14px 14px -14px; border-radius: var(--radius) var(--radius) 0 0; border-bottom: 2px solid #e3f0f8; }
.tt-scope .pane-header .title { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .4px; font-size: 22px; user-select: none; color: var(--accent); text-shadow: 0 1px 2px rgba(0,83,132,.1); }
.tt-scope .logo-dot { width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 30% 30%, #37d26b, #0f9d58 70%); box-shadow: 0 0 0 3px rgba(15,157,88,.15); display: inline-block; }
.tt-scope .content-divider { border: none; height: 1px; background-color: var(--border); margin: 16px 0; }
/* Switch Field */
.tt-scope .switch-field { display: flex; flex-direction: column; gap: 6px; align-items: center; }
.tt-scope .switch { display: inline-flex; align-items: center; cursor: pointer; user-select: none; }
.tt-scope .switch input { display: none; }
.tt-scope .switch .switch-track { position: relative; width: 58px; height: 32px; border-radius: 999px; background: #e8edf3; border: 1px solid #d7e1ea; display: inline-flex; align-items: center; justify-content: space-between; padding: 0 8px; color: #7b8a98; transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; }
.tt-scope .switch .on { opacity: 0; transition: opacity .18s ease; }
.tt-scope .switch .off { opacity: 1; transition: opacity .18s ease; }
.tt-scope .switch .switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; background: #fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,.08); transition: transform .18s ease, box-shadow .18s ease; }
.tt-scope .switch input:checked + .switch-track { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: #a9d0ea; color: #fff; }
.tt-scope .switch input:focus-visible + .switch-track { box-shadow: 0 0 0 5px var(--ring); }
.tt-scope .switch input:checked + .switch-track::after { transform: translateX(26px); }
.tt-scope .switch input:checked + .switch-track .on { opacity: 1; }
.tt-scope .switch input:checked + .switch-track .off { opacity: 0; }
/* Filters Layout */
.tt-scope .filters-layout { display: grid; gap: 12px; align-items: flex-end; grid-template-columns: 1.5fr 210px 190px 1fr auto; margin-bottom: 16px; }
@media (max-width: 1400px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1) { grid-column: 1 / -1; } }
@media (max-width: 900px) { .tt-scope .filters-layout { grid-template-columns: 1fr 1fr; } .tt-scope .filters-layout .field:nth-child(1), .tt-scope .filters-layout .field:nth-child(4) { grid-column: 1 / -1; } }
@media (max-width: 600px) { .tt-scope .filters-layout { grid-template-columns: 1fr; } .tt-scope .filters-layout .field { grid-column: auto !important; } }
.tt-scope .field label { display: block; margin: 0 0 6px; color: var(--muted); font-size: 12px; }
.tt-scope .results-container .table-wrap { border-radius: 12px 12px 0 0; border-bottom: none; }
.tt-scope .inline-copy { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.tt-scope .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tt-scope .clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* KV Layouts */
.tt-scope .kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 16px; }
.tt-scope .kv > div { display: contents; }
.tt-scope .kv > div > span { color: var(--muted); }
/* Key-Value Redesign Layout - moved to tt-core.css */
/* Radius Online Status Chip */
.tt-scope .ros-wrap { min-height: 28px; display: flex; align-items: center; justify-content: flex-start; width: 170px; }
.tt-scope .ros-chip { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: var(--radius); font-size: 12px; font-family: var(--mono); border: 1px solid var(--border); background: #fff; width: 100%; height: 28px; box-sizing: border-box; }
.tt-scope .ros-chip.is-clickable { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; }
.tt-scope .ros-chip.is-clickable:hover { background-color: #f3f8fc; }
.tt-scope [data-tooltip="Kopieren"], .tt-scope .ros-chip.is-clickable { user-select: none; -webkit-user-select: none; }
.tt-scope .ros-chip.on { box-shadow: 0 0 0 3px rgba(15,157,88,.08); }
.tt-scope .ros-chip.off { box-shadow: 0 0 0 3px rgba(224,49,49,.08); }
.tt-scope .ros-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; color: inherit; flex-shrink: 0; }
.tt-scope .ros-chip.on .dot { background: var(--ok); }
.tt-scope .ros-chip.off .dot { background: var(--bad); }
.tt-scope .ros-chip .ip { flex-grow: 1; text-align: center; }
.tt-scope .ros-chip.skeleton { background: #f8fafc; color: #d1d9e4; align-items: center; }
.tt-scope .ros-chip.is-copied { background-color: #eaf7ef; border-color: #c9e6d8; box-shadow: 0 0 0 3px rgba(15, 157, 88, 0.25); animation: copy-feedback-pop 0.3s ease-in-out; }
/* ONT Card Styles */
.tt-scope .ont-card .block { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); }
.tt-scope .ont-card .block + .block { margin-top: 12px; }
.tt-scope .block-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
/* Radius-Specific Tooltips */
.tt-scope .ip-field-wrapper, .tt-scope .ac-root { position: relative; }
.tt-scope .ip-focus-tooltip, .tt-scope .ac-focus-tooltip { position: absolute; bottom: calc(100% + 8px); left: 0; background: linear-gradient(135deg, #e3f0f8 0%, #d6e8f5 100%); border: 1px solid #b8d9f0; padding: 6px 12px; border-radius: 8px; font-size: 11px; font-weight: 600; color: #0b3a57; white-space: nowrap; opacity: 0; transform: translateY(6px); pointer-events: none; transition: all .22s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 50; box-shadow: 0 4px 12px rgba(0, 83, 132, .15), 0 0 0 1px rgba(255, 255, 255, .8) inset; }
.tt-scope .ip-focus-tooltip::before, .tt-scope .ac-focus-tooltip::before { content: ''; position: absolute; top: 100%; left: 16px; border: 6px solid transparent; border-top-color: #d6e8f5; transform: translateY(-1px); }
.tt-scope .ip-focus-tooltip::after, .tt-scope .ac-focus-tooltip::after { content: ''; position: absolute; top: 100%; left: 17px; border: 5px solid transparent; border-top-color: #e3f0f8; }
.tt-scope .ip-field-wrapper:focus-within .ip-focus-tooltip, .tt-scope .ac-root:focus-within .ac-focus-tooltip { opacity: 1; transform: translateY(0); }
/* Modal & Misc */
.tt-scope .modal-body-scrollable { max-height: calc(90vh - 120px); overflow-y: auto; padding-right: 8px; overflow-x: hidden; }
.tt-scope .unselectable { user-select: none; }
/* Custom Dropdown */
.tt-scope .custom-dropdown { position: relative; width: 120px; }
.tt-scope .dropdown-toggle { appearance: none; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: #fff; cursor: pointer; font-weight: 700; transition: all .2s ease; height: 38px; box-sizing: border-box; }
.tt-scope .dropdown-toggle:hover { border-color: #c4d1de; }
.tt-scope .dropdown-toggle:focus, .tt-scope .dropdown-toggle.is-open { border-color: #bcd9ee; box-shadow: 0 0 0 5px var(--ring); outline: none; }
.tt-scope .dropdown-toggle .fa-chevron-down { font-size: 12px; transition: transform .2s ease; }
.tt-scope .dropdown-toggle.is-open .fa-chevron-down { transform: rotate(180deg); }
.tt-scope .dropdown-panel { position: absolute; top: calc(100% + 6px); left: 0; width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); z-index: 25; padding: 6px; max-height: 200px; overflow-y: auto; }
.tt-scope .dropdown-item { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-weight: 500; }
.tt-scope .dropdown-item:hover, .tt-scope .dropdown-item.is-active { background-color: #f3f8fc; }
/* Stat Cards V2 */
.tt-scope .stat-card-v2 { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; align-items: center; gap: 16px; background-color: var(--card-2); }
.tt-scope .stat-card-v2 .stat-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; }
.tt-scope .stat-card-v2 .stat-label { font-size: 12px; color: var(--muted); margin-bottom: 2px; }
.tt-scope .stat-card-v2 .stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; font-family: var(--mono); }
.tt-scope .stat-card-v2.stat-total .stat-icon { background-color: #eef2f6; color: #334155; }
.tt-scope .stat-card-v2.stat-total .stat-value { color: var(--text); }
.tt-scope .stat-card-v2.stat-download .stat-icon { background-color: #dcfce7; color: #16a34a; }
.tt-scope .stat-card-v2.stat-download .stat-value { color: #15803d; }
.tt-scope .stat-card-v2.stat-upload .stat-icon { background-color: #e0f2fe; color: #0284c7; }
.tt-scope .stat-card-v2.stat-upload .stat-value { color: var(--accent); }
.tt-scope .stat-card-v2.stat-duration .stat-icon { background-color: #eef2f6; color: #334155; }
.tt-scope .stat-card-v2.stat-duration .stat-value { color: var(--text); }
/* Chart Card */
.tt-scope .chart-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--card-2); position: relative; }
.tt-scope .chart-card canvas { max-height: calc(250px - 32px); }
.tt-scope .chart-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); }
.tt-scope .chart-placeholder i { font-size: 32px; color: #bdc8d8; }
.tt-scope .table-placeholder-fixed-height { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--muted); text-align: center; padding: 20px; background-color: var(--card-2); user-select: none; }
.tt-scope .table-placeholder-fixed-height i { font-size: 32px; color: #bdc8d8; }
.tt-scope .overlay { position: fixed; inset: 0; background: rgba(255,255,255,.8); backdrop-filter: blur(4px); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 50; text-align: center; }
.tt-scope .ont-loading-card { background: transparent; border: none; box-shadow: none; width: min(520px, 90vw); padding: 16px; color: var(--text); }
.tt-scope .spinner-lg { width: 42px; height: 42px; border: 4px solid #dbe9f5; border-top-color: var(--brand-blue); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 10px; }
.tt-scope .alert.error { padding: 10px 12px; border-radius: 10px; border: 1px solid #ffd6d6; background: #fff3f3; color: #8a1d1d; }
.tt-scope .card-in { animation: cardIn .18s ease; }
@keyframes cardIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* Network Mesh Visualization */
.tt-scope .network-tree-container { overflow: auto; padding: 40px; display: inline-flex; justify-content: flex-start; align-items: flex-start; }
.tt-scope .mesh-node { display: flex; flex-direction: row; align-items: flex-start; position: relative; }
.tt-scope .mesh-content { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; width: 240px; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); display: flex; align-items: center; gap: 10px; z-index: 2; position: relative; transition: all 0.2s ease; margin: 5px 0; }
.tt-scope .mesh-content:hover { box-shadow: 0 4px 8px rgba(0,0,0,0.1); border-color: #ccc; }
.tt-scope .mesh-content.is-router { border-color: #005384; background-color: #f0f7fa; }
.tt-scope .mesh-content.is-repeater { border-color: #0f9d58; background-color: #f0fbf5; }
.tt-scope .mesh-content.is-offline { opacity: 0.7; filter: grayscale(100%); }
.tt-scope .mesh-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #555; position: relative; flex-shrink: 0; }
.tt-scope .conn-badge { position: absolute; bottom: -2px; right: -2px; width: 16px; height: 16px; background: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
.tt-scope .conn-badge.wlan { color: #005384; }
.tt-scope .conn-badge.eth { color: #0f9d58; }
.tt-scope .mesh-info { flex-grow: 1; min-width: 0; }
.tt-scope .mesh-name { font-weight: 600; font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tt-scope .mesh-meta { display: flex; gap: 6px; align-items: center; margin-top: 2px; }
.tt-scope .mesh-ip, .tt-scope .mesh-mac { font-size: 10px; color: #777; font-family: var(--mono); }
.tt-scope .mesh-vendor { font-size: 10px; color: var(--accent); font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tt-scope .mesh-details { font-size: 10px; color: #555; margin-top: 4px; background: #eee; padding: 1px 4px; border-radius: 4px; display: inline-block; }
.tt-scope .mesh-children { display: flex; flex-direction: column; margin-left: 80px; position: relative; justify-content: center; }
.tt-scope .mesh-branch { display: flex; align-items: center; position: relative; padding-left: 40px; }
.tt-scope .mesh-branch::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; border-left: 2px solid #ccc; display: block; }
.tt-scope .mesh-branch::after { content: ''; position: absolute; left: 0; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; }
.tt-scope .mesh-branch:first-child::before { top: var(--line-offset); }
.tt-scope .mesh-branch:last-child::before { bottom: auto; height: var(--line-offset); }
.tt-scope .mesh-branch:only-child::before { display: none; }
.tt-scope .mesh-content::before { content: ''; position: absolute; left: -40px; top: var(--line-offset); width: 40px; border-top: 2px solid #ccc; }
.tt-scope .network-tree-container > .mesh-node > .mesh-content::before { display: none; }
.tt-scope .mesh-node > .mesh-content:not(:last-child)::after { content: ''; position: absolute; right: -80px; top: var(--line-offset); width: 80px; border-top: 2px solid #ccc; }
/* Tooltip Fixes for Table Actions */
.tt-scope .table-wrap [data-tooltip]::before,
.tt-scope .table-wrap [data-tooltip]::after {
position: fixed;
z-index: 10002;
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before,
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after {
left: auto;
right: 100%;
transform: translateX(0) translateY(-50%);
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::before {
top: 50%;
bottom: auto;
border: 5px solid transparent;
border-left-color: #0b1320;
border-top-color: transparent;
margin-right: -10px;
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]::after {
top: 50%;
bottom: auto;
margin-right: -5px;
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::before,
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="left"]:hover::after {
transform: translateX(-4px) translateY(-50%);
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before,
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after {
left: 100%;
right: auto;
transform: translateX(0) translateY(-50%);
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::before {
top: 50%;
bottom: auto;
border: 5px solid transparent;
border-right-color: #0b1320;
border-top-color: transparent;
margin-left: -10px;
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]::after {
top: 50%;
bottom: auto;
margin-left: -5px;
}
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::before,
.tt-scope .table-wrap [data-tooltip][data-tooltip-align="right"]:hover::after {
transform: translateX(4px) translateY(-50%);
}
/* Router Management Modal */
.tt-scope .router-info-header { display: flex; align-items: center; gap: 12px; padding: 20px 24px; margin: -14px -24px 12px -16px; background: linear-gradient(135deg, #e3f0f8 0%, #cce4f5 100%); border-bottom: 2px solid #b8d9f0; }
.tt-scope .router-info-header i { font-size: 28px; color: var(--accent); flex-shrink: 0; }
.tt-scope .router-header-text { flex-grow: 1; min-height: 39px; }
.tt-scope .router-title { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; line-height: 1.375; }
.tt-scope .router-subtitle { font-size: 12px; color: var(--muted); font-family: var(--mono); margin-top: 2px; line-height: 1.25; }
.tt-scope .router-info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 16px; margin-right: -8px; }
@media (max-width: 700px) { .tt-scope .router-info-grid { grid-template-columns: 1fr; } }
/* Info Card Styles - moved to tt-core.css (TtInfoCard component) */
.tt-scope .router-actions-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); margin-right: -8px; }
.tt-scope .router-actions-header { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 12px; letter-spacing: 0.3px; text-transform: uppercase; user-select: none; }
.tt-scope .router-actions-header i { font-size: 14px; color: var(--accent); }
.tt-scope .router-actions-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (max-width: 700px) { .tt-scope .router-actions-grid { grid-template-columns: 1fr; } }
.tt-scope .action-btn { display: flex; align-items: center; padding: 8px 16px; }
.tt-scope .action-btn i { width: 20px; flex-shrink: 0; text-align: center; margin-right: 10px; }
+90 -236
View File
@@ -1,250 +1,104 @@
/* ===== Radius.js ===== */
/* ---------- Shared Utilities (global) ---------- */
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
return resolve();
}
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = () => reject(new Error(`Script load error for ${src}`));
document.head.appendChild(script);
});
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text || '');
return true;
} catch {
const ta = document.createElement('textarea');
ta.value = text || '';
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
return false;
}
}
function formatBytes(bytes, decimals = 2) {
bytes = parseInt(bytes, 10);
if (!bytes || bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function formatDuration(seconds) {
if (!seconds || seconds < 0) return '0s';
seconds = parseInt(seconds, 10);
const d = Math.floor(seconds / (3600*24));
const h = Math.floor(seconds % (3600*24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
if (d > 0) return `${d}t ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return `< 1m`;
}
function calculateSimilarity(str1, str2) {
if (!str1 || !str2) return 0;
str1 = ('' + str1).toLowerCase();
str2 = ('' + str2).toLowerCase();
let match = 0;
for (let c of str1) if (str2.includes(c)) match++;
return (match / str1.length) * 100;
}
function validateData(strasse, plz, stadt, info) {
const thresholds = 90;
return !(
calculateSimilarity(strasse, info) < thresholds ||
calculateSimilarity(plz, info) < thresholds ||
calculateSimilarity(stadt, info) < thresholds
);
}
window.RadiusUtils = { calculateSimilarity, validateData, copyToClipboard, formatBytes, formatDuration, loadScript };
/* ---------- Reusable Component: radius-table-view ---------- */
Vue.component('radius-table-view', {
props: {
items: Array,
isLoading: Boolean,
hasSearched: Boolean,
density: { type: String, default: 'compact' },
tableClass: { type: String, default: '' },
tableStyle: Object,
tableMinHeight: { type: String, default: 'auto' },
initialPlaceholderIcon: { type: String, default: 'fa-duotone fa-keyboard' },
initialPlaceholderText: { type: String, default: 'Beginnen Sie Ihre Suche.' },
noResultsPlaceholderIcon: { type: String, default: 'fa-duotone fa-database' },
noResultsPlaceholderText: { type: String, default: 'Keine Ergebnisse gefunden.' },
skeletonRowCount: { type: Number, default: 6 }
},
template: `
<div class="table-view-wrapper">
<div v-if="!hasSearched" class="table-placeholder" :style="{minHeight: tableMinHeight}">
<i :class="initialPlaceholderIcon"></i>
<div>{{ initialPlaceholderText }}</div>
</div>
<div v-else-if="isLoading">
<slot name="loading-placeholder">
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
<table class="tt-table" :class="[density, tableClass]">
<slot name="head"></slot>
<tbody><tr v-for="n in skeletonRowCount" :key="'skel'+n"><slot name="skeleton-row"></slot></tr></tbody>
</table>
</div>
</slot>
</div>
<div v-else-if="!items.length" class="table-placeholder" :style="{minHeight: tableMinHeight}">
<i :class="noResultsPlaceholderIcon"></i>
<div>{{ noResultsPlaceholderText }}</div>
</div>
<template v-else>
<div class="table-wrap" :style="{maxHeight: '65vh', ...tableStyle}">
<table class="tt-table" :class="[density, tableClass]">
<slot name="head"></slot>
<tbody><tr v-for="(item, index) in items" :key="index" class="row-fade-in"><slot name="row" :item="item" :index="index"></slot></tr></tbody>
</table>
<slot name="observer"></slot>
</div>
</template>
</div>
`
});
/* ---------- Reusable Component: radius-file-drop ---------- */
Vue.component('radius-file-drop', {
data: () => ({ dragCounter: 0 }),
computed: { isDragging() { return this.dragCounter > 0; } },
template: `
<label class="file-drop" :class="{'is-dragover': isDragging}" @dragover.prevent @dragenter.prevent="dragCounter++" @dragleave.prevent="dragCounter--" @drop.prevent="onDrop">
<input type="file" accept=".xlsx" @change="$emit('file-selected', $event.target.files[0])" hidden ref="fileInput">
<div class="file-cta">
<i class="fa-duotone fa-cloud-arrow-up"></i>
<div>Hierhin ziehen oder <button type="button" class="link-btn" @click.prevent="$refs.fileInput.click()">Datei auswählen</button></div>
</div>
</label>
`,
methods: { onDrop(e) { this.dragCounter = 0; const file = e.dataTransfer.files?.[0]; if (file) this.$emit('file-selected', file); } }
});
/* ---------- Reusable Component: radius-processing-indicator ---------- */
Vue.component('radius-processing-indicator', {
props: ['progress', 'currentRow', 'totalRows', 'currentSerial'],
template: `
<div class="table-placeholder">
<i class="fa-duotone fa-hourglass-half animated-hourglass" style="font-size: 36px; margin-bottom: 10px; color: var(--brand-blue);"></i>
<div class="h5">Verarbeitung läuft...</div>
<slot name="description"><p v-if="currentSerial" class="muted small">Aktuell: {{ currentSerial || '—' }}</p></slot>
<div class="progress-bar mt-3" style="width: 250px; margin-left: auto; margin-right: auto;"><div class="bar" :style="{width: progress + '%'}"></div></div>
<div class="muted small mt-2">Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}</div>
</div>
`
});
/* ---------- Online state chip (fetches radacct when visible) ---------- */
Vue.component('radius-online-state', {
props: { username: String }, data: () => ({ data: null, observed: false, ob: null }),
template: `
<div class="radius-scope ros-wrap" ref="root">
<template v-if="data===null"><span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span></template>
<template v-else-if="data!==null"><span class="ros-chip" :class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]" :data-tooltip="data.ip ? 'IP-Adresse kopieren' : null" @click="copyIp($event)"><span class="dot"></span><span class="ip">{{ data.ip || '—' }}</span></span></template>
</div>
`,
mounted() { this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); if (this.$refs.root) this.ob.observe(this.$refs.root); },
beforeDestroy() { this.ob?.disconnect(); },
methods: {
async fetchState() { try { const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); this.data = r.ok ? await r.json() : { online: false, ip: null }; } catch { this.data = { online: false, ip: null }; } },
async copyIp(event) { if (!this.data?.ip) return; const c = event.currentTarget; if (!c || c.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(this.data.ip); c.classList.add('is-copied'); const o = c.dataset.tooltip; if (o) c.dataset.tooltip = 'Kopiert!'; setTimeout(() => { c.classList.remove('is-copied'); if (o) c.dataset.tooltip = o; }, 1500); }
}
});
/* ---------- Autocomplete ---------- */
Vue.component('radius-autocomplete', {
props: { value: String, placeholder: String, wide: { type: Boolean, default: true } }, data() { return { q: this.value || '', open: false, items: {}, highlighted: -1, busy: false, mode: 'autocomplete', logoDropdownOpen: false, hasMoreResults: false }; }, watch: { value(v){ if (v !== this.q) { this.q = v; if (this.mode === 'autocomplete') this.debouncedFetch(); } } },
template: `<div class="radius-scope ac-root" :data-wide="wide ? '1' : null" @keydown.down.prevent="mode === 'autocomplete' && move(1)" @keydown.up.prevent="mode === 'autocomplete' && move(-1)" @keydown.enter.prevent="onEnter"><span class="ac-focus-tooltip">Klicken Sie auf das Logo, um die Kundenbasis zu wechseln</span><div class="input-wrap"><div class="logo-switcher" @mousedown.prevent.stop="toggleLogoDropdown" :class="{'is-open': logoDropdownOpen}"><img v-if="mode === 'autocomplete'" src="/img/xinon-logo.png" class="input-icon-logo" alt="Xinon Logo"><img v-else src="/img/estmk_logo.png" class="input-icon-logo" alt="ESTMK Logo"><i class="fa-solid fa-chevron-down switcher-caret"></i></div><input ref="mainInput" :placeholder="placeholderText" class="ri" v-model="q" autocomplete="off" autocapitalize="none" autocorrect="off" @input="onInput" @focus="mode === 'autocomplete' && maybeOpen()" @blur="deferClose"/><button v-if="q" class="btn-clear" @mousedown.prevent="clear" title="Feld leeren"><i class="fa-duotone fa-xmark"></i></button></div><transition name="ac-pop"><div v-if="logoDropdownOpen" class="logo-dropdown"><div class="logo-option" @mousedown.prevent="selectMode('autocomplete')"><img src="/img/xinon-logo.png" alt="Xinon Logo"><span>XINON (Suche)</span></div><div class="logo-option" @mousedown.prevent="selectMode('text')"><img src="/img/estmk_logo.png" alt="ESTMK Logo"><span>ESTMK (Eingabe)</span></div></div></transition><transition name="ac-pop"><div v-if="open && mode === 'autocomplete'" class="ac-panel" :class="{'wide': wide}"><div v-if="busy" class="ac-skel"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line"></div></div><template v-else><div v-if="!Object.keys(items).length && !hasMoreResults" class="ac-empty muted">Keine Treffer</div><ul ref="resultsList" class="ac-list" role="listbox"><li v-for="(disp, id) in items" :key="id" :class="['ac-item', highlightedId===id ? 'is-active' : '']" @mousedown.prevent="choose(id, disp)"><i class="fa-duotone fa-address-card"></i><span class="txt">{{ disp }}</span></li><li v-if="hasMoreResults" class="ac-more-info muted"><i class="fa-duotone fa-ellipsis"></i><span class="txt">Mehr Ergebnisse verfügbar</span></li></ul></template></div></transition></div>`,
computed: { highlightedId(){ const k=Object.keys(this.items); return k[this.highlighted] || null; }, placeholderText() { return this.mode === 'autocomplete' ? (this.placeholder || 'Rechnungsadresse suchen') : 'Partner-Kundennummer eingeben'; } },
created() { this.debouncedFetch = this.debounce(() => this.fetchItems(), 220); },
methods: {
toggleLogoDropdown() { this.logoDropdownOpen = !this.logoDropdownOpen; if (this.logoDropdownOpen) this.open = false; },
selectMode(m) { if (this.mode !== m) { this.mode = m; this.$emit('mode-change', m); this.clear(); } this.logoDropdownOpen = false; this.$nextTick(() => this.$refs.mainInput.focus()); },
onInput() { this.$emit('input', this.q); if (this.mode === 'autocomplete') this.debouncedFetch(); },
onEnter() { if (this.mode === 'autocomplete') this.chooseHighlighted(true); else this.$emit('enter'); },
maybeOpen(){ this.open = true; if (this.q) this.debouncedFetch(); },
deferClose(){ setTimeout(()=> { this.open = false; this.logoDropdownOpen = false; }, 150); },
clear(){ this.q = ''; this.items={}; this.highlighted=-1; this.emitSelection('', ''); if (this.mode === 'autocomplete') { this.open = true; this.debouncedFetch(); } },
move(d){ const k=Object.keys(this.items); if (!k.length) return; this.highlighted=(this.highlighted+d+k.length)%k.length; this.$nextTick(() => { const a = this.$refs.resultsList?.querySelector('.is-active'); if (a) a.scrollIntoView({ block: 'center', behavior: 'smooth' }); }); },
chooseHighlighted(e){ const i=this.highlightedId; if (i) this.choose(i, this.items[i], e); else if (e) this.$emit('enter'); },
choose(id, display, emitEnter){ const c=(display.match(/\[(\d+)\]/)||[])[1] || ''; this.emitSelection(c, display); this.open=false; if (emitEnter) this.$emit('enter'); },
emitSelection(custnum, display){ this.$emit('select', { custnum, display }); this.$emit('input', display); this.$emit('change', display); },
async fetchItems() { if (this.mode !== 'autocomplete' || !this.q || this.q.length < 2) { this.items = {}; this.hasMoreResults = false; return; } this.busy = true; try { const b = window.TT_CONFIG.BASE_PATH || ''; const r = await fetch(`${b}/Address/Api?do=findAddress&fibu_primary_account=1&q=${encodeURIComponent(this.q)}`); if (r.ok) { const j = await r.json(); const addresses = j?.result?.addresses || {}; if (addresses.more) { this.hasMoreResults = true; delete addresses.more; } else { this.hasMoreResults = false; } this.items = addresses; this.highlighted = 0; } else { this.items = {}; this.hasMoreResults = false; } } catch { this.items = {}; this.hasMoreResults = false; } this.busy = false; },
debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
}
});
/* ---------- Generic Modal ---------- */
Vue.component('radius-modal', {
props: { show: Boolean, title: String, modalClass: String },
template: `
<transition name="fade">
<div v-if="show" class="radius-scope modal-overlay" @click.self="$emit('close')">
<div class="modal-card pop" :class="modalClass">
<div class="modal-head">
<div class="modal-title"><i class="fa-duotone fa-database"></i> {{ title }}</div>
<button class="icon-btn" @click="$emit('close')" aria-label="Close" title="Schließen"><i class="fa-duotone fa-xmark"></i></button>
</div>
<div class="modal-body"><slot/></div>
</div>
</div>
</transition>
`,
watch: {
show(isShown) {
if (isShown) {
this.$nextTick(() => {
// nodeType 1 is an Element node, this prevents errors if v-if renders a comment node.
if (this.$el && this.$el.nodeType === 1 && this.$el.parentNode !== document.body) {
document.body.appendChild(this.$el);
}
document.body.style.overflow = 'hidden';
});
} else {
document.body.style.overflow = '';
}
}
},
beforeDestroy() {
if (this.show && this.$el && this.$el.nodeType === 1 && this.$el.parentNode === document.body) {
document.body.removeChild(this.$el);
}
document.body.style.overflow = '';
}
});
/* ===== Radius.js (Vue 3 + TT-Core) ===== */
/* ---------- Root View: <radius> ---------- */
Vue.component('radius', {
const Radius = {
name: 'Radius',
template: `
<div class="radius-scope radius-container">
<div class="tt-scope radius-container">
<section class="card card-in">
<div class="pane-header"><div class="title"><span class="logo-dot"></span><span>Radius</span></div><nav class="view-tabs"><button v-for="i in viewOptions" :key="i.id" class="tab-btn" :class="{active:view===i.id}" @click="switchView(i.id)"><i :class="i.icon"></i> {{ i.name }}</button></nav><div class="view-select-wrap select"><select v-model="view" @change="switchView($event.target.value)"><option v-for="i in viewOptions" :value="i.id">{{ i.name }}</option></select></div></div>
<div class="pane-header">
<div class="title">
<span class="logo-dot"></span>
<span>Radius</span>
</div>
<nav class="view-tabs">
<button
v-for="i in viewOptions"
:key="i.id"
class="tab-btn"
:class="{active: view === i.id}"
@click="switchView(i.id)"
>
<i :class="i.icon"></i> {{ i.name }}
</button>
</nav>
<div class="view-select-wrap select">
<select v-model="view" @change="switchView($event.target.value)">
<option v-for="i in viewOptions" :value="i.id">{{ i.name }}</option>
</select>
</div>
</div>
<hr class="content-divider" />
<section v-show="view==='users'" class="card-in"><radius-users/></section><section v-show="view==='free'" class="card-in"><radius-free-users ref="freeView"/></section><section v-show="view==='unused'" class="card-in"><radius-unused-users ref="unusedView"/></section><section v-show="view==='ont'" class="card-in"><radius-ont-parser/></section><section v-show="view==='ontReverse'" class="card-in"><radius-ont-finder/></section>
<section v-show="view === 'users'" class="card-in">
<radius-users />
</section>
<section v-show="view === 'free'" class="card-in">
<radius-free-users ref="freeView" />
</section>
<section v-show="view === 'unused'" class="card-in">
<radius-unused-users ref="unusedView" />
</section>
<section v-show="view === 'ont'" class="card-in">
<radius-ont-parser />
</section>
<section v-show="view === 'ontReverse'" class="card-in">
<radius-ont-finder />
</section>
</section>
</div>
`,
data() { return { view: 'users', window: window, _initFlags: {} }; },
data() {
return {
view: 'users',
window: window,
_initFlags: {}
};
},
computed: {
viewOptions() {
const o = [{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },{ id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }];
if (window.TT_CONFIG.CAN_BILLING === '1') { o.push({ id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' }, { id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' }); } return o;
const options = [
{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },
{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },
{ id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }
];
if (window.TT_CONFIG.CAN_BILLING === '1') {
options.push(
{ id: 'ont', name: 'ONT Parser', icon: 'fa-duotone fa-diagram-project' },
{ id: 'ontReverse', name: 'ONT Reverse', icon: 'fa-duotone fa-arrows-rotate' }
);
}
return options;
}
},
mounted() { this.switchView(this.view); },
mounted() {
this.switchView(this.view);
},
methods: {
switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}}
switchView(v) {
this.view = v;
if (!this._initFlags || this._initFlags[v]) return;
let refName = '';
if (v === 'free') refName = 'freeView';
else if (v === 'unused') refName = 'unusedView';
if (refName) {
this.$nextTick(() => {
const childComponent = this.$refs[refName];
if (childComponent && typeof childComponent.initIfNeeded === 'function') {
childComponent.initIfNeeded();
this._initFlags[v] = true;
}
});
}
}
}
});
};
// Register component with Vue 3 app
if (window.VueApp) {
window.VueApp.component('radius', Radius);
}
+139 -28
View File
@@ -1,48 +1,159 @@
/* ===== RadiusFreeUsers.js ===== */
Vue.component('radius-free-users', {
/* ===== RadiusFreeUsers.js (Vue 3 + TT-Core) ===== */
const RadiusFreeUsers = {
name: 'RadiusFreeUsers',
template: `
<div class="radius-scope">
<div class="grid cols-1 cols-2-xl">
<div class="subcard" style="border-right: 1px solid var(--border); padding-right: 12px;">
<div class="tt-scope">
<div class="free-users-grid">
<div class="free-users-column">
<div class="h5" style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
<span><i class="fa-duotone fa-shield-keyhole"></i> Freie NAT Benutzer <span class="badge">{{ filteredNat.length }}</span></span>
<button class="ghost-btn" @click="reloadNat" :disabled="loadingNat"><span v-if="!loadingNat"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span><span v-else class="btn-loader"></span></button>
<button class="ghost-btn" @click="reloadNat" :disabled="loadingNat">
<span v-if="!loadingNat"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span>
<span v-else class="btn-loader"></span>
</button>
</div>
<radius-table-view :items="filteredNat" :is-loading="loadingNat" :has-searched="true" density="ultra-compact" table-class="no-min-width" no-results-placeholder-text="Keine Treffer" :skeleton-row-count="8">
<template #head><thead><tr><th>Username</th><th>Info</th></tr></thead></template>
<template #skeleton-row><td colspan="2"><div class="skeleton-line"></div></td></template>
<tt-data-table
:items="filteredNat"
:is-loading="loadingNat"
:has-searched="true"
density="ultra-compact"
table-class="no-min-width"
no-results-placeholder-text="Keine Treffer"
:skeleton-row-count="8"
>
<template #head>
<thead>
<tr>
<th>Username</th>
<th>Info</th>
</tr>
</thead>
</template>
<template #skeleton-row>
<td colspan="2"><tt-skeleton /></td>
</template>
<template #row="{ item }">
<td><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.Username" data-tooltip="User in Radius öffnen" data-tooltip-align="right">{{ item.Username }}</a></td>
<td>
<a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + item.Username"
data-tooltip="User in Radius öffnen"
data-tooltip-align="right">{{ item.Username }}</a>
</td>
<td class="clamp-2 mono">{{ item.Info }}</td>
</template>
</radius-table-view>
<div v-if="!loadingNat && filteredNat.length" class="results-summary">{{ filteredNat.length }} Treffer gefunden</div>
</tt-data-table>
<div v-if="!loadingNat && filteredNat.length" class="results-summary">
{{ filteredNat.length }} Treffer gefunden
</div>
</div>
<div class="subcard" style="padding-left: 12px;">
<div class="free-users-column">
<div class="h5" style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
<span><i class="fa-duotone fa-id-card-clip"></i> Freie STF Benutzer <span class="badge">{{ filteredStf.length }}</span></span>
<button class="ghost-btn" @click="reloadStf" :disabled="loadingStf"><span v-if="!loadingStf"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span><span v-else class="btn-loader"></span></button>
<button class="ghost-btn" @click="reloadStf" :disabled="loadingStf">
<span v-if="!loadingStf"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span>
<span v-else class="btn-loader"></span>
</button>
</div>
<radius-table-view :items="filteredStf" :is-loading="loadingStf" :has-searched="true" density="ultra-compact" table-class="no-min-width" no-results-placeholder-text="Keine Treffer" :skeleton-row-count="8">
<template #head><thead><tr><th>Username</th><th>Info</th></tr></thead></template>
<template #skeleton-row><td colspan="2"><div class="skeleton-line"></div></td></template>
<tt-data-table
:items="filteredStf"
:is-loading="loadingStf"
:has-searched="true"
density="ultra-compact"
table-class="no-min-width"
no-results-placeholder-text="Keine Treffer"
:skeleton-row-count="8"
>
<template #head>
<thead>
<tr>
<th>Username</th>
<th>Info</th>
</tr>
</thead>
</template>
<template #skeleton-row>
<td colspan="2"><tt-skeleton /></td>
</template>
<template #row="{ item }">
<td><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.Username" data-tooltip="User in Radius öffnen" data-tooltip-align="right">{{ item.Username }}</a></td>
<td>
<a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + item.Username"
data-tooltip="User in Radius öffnen"
data-tooltip-align="right">{{ item.Username }}</a>
</td>
<td class="clamp-2 mono">{{ item.Info }}</td>
</template>
</radius-table-view>
<div v-if="!loadingStf && filteredStf.length" class="results-summary">{{ filteredStf.length }} Treffer gefunden</div>
</tt-data-table>
<div v-if="!loadingStf && filteredStf.length" class="results-summary">
{{ filteredStf.length }} Treffer gefunden
</div>
</div>
</div>
</div>
`,
data: () => ({ nat: [], stf: [], loadingNat: false, loadingStf: false, _initialized: false }),
computed: { filteredNat() { return this.nat.filter(this.isTrulyFree); }, filteredStf() { return this.stf.filter(this.isTrulyFree); } },
data: () => ({
nat: [],
stf: [],
loadingNat: false,
loadingStf: false,
_initialized: false
}),
computed: {
filteredNat() {
return this.nat.filter(this.isTrulyFree);
},
filteredStf() {
return this.stf.filter(this.isTrulyFree);
}
},
methods: {
initIfNeeded(){ if (this._initialized) return; this._initialized = true; this.reloadNat(); this.reloadStf(); },
isTrulyFree(user) { return !/frei[a-z]/.test((user.Info || '').toLowerCase()); },
normalizeUsers(arr){ if (!Array.isArray(arr)) return []; return arr.map(u => ({ Username: (u.Username || u.username || '').trim(), Info: (u.Info || u.info || '').toString().replace(/\s+$/,'') })).filter(u => u.Username); },
async reloadNat() { this.nat = []; this.loadingNat = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=nat'); this.nat = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.nat = []; } this.loadingNat = false; },
async reloadStf() { this.stf = []; this.loadingStf = true; try { const r = await fetch(window.TT_CONFIG.BASE_PATH + '/Radius/proxyUnsecureHTTPRequestToRadius?action2=free_user&filter=stf'); this.stf = this.normalizeUsers(r.ok ? (await r.json()).users : []); } catch { this.stf = []; } this.loadingStf = false; }
initIfNeeded() {
if (this._initialized) return;
this._initialized = true;
this.reloadNat();
this.reloadStf();
},
isTrulyFree(user) {
return !/frei[a-z]/.test((user.Info || '').toLowerCase());
},
normalizeUsers(arr) {
if (!Array.isArray(arr)) return [];
return arr.map(u => ({
Username: (u.Username || u.username || '').trim(),
Info: (u.Info || u.info || '').toString().replace(/\s+$/, '')
})).filter(u => u.Username);
},
async reloadNat() {
this.nat = [];
this.loadingNat = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'free_user', filter: 'nat' }
});
this.nat = this.normalizeUsers(data?.users || []);
} catch (error) {
this.nat = [];
}
this.loadingNat = false;
},
async reloadStf() {
this.stf = [];
this.loadingStf = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'free_user', filter: 'stf' }
});
this.stf = this.normalizeUsers(data?.users || []);
} catch (error) {
this.stf = [];
}
this.loadingStf = false;
}
}
});
};
// Register component with Vue 3 app
if (window.VueApp) {
window.VueApp.component('radius-free-users', RadiusFreeUsers);
}
@@ -0,0 +1,67 @@
const RadiusNetworkNode = {
name: 'RadiusNetworkNode',
props: {
device: Object
},
template: `
<div class="mesh-node">
<div class="mesh-content" :class="nodeClass">
<div class="mesh-icon">
<i :class="iconClass"></i>
<div v-if="connectionType === 'wlan'" class="conn-badge wlan"><i class="fa-duotone fa-wifi"></i></div>
<div v-if="connectionType === 'ethernet'" class="conn-badge eth"><i class="fa-duotone fa-ethernet"></i></div>
</div>
<div class="mesh-info">
<div class="mesh-name" :title="device.name">{{ device.name }}</div>
<div class="mesh-meta">
<span class="mesh-ip" v-if="device.ipv4 && device.ipv4.ip">{{ device.ipv4.ip }}</span>
</div>
<div class="mesh-meta" v-if="device.mac">
<span class="mesh-mac">{{ device.mac }}</span>
</div>
<div class="mesh-vendor" v-if="device.vendor">{{ device.vendor }}</div>
<div class="mesh-details" v-if="details">
<span class="mesh-speed">{{ details }}</span>
</div>
</div>
</div>
<div class="mesh-children" v-if="device.children && device.children.length">
<div v-for="child in device.children" :key="child.UID" class="mesh-branch">
<radius-network-node :device="child" />
</div>
</div>
</div>
`,
computed: {
iconClass() {
if (this.device.model === 'fbox') return 'fa-duotone fa-router';
if ((this.device.name || '').toLowerCase().includes('repeater')) return 'fa-duotone fa-wifi-exclamation';
if (this.device.type === 'wlan') return 'fa-duotone fa-mobile-screen';
return 'fa-duotone fa-desktop';
},
connectionType() {
if (this.device.type === 'wlan') return 'wlan';
if (this.device.type === 'ethernet') return 'ethernet';
return null;
},
nodeClass() {
return {
'is-router': this.device.model === 'fbox',
'is-repeater': (this.device.name || '').toLowerCase().includes('repeater'),
'is-offline': this.device.state && this.device.state.class !== 'globe_online' && this.device.state.class !== 'led_green'
}
},
details() {
if (this.device.properties && this.device.properties.length > 0) {
const props = this.device.properties.filter(p => p.txt && p.txt !== 'Mesh');
if (props.length > 0) return props[0].txt;
}
if (this.device.port && this.device.port !== 'WLAN') return this.device.port;
return null;
}
}
};
if (window.VueApp) {
VueApp.component('radius-network-node', RadiusNetworkNode);
}
+192 -18
View File
@@ -1,29 +1,203 @@
/* ===== RadiusOntFinder.js ===== */
Vue.component('radius-ont-finder', {
/* ===== RadiusOntFinder.js (Vue 3 + TT-Core) ===== */
const RadiusOntFinder = {
name: 'RadiusOntFinder',
template: `
<div class="radius-scope ont-card">
<div class="tt-scope ont-card">
<div v-if="step===1">
<div class="block-head"><div class="h4"><i class="fa-duotone fa-file-spreadsheet"></i> Schritt 1 · Excel (XLSX) Upload</div><p class="muted small">Datei muss die Spalte <code>Serial</code> enthalten. Optional <code>MAC</code>.</p></div>
<radius-file-drop @file-selected="readXlsx" /><div v-if="uploadError" class="alert error mt-2">{{ uploadError }}</div>
<div class="block-head">
<div class="h4"><i class="fa-duotone fa-file-spreadsheet"></i> Schritt 1 · Excel (XLSX) Upload</div>
<p class="muted small">Datei muss die Spalte <code>Serial</code> enthalten. Optional <code>MAC</code>.</p>
</div>
<tt-file-dropzone accept=".xlsx,.xls" @file-selected="readXlsx" />
<div v-if="uploadError" class="alert error mt-2">{{ uploadError }}</div>
</div>
<div v-if="step===2">
<div class="block-head"><div class="h4"><i class="fa-duotone fa-list-check"></i> Ergebnisse</div><div class="cluster"><button class="primary-btn" @click="downloadResults" :disabled="loading"><i class="fa-duotone fa-download"></i> Ergebnisse herunterladen</button><button class="ghost-btn" @click="resetComponent" :disabled="loading"><i class="fa-duotone fa-rotate-right"></i> Neue Datei</button></div></div>
<div class="block-head">
<div class="h4"><i class="fa-duotone fa-list-check"></i> Ergebnisse</div>
<div class="cluster">
<button class="primary-btn" @click="downloadResults" :disabled="loading">
<i class="fa-duotone fa-download"></i> Ergebnisse herunterladen
</button>
<button class="ghost-btn" @click="resetComponent" :disabled="loading">
<i class="fa-duotone fa-rotate-right"></i> Neue Datei
</button>
</div>
</div>
<div class="results-container mt-between">
<radius-processing-indicator v-if="loading" :progress="progress" :current-row="currentRow" :total-rows="totalRows" :current-serial="currentSerial" />
<radius-table-view v-else :items="processedData" :has-searched="true" no-results-placeholder-text="Keine Daten verarbeitet.">
<template #head><thead><tr><th v-for="h in originalHeaders" :key="'h'+h">{{ h }}</th><th>Username</th><th>Kundennummer</th><th>Kundenname</th><th>Info</th></tr></thead></template>
<template #row="{ item }"><td v-for="h in originalHeaders" :key="h+item.Serial">{{ item[h] }}</td><td class="mono">{{ item.fetched_username }}</td><td class="mono">{{ item.fetched_customerNumber }}</td><td class="clamp-2">{{ item.fetched_customerName }}</td><td class="clamp-2 mono">{{ item.fetched_info }}</td></template>
</radius-table-view>
<div v-if="!loading && processedData.length" class="results-summary">{{ processedData.length }} Zeilen verarbeitet</div>
<tt-loading-indicator
v-if="loading"
:text="currentSerial"
:progress="progress"
style="min-height: 200px;"
/>
<tt-data-table
v-else
:items="processedData"
:has-searched="true"
no-results-placeholder-text="Keine Daten verarbeitet."
>
<template #head>
<thead>
<tr>
<th v-for="h in originalHeaders" :key="'h'+h">{{ h }}</th>
<th>Username</th>
<th>Kundennummer</th>
<th>Kundenname</th>
<th>Info</th>
</tr>
</thead>
</template>
<template #row="{ item }">
<td v-for="h in originalHeaders" :key="h+item.Serial">{{ item[h] }}</td>
<td class="mono">{{ item.fetched_username }}</td>
<td class="mono">{{ item.fetched_customerNumber }}</td>
<td class="clamp-2">{{ item.fetched_customerName }}</td>
<td class="clamp-2 mono">{{ item.fetched_info }}</td>
</template>
</tt-data-table>
<div v-if="!loading && processedData.length" class="results-summary">
{{ processedData.length }} Zeilen verarbeitet
</div>
</div>
</div>
</div>
`,
data: () => ({ step: 1, parsedData: [], processedData: [], originalHeaders: [], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentSerial: '', uploadError: null, serialColumnName: 'Serial', macColumnName: 'MAC', fetchedKeys: { username: 'fetched_username', customerNumber: 'fetched_customerNumber', customerName: 'fetched_customerName', info: 'fetched_info' }, apiBasePath: window.TT_CONFIG?.BASE_PATH }),
data: () => ({
step: 1,
parsedData: [],
processedData: [],
originalHeaders: [],
loading: false,
progress: 0,
currentRow: 0,
totalRows: 0,
currentSerial: '',
uploadError: null,
serialColumnName: 'Serial',
macColumnName: 'MAC',
fetchedKeys: {
username: 'fetched_username',
customerNumber: 'fetched_customerNumber',
customerName: 'fetched_customerName',
info: 'fetched_info'
},
apiBasePath: window.TT_CONFIG?.BASE_PATH
}),
methods: {
resetComponent(){ Object.assign(this.$data, this.$options.data.call(this)); const i=this.$el.querySelector('input[type="file"]'); if (i) i.value=''; },
async readXlsx(file){ this.uploadError=null; try{ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const arr = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=e=>res(new Uint8Array(e.target.result)); r.onerror=()=>rej(new Error('Fehler beim Lesen.')); r.readAsArrayBuffer(file); }); const wb = XLSX.read(arr, {type:'array'}); const ws = wb.Sheets[wb.SheetNames[0]]; this.parsedData = XLSX.utils.sheet_to_json(ws, {defval:''}); if (!this.parsedData.length) throw new Error('Die Datei ist leer.'); this.originalHeaders = Object.keys(this.parsedData[0]); if (!this.originalHeaders.includes(this.serialColumnName)) throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`); this.startProcessing(); } catch(e){ this.uploadError=e.message; this.step=1; } },
async startProcessing(){ this.step = 2; this.loading = true; this.totalRows=this.parsedData.length; this.processedData=[]; const setRow = (row, msg, data={})=>{ const d={username:`N/A - ${msg}`,customerNumber:'N/A',customerName:'N/A',info:'N/A'}; Object.keys(this.fetchedKeys).forEach(k=>row[this.fetchedKeys[k]]=data[k]||d[k]); }; for (const [i,row] of this.parsedData.entries()){ this.currentRow=i; const out={...row}; const sn=(''+(row[this.serialColumnName]||'')).trim(); this.currentSerial=`SN: ${sn||'—'}`; let found=false; if (sn){ try{ const r=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=${encodeURIComponent(sn)}`); if(r.ok){ const j=await r.json(); if(Array.isArray(j)&&j.length>0){ setRow(out,'',j[0]); found=true; }}}catch{} } if (!found && this.originalHeaders.includes(this.macColumnName)){ const macRaw=(''+(row[this.macColumnName]||'')).trim(); if(macRaw&&macRaw.length===12){ const mac=macRaw.toUpperCase().match(/.{1,2}/g).join(':'); try{ const s=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=${encodeURIComponent(mac)}`); if(s.ok){ const ses=await s.json(); if(Array.isArray(ses)&&ses.length>0){ const u=await fetch(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=${encodeURIComponent(ses[0])}&info=&custnum=`); if(u.ok){ const d=await u.json(); if(Array.isArray(d)&&d.length>0) {setRow(out,'',d[0]); found=true;}}}}}catch{}}} if(!found) setRow(out,'Keinen Benutzer gefunden'); this.processedData.push(out); this.progress=((i+1)/this.totalRows)*100; if ((i+1)%20===0) await new Promise(r=>setTimeout(r,20)); } this.loading=false; this.currentSerial=''; },
downloadResults(){ if (!this.processedData.length) return; try{ const data=this.processedData.map(r=>{ const o={}; this.originalHeaders.forEach(h=>o[h]=r[h]); Object.keys(this.fetchedKeys).forEach(k=>{const K=k.charAt(0).toUpperCase()+k.slice(1).replace('Number','nummer').replace('Name','name'); o[K]=r[this.fetchedKeys[k]];}); return o; }); const ws=XLSX.utils.json_to_sheet(data); const wb=XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb,ws,'ONT_Finder_Results'); XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g,'').slice(0,14)}.xlsx`); } catch { if(window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.'); } }
resetComponent() {
Object.assign(this.$data, this.$options.data.call(this));
const i = this.$el.querySelector('input[type="file"]');
if (i) i.value = '';
},
async readXlsx(file) {
this.uploadError = null;
try {
await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js');
const arr = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = e => res(new Uint8Array(e.target.result));
r.onerror = () => rej(new Error('Fehler beim Lesen.'));
r.readAsArrayBuffer(file);
});
const wb = XLSX.read(arr, {type: 'array'});
const ws = wb.Sheets[wb.SheetNames[0]];
this.parsedData = XLSX.utils.sheet_to_json(ws, {defval: ''});
if (!this.parsedData.length) throw new Error('Die Datei ist leer.');
this.originalHeaders = Object.keys(this.parsedData[0]);
if (!this.originalHeaders.includes(this.serialColumnName))
throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`);
this.startProcessing();
} catch (e) {
this.uploadError = e.message;
this.step = 1;
}
},
async startProcessing() {
this.step = 2;
this.loading = true;
this.totalRows = this.parsedData.length;
this.processedData = [];
const setRow = (row, msg, data = {}) => {
const d = {
username: `N/A - ${msg}`,
customerNumber: 'N/A',
customerName: 'N/A',
info: 'N/A'
};
Object.keys(this.fetchedKeys).forEach(k => row[this.fetchedKeys[k]] = data[k] || d[k]);
};
for (const [i, row] of this.parsedData.entries()) {
this.currentRow = i;
const out = {...row};
const sn = ('' + (row[this.serialColumnName] || '')).trim();
this.currentSerial = `SN: ${sn || '—'}`;
let found = false;
if (sn) {
try {
const { data } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { ont_sn: sn }
});
if (Array.isArray(data) && data.length > 0) {
setRow(out, '', data[0]);
found = true;
}
} catch {
}
}
if (!found && this.originalHeaders.includes(this.macColumnName)) {
const macRaw = ('' + (row[this.macColumnName] || '')).trim();
if (macRaw && macRaw.length === 12) {
const mac = macRaw.toUpperCase().match(/.{1,2}/g).join(':');
try {
const { data: ses } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'find_by_current_session', mac }
});
if (Array.isArray(ses) && ses.length > 0) {
const { data: d } = await axios.get(`${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { username: ses[0], info: '', custnum: '' }
});
if (Array.isArray(d) && d.length > 0) {
setRow(out, '', d[0]);
found = true;
}
}
} catch {
}
}
}
if (!found) setRow(out, 'Keinen Benutzer gefunden');
this.processedData.push(out);
this.progress = ((i + 1) / this.totalRows) * 100;
if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20));
}
this.loading = false;
this.currentSerial = '';
},
downloadResults() {
if (!this.processedData.length) return;
try {
const data = this.processedData.map(r => {
const o = {};
this.originalHeaders.forEach(h => o[h] = r[h]);
Object.keys(this.fetchedKeys).forEach(k => {
const K = k.charAt(0).toUpperCase() + k.slice(1).replace('Number', 'nummer').replace('Name', 'name');
o[K] = r[this.fetchedKeys[k]];
});
return o;
});
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'ONT_Finder_Results');
XLSX.writeFile(wb, `ont_finder_results_${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 14)}.xlsx`);
} catch {
if (window.notify) window.notify('error', 'Fehler beim Erstellen der Excel-Datei.');
}
}
}
});
};
// Register component with Vue 3 app
if (window.VueApp) {
window.VueApp.component('radius-ont-finder', RadiusOntFinder);
}
+172 -23
View File
@@ -1,36 +1,185 @@
/* ===== RadiusOntParser.js ===== */
Vue.component('radius-ont-parser', {
/* ===== RadiusOntParser.js (Vue 3 + TT-Core) ===== */
const RadiusOntParser = {
name: 'RadiusOntParser',
template: `
<div class="radius-scope ont-card">
<div class="tt-scope ont-card">
<div v-if="step===1">
<div class="block-head"><div class="h4"><i class="fa-duotone fa-file-spreadsheet"></i> Schritt 1 · Excel (XLSX) Upload</div><div class="muted small">Laden Sie eine XLSX-Datei mit Ihren Kundendaten.</div></div>
<radius-file-drop @file-selected="readXlsx" />
<div class="block-head">
<div class="h4"><i class="fa-duotone fa-file-spreadsheet"></i> Schritt 1 · Excel (XLSX) Upload</div>
<div class="muted small">Laden Sie eine XLSX-Datei mit Ihren Kundendaten.</div>
</div>
<tt-file-dropzone accept=".xlsx,.xls" @file-selected="readXlsx" />
</div>
<div v-if="step===2">
<div class="block-head"><div class="h4"><i class="fa-duotone fa-sliders"></i> Schritt 2 · Spaltenzuordnung</div></div>
<div class="grid g-4 cols-2 cols-1@sm"><div class="field" v-for="field in requiredFields" :key="field.key"><label>{{ field.label }}</label><div class="select"><select v-model="selectedColumns[field.key]"><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select></div></div></div>
<div class="cluster mt-3"><button class="primary-btn" @click="startProcessing"><i class="fa-duotone fa-play"></i> Verarbeitung starten</button><button class="ghost-btn" @click="step = 1"><i class="fa-duotone fa-arrow-left"></i> Zurück</button></div>
<div class="block-head">
<div class="h4"><i class="fa-duotone fa-sliders"></i> Schritt 2 · Spaltenzuordnung</div>
</div>
<div class="grid g-4 cols-2 cols-1@sm">
<div class="field" v-for="field in requiredFields" :key="field.key">
<label>{{ field.label }}</label>
<div class="select">
<select v-model="selectedColumns[field.key]">
<option v-for="h in headers" :key="h" :value="h">{{ h }}</option>
</select>
</div>
</div>
</div>
<div class="cluster mt-3">
<button class="primary-btn" @click="startProcessing">
<i class="fa-duotone fa-play"></i> Verarbeitung starten
</button>
<button class="ghost-btn" @click="step = 1">
<i class="fa-duotone fa-arrow-left"></i> Zurück
</button>
</div>
</div>
<div v-if="step===3">
<div class="block-head"><div class="h4"><i class="fa-duotone fa-list-check"></i> Schritt 3 · Ergebnisse</div><div class="cluster"><button class="primary-btn" @click="downloadResults" :disabled="loading"><i class="fa-duotone fa-download"></i> Neue Excel herunterladen</button><button class="ghost-btn" @click="step = 2" :disabled="loading"><i class="fa-duotone fa-arrow-left"></i> Zurück</button><button class="ghost-btn" @click="resetLocal" :disabled="loading"><i class="fa-duotone fa-rotate-right"></i> Neue Verarbeitung</button></div></div>
<div class="block-head">
<div class="h4"><i class="fa-duotone fa-list-check"></i> Schritt 3 · Ergebnisse</div>
<div class="cluster">
<button class="primary-btn" @click="downloadResults" :disabled="loading">
<i class="fa-duotone fa-download"></i> Neue Excel herunterladen
</button>
<button class="ghost-btn" @click="step = 2" :disabled="loading">
<i class="fa-duotone fa-arrow-left"></i> Zurück
</button>
<button class="ghost-btn" @click="resetLocal" :disabled="loading">
<i class="fa-duotone fa-rotate-right"></i> Neue Verarbeitung
</button>
</div>
</div>
<div class="results-container mt-between">
<radius-processing-indicator v-if="loading" :progress="progress" :current-row="currentRow" :total-rows="totalRows">
<template #description><p class="muted small">Aktueller Kunde: {{ currentCustomerNumber || '—' }}</p></template>
</radius-processing-indicator>
<radius-table-view v-else :items="processedData" :has-searched="true" no-results-placeholder-text="Keine Daten verarbeitet.">
<template #head><thead><tr><th v-for="h in requiredFields" :key="h.key">{{ h.label }}</th><th>ONT SN</th></tr></thead></template>
<template #row="{ item }"><td>{{ item[selectedColumns.kundennummer] }}</td><td>{{ item[selectedColumns.anschlussstrasse] }}</td><td>{{ item[selectedColumns.anschlussplz] }}</td><td>{{ item[selectedColumns.anschlusscity] }}</td><td class="mono">{{ item.ont_sn }}</td></template>
</radius-table-view>
<div v-if="!loading && processedData.length" class="results-summary">{{ processedData.length }} Zeilen verarbeitet</div>
<tt-loading-indicator
v-if="loading"
:text="'Aktueller Kunde: ' + (currentCustomerNumber || '—')"
:progress="progress"
style="min-height: 200px;"
/>
<tt-data-table
v-else
:items="processedData"
:has-searched="true"
no-results-placeholder-text="Keine Daten verarbeitet."
>
<template #head>
<thead>
<tr>
<th v-for="h in requiredFields" :key="h.key">{{ h.label }}</th>
<th>ONT SN</th>
</tr>
</thead>
</template>
<template #row="{ item }">
<td>{{ item[selectedColumns.kundennummer] }}</td>
<td>{{ item[selectedColumns.anschlussstrasse] }}</td>
<td>{{ item[selectedColumns.anschlussplz] }}</td>
<td>{{ item[selectedColumns.anschlusscity] }}</td>
<td class="mono">{{ item.ont_sn }}</td>
</template>
</tt-data-table>
<div v-if="!loading && processedData.length" class="results-summary">
{{ processedData.length }} Zeilen verarbeitet
</div>
</div>
</div>
</div>
`,
data: () => ({ step: 1, headers: [], parsedData: [], processedData: [], selectedColumns: { kundennummer: 'crmPartner', anschlussstrasse: 'AnlStrasse', anschlussplz: 'AnlPlz', anschlusscity: 'AnlOrt' }, requiredFields: [ { key: 'kundennummer', label: 'Kundennummer' }, { key: 'anschlussstrasse', label: 'Anschlussstraße' }, { key: 'anschlussplz', label: 'Anschluss PLZ' }, { key: 'anschlusscity', label: 'Anschluss City' } ], loading: false, progress: 0, currentRow: 0, totalRows: 0, currentCustomerNumber: '' }),
data: () => ({
step: 1,
headers: [],
parsedData: [],
processedData: [],
selectedColumns: {
kundennummer: 'crmPartner',
anschlussstrasse: 'AnlStrasse',
anschlussplz: 'AnlPlz',
anschlusscity: 'AnlOrt'
},
requiredFields: [
{ key: 'kundennummer', label: 'Kundennummer' },
{ key: 'anschlussstrasse', label: 'Anschlussstraße' },
{ key: 'anschlussplz', label: 'Anschluss PLZ' },
{ key: 'anschlusscity', label: 'Anschluss City' }
],
loading: false,
progress: 0,
currentRow: 0,
totalRows: 0,
currentCustomerNumber: ''
}),
methods: {
async readXlsx(file){ await window.RadiusUtils.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'); const fr = new FileReader(); fr.onload = (e)=>{ const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' }); this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); this.headers = Object.keys(this.parsedData[0] || {}); this.step = 2; }; fr.readAsArrayBuffer(file); },
async startProcessing(){ this.step = 3; this.loading = true; this.totalRows = this.parsedData.length; this.processedData = []; this.currentRow = 0; const p = []; const b = window.TT_CONFIG.BASE_PATH; loop: for (let i=0; i<this.parsedData.length; i++){ this.currentRow = i; this.progress = ((i + 1) / this.totalRows) * 100; const row = { ...this.parsedData[i] }; this.currentCustomerNumber = row[this.selectedColumns.kundennummer] || ''; try{ const res = await fetch(`${b}/Radius/proxyUnsecureHTTPRequestToRadius?custnume=${encodeURIComponent(row[this.selectedColumns.kundennummer])}`); const users = await res.json(); if (users.length === 0) { row.ont_sn = 'N/A - Kein Benutzer'; } else if (users.length === 1) { const r = await fetch(`${b}/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=${encodeURIComponent(users[0].username)}`); const d = await r.json(); row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; } else { const [s,pl,c] = [row[this.selectedColumns.anschlussstrasse], row[this.selectedColumns.anschlussplz], row[this.selectedColumns.anschlusscity]]; for (let u of users) { if (window.RadiusUtils.validateData(s, pl, c, u.info || users[0].info || '')) { const r = await fetch(`${b}/Radius/proxyUnsecureHTTPRequestToRadius?skipAdditional=true&action2=fetchRadacct&username=${encodeURIComponent(u.username)}`); const d = await r.json(); row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN'; p.push(row); continue loop; } } row.ont_sn = 'N/A - Anschluss nicht zugeordnet'; } } catch { row.ont_sn = 'N/A - Fehler'; } p.push(row); if ((i + 1) % 20 === 0) await new Promise(r=>setTimeout(r,20)); } this.loading=false; this.processedData = p; },
downloadResults(){ const ws = XLSX.utils.json_to_sheet(this.processedData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Results'); XLSX.writeFile(wb, 'results.xlsx'); },
resetLocal(){ Object.assign(this.$data, this.$options.data.call(this)); }
async readXlsx(file) {
await window.TT_CORE.loadScript('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js');
const fr = new FileReader();
fr.onload = (e) => {
const wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' });
this.parsedData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
this.headers = Object.keys(this.parsedData[0] || {});
this.step = 2;
};
fr.readAsArrayBuffer(file);
},
async startProcessing() {
this.step = 3;
this.loading = true;
this.totalRows = this.parsedData.length;
this.processedData = [];
this.currentRow = 0;
const p = [];
const b = window.TT_CONFIG.BASE_PATH;
loop: for (let i = 0; i < this.parsedData.length; i++) {
this.currentRow = i;
this.progress = ((i + 1) / this.totalRows) * 100;
const row = { ...this.parsedData[i] };
this.currentCustomerNumber = row[this.selectedColumns.kundennummer] || '';
try {
const { data: users } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { custnume: row[this.selectedColumns.kundennummer] }
});
if (users.length === 0) {
row.ont_sn = 'N/A - Kein Benutzer';
} else if (users.length === 1) {
const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { skipAdditional: 'true', action2: 'fetchRadacct', username: users[0].username }
});
row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN';
} else {
const [s, pl, c] = [row[this.selectedColumns.anschlussstrasse], row[this.selectedColumns.anschlussplz], row[this.selectedColumns.anschlusscity]];
for (let u of users) {
if (window.TT_CORE.validateData(s, pl, c, u.info || users[0].info || '')) {
const { data: d } = await axios.get(`${b}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { skipAdditional: 'true', action2: 'fetchRadacct', username: u.username }
});
row.ont_sn = d.ont_sn || 'N/A - Keine ONT SN';
p.push(row);
continue loop;
}
}
row.ont_sn = 'N/A - Anschluss nicht zugeordnet';
}
} catch {
row.ont_sn = 'N/A - Fehler';
}
p.push(row);
if ((i + 1) % 20 === 0) await new Promise(r => setTimeout(r, 20));
}
this.loading = false;
this.processedData = p;
},
downloadResults() {
const ws = XLSX.utils.json_to_sheet(this.processedData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Results');
XLSX.writeFile(wb, 'results.xlsx');
},
resetLocal() {
Object.assign(this.$data, this.$options.data.call(this));
}
}
});
};
// Register component with Vue 3 app
if (window.VueApp) {
window.VueApp.component('radius-ont-parser', RadiusOntParser);
}
@@ -0,0 +1,85 @@
const RadiusRadacctModal = {
name: 'RadiusRadacctModal',
props: {
show: Boolean,
username: String
},
template: `
<tt-dialog :show="show" title="RADIUS Daten" @close="$emit('close')">
<div class="kv-redesign">
<div class="kv-row"><span class="kv-label">Status</span>
<div class="kv-value">
<div v-if="radacctData"><strong class="chip"
:class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong>
</div>
<div v-else><tt-skeleton width="80px" height="24px" style="margin-left: auto;" /></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">IP</span>
<div class="kv-value">
<div v-if="radacctData" class="inline-copy">
<code v-if="radacctData.ip">{{ radacctData.ip }}</code>
<code v-else>—</code>
<tt-copy-button v-if="radacctData.ip" :text="radacctData.ip" />
</div>
<div v-else><tt-skeleton width="120px" style="margin-left: auto;" /></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">Username</span>
<div class="kv-value">
<div v-if="radacctData" class="inline-copy">
<a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username"
data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a>
<tt-copy-button :text="radacctData.username" />
</div>
<div v-else><tt-skeleton width="150px" style="margin-left: auto;" /></div>
</div>
</div>
<template v-if="radacctData">
<div class="kv-row"><span class="kv-label">Kundennummer</span><code class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div>
<div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div></div>
<div class="kv-row"><span class="kv-label">Info</span><div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div></div>
<div class="kv-row"><span class="kv-label">WLAN Password</span><code class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div>
<div class="kv-row"><span class="kv-label">Bandbreite</span><code class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div>
</template>
<template v-else>
<div class="kv-row" v-for="n in 5" :key="n"><span class="kv-label">&nbsp;</span><div class="kv-value"><tt-skeleton style="margin-left: auto;" /></div></div>
</template>
</div>
</tt-dialog>
`,
data: () => ({
radacctData: null
}),
watch: {
show(val) {
if (val && this.username) {
this.fetchRadacctData();
} else {
this.radacctData = null;
}
}
},
methods: {
async fetchRadacctData() {
this.radacctData = null;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'fetchRadacct', username: this.username }
});
this.radacctData = data;
} catch (error) {
console.error(error);
this.radacctData = {};
}
}
}
};
if (window.VueApp) {
window.VueApp.component('RadiusRadacctModal', RadiusRadacctModal);
}
@@ -0,0 +1,474 @@
const RadiusRouterManager = {
name: 'RadiusRouterManager',
props: {
show: Boolean,
userItem: Object
},
template: `
<div>
<!-- Main Router Management Modal -->
<tt-dialog
:show="show"
:title="'Router Management - ' + (userItem.username || '')"
@close="$emit('close')"
size="wide"
>
<div class="modal-body-scrollable">
<div v-if="!routerDevice && !routerLoading" class="table-placeholder" style="min-height: 300px;">
<i class="fa-duotone fa-router-slash" style="font-size: 48px; opacity: 0.3;"></i>
<div style="margin-top: 16px;">Kein Router mit dieser IP gefunden</div>
</div>
<div v-else>
<!-- Router Info Header -->
<div class="router-info-header">
<i class="fa-duotone fa-router"></i>
<div class="router-header-text">
<div class="router-title">
<tt-skeleton v-if="routerLoading" width="200px" height="22px" />
<span v-else>{{ routerDevice.deviceInfo.hardwareVersion || 'Router' }}</span>
</div>
<div class="router-subtitle" :style="routerLoading ? 'margin-top: 2px' : ''">
<tt-skeleton v-if="routerLoading" width="140px" height="15px" />
<span v-else>{{ routerDevice.username || userItem.username }}</span>
</div>
</div>
</div>
<!-- Router Information Grid -->
<div class="router-info-grid">
<tt-info-card icon="fa-microchip" label="Hardware Modell" :value="routerDevice?.deviceInfo?.hardwareVersion" :loading="routerLoading" />
<tt-info-card icon="fa-code-branch" label="Software Version" :value="routerDevice?.deviceInfo?.softwareVersion" :loading="routerLoading" />
<tt-info-card icon="fa-barcode" label="CWMP Account" :value="routerDevice?.deviceInfo?.serialNumber" :loading="routerLoading" />
<tt-info-card icon="fa-fingerprint" label="ACS ID" :value="routerDevice?.deviceId" :loading="routerLoading" />
<tt-info-card icon="fa-globe" label="Externe IP" :value="routerDevice?.ip" :loading="routerLoading" />
<tt-info-card icon="fa-network-wired" label="Management IP" :value="routerDevice?.managementIp" :loading="routerLoading" />
</div>
<!-- Router Actions Section -->
<div class="router-actions-section">
<h4 class="router-actions-header">
<i class="fa-duotone fa-bolt"></i>
Router Aktionen
</h4>
<div class="router-actions-grid">
<button class="ghost-btn action-btn" @click="runRemoteAccess" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-key"></i>
<span>Remote-Zugriff</span>
</button>
<button class="ghost-btn action-btn" @click="rebootRouter" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-power-off"></i>
<span>Neustart</span>
</button>
<button class="ghost-btn action-btn" @click="pingRouter" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-signal-bars"></i>
<span>Ping</span>
</button>
<button class="ghost-btn action-btn" @click="runSpeedtest" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-gauge-high"></i>
<span>Speedtest</span>
</button>
<button class="ghost-btn action-btn" @click="openNetworkStructure" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-sitemap"></i>
<span>Netzwerkstruktur</span>
</button>
<button class="ghost-btn action-btn" @click="openEventLog" :disabled="routerLoading || routerActionLoading || speedtestLoading">
<i class="fa-duotone fa-list-timeline"></i>
<span>Ereignisprotokoll</span>
</button>
</div>
</div>
</div>
</div>
</tt-dialog>
<!-- SUB MODALS (Managed by this component) -->
<!-- Ping Modal -->
<tt-dialog :show="showPingModal" title="Ping Ergebnis" @close="showPingModal = false">
<tt-loading-indicator v-if="routerActionLoading && !pingResult" text="Ping läuft..." style="height: 150px;" />
<div v-else-if="pingResult">
<div class="kv-redesign">
<div class="kv-row"><span class="kv-label">Gesendet</span><code class="kv-value">{{ pingResult.packetsTransmitted }}</code></div>
<div class="kv-row"><span class="kv-label">Empfangen</span><code class="kv-value">{{ pingResult.packetsReceived }}</code></div>
<div class="kv-row"><span class="kv-label">Verlust</span><code class="kv-value">{{ pingResult.packetLoss }}%</code></div>
<div class="kv-row"><span class="kv-label">Min / Avg / Max</span><code class="kv-value">{{ pingResult.min }} / {{ pingResult.avg }} / {{ pingResult.max }} ms</code></div>
</div>
</div>
<div v-else class="table-placeholder" style="height: 150px;">Kein Ergebnis.</div>
</tt-dialog>
<!-- Speedtest Modal -->
<tt-dialog :show="showSpeedtestModal" title="Speedtest Ergebnis" @close="showSpeedtestModal = false" size="wide">
<tt-loading-indicator v-if="speedtestLoading && speedtestHistory.length === 0" text="Speedtest wird initialisiert..." style="height: 200px;" />
<div v-else>
<div class="table-wrap" style="max-height: 500px; overflow-y: auto;">
<table class="tt-table compact">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th style="text-align: right">Bandbreite</th>
<th style="text-align: right">Übertragen</th>
<th style="text-align: right">Pakete</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in speedtestHistory" :key="idx">
<td class="mono small">{{ idx + 1 }}</td>
<td class="mono small" style="text-align: right">{{ row.bpsFormatted }}</td>
<td class="mono small" style="text-align: right">{{ row.bytesFormatted }}</td>
<td class="mono small" style="text-align: right">{{ row.packets }}</td>
</tr>
</tbody>
</table>
<div ref="speedtestBottom"></div>
</div>
<div v-if="speedtestLoading" class="center mt-3 muted small">
<i class="fa-duotone fa-spinner fa-spin"></i> Aktualisiere...
</div>
<div v-else class="center mt-3" style="color: var(--ok);">
<i class="fa-duotone fa-check-circle"></i> Abgeschlossen
</div>
</div>
</tt-dialog>
<!-- Remote Access Modal -->
<tt-dialog :show="showRemoteAccessModal" title="Remote Zugriff Konfiguration" @close="showRemoteAccessModal = false">
<tt-loading-indicator v-if="remoteAccessLoading" :text="remoteAccessStep" style="height: 200px;" />
<div v-else-if="remoteAccessResult">
<div class="alert ok mb-4" style="background-color: #eaf7ef; border: 1px solid #c9e6d8; color: #206a42; padding: 12px; border-radius: 8px;">
<i class="fa-duotone fa-check-circle"></i> Konfiguration erfolgreich abgeschlossen.
</div>
<div class="kv-redesign">
<div class="kv-row">
<span class="kv-label">Remote Link</span>
<div class="kv-value inline-copy">
<a :href="remoteAccessResult.link" target="_blank" class="link">{{ remoteAccessResult.link }}</a>
<tt-copy-button :text="remoteAccessResult.link" />
</div>
</div>
<div class="kv-row">
<span class="kv-label">Username</span>
<div class="kv-value inline-copy">
<code class="mono">{{ remoteAccessResult.username }}</code>
<tt-copy-button :text="remoteAccessResult.username" />
</div>
</div>
<div class="kv-row">
<span class="kv-label">Password</span>
<div class="kv-value inline-copy">
<code class="mono">{{ remoteAccessResult.password }}</code>
<tt-copy-button :text="remoteAccessResult.password" />
</div>
</div>
</div>
<div class="mt-4 pt-3" style="border-top: 1px solid var(--border);">
<button class="ghost-btn" @click="runRemoteAccess(true)" :disabled="remoteAccessLoading">
<i class="fa-duotone fa-rotate"></i>
<span>Zugangsdaten neu erstellen</span>
</button>
</div>
</div>
<div v-else class="table-placeholder" style="height: 200px;">Ein Fehler ist aufgetreten.</div>
</tt-dialog>
<!-- Network Structure Modal -->
<tt-dialog :show="showNetworkStructureModal" title="Netzwerkstruktur" @close="showNetworkStructureModal = false" size="wide">
<tt-loading-indicator v-if="networkStructureLoading" text="Lade Struktur..." style="min-height: 300px;" />
<div v-else-if="rootDevice">
<div class="network-tree-container">
<!-- Uses the recursive component -->
<radius-network-node :device="rootDevice" />
</div>
</div>
<div v-else class="table-placeholder" style="min-height: 300px;">Keine Daten verfügbar.</div>
</tt-dialog>
<!-- Event Log Modal -->
<tt-dialog :show="showEventLogModal" title="Ereignisprotokoll" @close="showEventLogModal = false" size="wide">
<tt-loading-indicator v-if="eventLogLoading" text="Lade Ereignisprotokoll..." style="min-height: 300px;" />
<div v-else-if="eventLogData && eventLogData.length > 0">
<div class="table-wrap" style="max-height: 500px; overflow-y: auto;">
<table class="tt-table compact">
<thead>
<tr>
<th style="width: 100px;">Datum</th>
<th style="width: 80px;">Uhrzeit</th>
<th style="width: 120px;">Gruppe</th>
<th>Nachricht</th>
</tr>
</thead>
<tbody>
<tr v-for="(event, idx) in eventLogData" :key="idx">
<td class="mono small">{{ event.date }}</td>
<td class="mono small">{{ event.time }}</td>
<td class="small">{{ event.group }}</td>
<td class="small">{{ event.msg }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="table-placeholder" style="min-height: 300px;">Keine Ereignisse verfügbar.</div>
</tt-dialog>
</div>
`,
data: () => ({
routerLoading: false,
routerActionLoading: false,
routerDevice: null,
// Sub-Modal States
showPingModal: false,
pingResult: null,
showSpeedtestModal: false,
speedtestLoading: false,
speedtestResult: null,
speedtestHistory: [],
speedtestHasStarted: false,
showRemoteAccessModal: false,
remoteAccessLoading: false,
remoteAccessResult: null,
remoteAccessStep: '',
showNetworkStructureModal: false,
networkStructureLoading: false,
rootDevice: null,
showEventLogModal: false,
eventLogLoading: false,
eventLogData: null
}),
watch: {
show: {
handler(val) {
if (val && this.userItem) {
this.loadRouterData();
}
},
immediate: true
},
userItem(val) {
if (val && this.show) {
this.loadRouterData();
}
}
},
methods: {
async loadRouterData() {
this.routerLoading = true;
this.routerDevice = null;
this.pingResult = null;
this.speedtestResult = null;
this.speedtestLoading = false;
try {
const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'fetchRadacct', username: this.userItem.username }
});
if (radacct?.ip) {
const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByIp`, {
params: { ip: radacct.ip }
});
if (deviceData?.success) {
this.routerDevice = deviceData;
}
}
} catch (error) {
console.error('Error fetching router:', error);
window.notify('error', 'Fehler beim Laden des Routers');
}
this.routerLoading = false;
},
async rebootRouter() {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
if (!confirm('Möchten Sie den Router wirklich neu starten?')) return;
this.routerActionLoading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRebootDevice`, {
deviceId: this.routerDevice.deviceId
});
if (data.success) {
window.notify('success', 'Router-Neustart gestartet');
} else {
window.notify('error', data.message || 'Fehler beim Neustart');
}
} catch (error) {
console.error('Error rebooting router:', error);
window.notify('error', 'Fehler beim Neustarten des Routers');
}
this.routerActionLoading = false;
},
async pingRouter() {
if (!this.routerDevice) return;
const pingIp = this.routerDevice.managementIp || this.routerDevice.ip;
if (!pingIp) return;
this.showPingModal = true;
this.routerActionLoading = true;
this.pingResult = null;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsPing`, {
params: { ip: pingIp }
});
if (data.success && data.result) {
this.pingResult = data.result;
window.notify('success', 'Ping erfolgreich');
} else {
window.notify('error', 'Ping fehlgeschlagen');
}
} catch (error) {
console.error('Error pinging router:', error);
window.notify('error', 'Fehler beim Pingen des Routers');
}
this.routerActionLoading = false;
},
async runSpeedtest() {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
this.showSpeedtestModal = true;
this.speedtestLoading = true;
this.speedtestResult = null;
this.speedtestHistory = [];
this.speedtestHasStarted = false;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRunSpeedtest`, {
deviceId: this.routerDevice.deviceId
});
if (data.success) {
this.pollSpeedtestResult();
} else {
throw new Error(data.message || "Speedtest konnte nicht gestartet werden");
}
} catch (e) {
window.notify('error', e.response?.data?.message || e.message || 'Fehler beim Starten des Speedtests');
this.speedtestLoading = false;
}
},
async pollSpeedtestResult() {
let attempts = 0;
const maxAttempts = 240;
const poll = async () => {
if (!this.showSpeedtestModal) return;
if (attempts >= maxAttempts) {
this.speedtestLoading = false;
window.notify('error', 'Speedtest Zeitüberschreitung');
return;
}
attempts++;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetSpeedtestResult`, {
deviceId: this.routerDevice.deviceId
});
if (data.success && data.result) {
this.speedtestHistory.push(data.result);
this.$nextTick(() => {
if (this.$refs.speedtestBottom) {
this.$refs.speedtestBottom.scrollIntoView({ behavior: 'smooth' });
}
});
if (data.result.bps > 0) this.speedtestHasStarted = true;
if (this.speedtestHasStarted && data.result.bps === 0) {
this.speedtestLoading = false;
window.notify('success', 'Speedtest abgeschlossen');
return;
}
}
} catch (e) {
console.error(e);
}
if (this.speedtestLoading) setTimeout(poll, 1000);
};
poll();
},
async runRemoteAccess(forceRecreate = false) {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
this.showRemoteAccessModal = true;
this.remoteAccessLoading = true;
this.remoteAccessStep = forceRecreate ? 'Erstelle neue Zugangsdaten...' : 'Konfiguriere Zugriff...';
this.remoteAccessResult = null;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRemoteAccess`, {
deviceId: this.routerDevice.deviceId,
forceRecreate: forceRecreate
});
if (data.success) {
this.remoteAccessResult = data;
if (forceRecreate) {
window.notify('success', 'Neue Zugangsdaten erstellt');
}
} else {
throw new Error(data.message || "Unbekannter Fehler");
}
} catch (error) {
window.notify('error', error.response?.data?.message || error.message || 'Fehler bei Remote Access');
} finally {
this.remoteAccessLoading = false;
}
},
async openNetworkStructure() {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
this.showNetworkStructureModal = true;
this.networkStructureLoading = true;
this.rootDevice = null;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsNetworkStructure`, {
deviceId: this.routerDevice.deviceId
});
if (data.root) {
this.rootDevice = data.root;
}
} catch (error) {
console.error(error);
window.notify('error', 'Fehler beim Laden der Netzwerkstruktur');
} finally {
this.networkStructureLoading = false;
}
},
async openEventLog() {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
this.showEventLogModal = true;
this.eventLogLoading = true;
this.eventLogData = null;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsEventLog`, {
deviceId: this.routerDevice.deviceId
});
if (data.success && data.events) {
this.eventLogData = data.events;
} else {
throw new Error(data.message || "Keine Ereignisse gefunden");
}
} catch (error) {
console.error(error);
window.notify('error', error.response?.data?.message || 'Fehler beim Laden des Ereignisprotokolls');
} finally {
this.eventLogLoading = false;
}
}
}
};
if (window.VueApp) {
window.VueApp.component('radius-router-manager', RadiusRouterManager);
}
@@ -0,0 +1,441 @@
const RadiusTransferModal = {
name: 'RadiusTransferModal',
props: {
show: Boolean,
username: String
},
template: `
<tt-dialog
:show="show"
:title="'Transfer Statistik für ' + username"
@close="close"
size="wide"
>
<div class="modal-body-scrollable">
<div v-if="transferYearlyData || transferInitialLoading">
<div class="unselectable">
<div class="cluster"
style="justify-content: space-between; margin-bottom: 16px; flex-wrap: nowrap; margin-top: 4px; padding-left: 8px;">
<div class="cluster">
<div class="custom-dropdown">
<button class="dropdown-toggle"
@click="!transferInitialLoading && (showYearDropdown = !showYearDropdown)"
:class="{'is-open': showYearDropdown}">
<span>{{ transferYear }}</span>
<i class="fa-solid fa-chevron-down"></i>
</button>
<transition name="ac-pop">
<div v-if="showYearDropdown" class="dropdown-panel">
<div v-for="y in availableYears" :key="y" class="dropdown-item" @click="selectYear(y)">
{{ y }}
</div>
</div>
</transition>
</div>
<div class="cluster" style="gap: 4px;">
<button v-for="m in allMonths" :key="m.month" class="tab-btn"
:class="{active: transferMonth === m.month}"
:disabled="isMonthDisabled(m.month)"
@click="changeTransferMonth(m.month)">{{ m.name }}
</button>
</div>
</div>
<div class="cluster" style="gap: 16px;">
<div class="muted small mono" style="text-align: right; flex-shrink: 0;">
Gesamt {{ transferYear }}:<br>
<strong v-if="transferInitialLoading || !transferYearlyData">
<tt-skeleton width="110px" height="16px" style="margin-left:auto;" />
</strong>
<strong v-else>{{ window.TT_CORE.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}</strong>
</div>
<button class="ghost-btn" @click="prepareEmailModal"
:disabled="transferInitialLoading || !transferYearlyData || !transferYearlyData.yearlySummary || transferYearlyData.yearlySummary.grandTotalBytes === 0"
data-tooltip="Statistik per E-Mail senden"
data-tooltip-align="bottom-left"
data-tooltip-wrap="true">
<i class="fa-duotone fa-paper-plane"></i>
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid g-4 cols-4">
<div class="stat-card-v2 stat-total">
<div class="stat-icon"><i class="fa-duotone fa-grid-2"></i></div>
<div>
<div class="stat-label">Monat gesamt</div>
<div class="stat-value">
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-download">
<div class="stat-icon"><i class="fa-duotone fa-arrow-down-to-line"></i></div>
<div>
<div class="stat-label">Download</div>
<div class="stat-value">
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-upload">
<div class="stat-icon"><i class="fa-duotone fa-arrow-up-from-line"></i></div>
<div>
<div class="stat-label">Upload</div>
<div class="stat-value">
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="100px" height="18px" /></span>
<span v-else>{{ window.TT_CORE.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-duration">
<div class="stat-icon"><i class="fa-duotone fa-hourglass-clock"></i></div>
<div>
<div class="stat-label">Dauer</div>
<div class="stat-value">
<span v-if="transferInitialLoading || transferMonthlyLoading"><tt-skeleton width="80px" height="18px" /></span>
<span v-else>{{ window.TT_CORE.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span>
</div>
</div>
</div>
</div>
<!-- Chart -->
<div class="chart-card mt-3" style="height: 250px;">
<div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder">
<tt-skeleton width="100%" height="100%" style="border-radius: var(--radius);" />
</div>
<div
v-else-if="!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length"
class="chart-placeholder">
<i class="fa-duotone fa-chart-pie"></i>
<span>Keine Daten in diesem Monat verfügbar</span>
</div>
<canvas
v-show="!transferMonthlyLoading && !transferInitialLoading && transferMonthlyData?.details?.length"
ref="transferChartCanvas">
</canvas>
</div>
</div>
<!-- Details Table -->
<div class="table-wrap mt-3" style="height: 350px;">
<div
v-if="!transferInitialLoading && !transferMonthlyLoading && (!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length)"
class="table-placeholder-fixed-height">
<i class="fa-duotone fa-database"></i>
<span>Keine detaillierten Daten für diesen Monat.</span>
</div>
<table v-else class="tt-table compact">
<thead>
<tr>
<th>Startzeit</th>
<th>Dauer</th>
<th>IP-Adresse</th>
<th style="text-align: right;">Download</th>
<th style="text-align: right;">Upload</th>
<th style="text-align: right;">Gesamt</th>
</tr>
</thead>
<tbody>
<template v-if="transferInitialLoading || transferMonthlyLoading">
<tr v-for="n in 10" :key="'skel'+n">
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
</tr>
</template>
<template v-else>
<tr v-for="(d, i) in transferMonthlyData.details" :key="i">
<td class="mono small">{{ d.startTime }}</td>
<td class="mono small">{{ window.TT_CORE.formatDuration(d.durationSeconds) }}</td>
<td class="mono small">{{ d.ipAddress }}</td>
<td class="mono small" style="text-align: right;">{{ window.TT_CORE.formatBytes(d.downloadBytes) }}</td>
<td class="mono small" style="text-align: right;">{{ window.TT_CORE.formatBytes(d.uploadBytes) }}</td>
<td class="mono small" style="text-align: right;"><strong>{{ window.TT_CORE.formatBytes(d.totalBytes) }}</strong></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div v-else-if="!transferInitialLoading" class="table-placeholder" style="min-height: 400px;">
<i class="fa-duotone fa-wifi-slash"></i>
<div>Daten konnten nicht geladen werden.</div>
</div>
</div>
<!-- Embedded Email Modal (Logic kept here as it depends on local chart/data) -->
<tt-dialog :show="showEmailModal" title="Statistik per E-Mail senden" @close="showEmailModal=false">
<div>
<div class="field">
<label style="margin-bottom: 8px; font-size: 14px;">Empfänger-E-Mail</label>
<div class="input-wrap">
<i class="fa-duotone fa-envelope input-icon"></i>
<input
class="ri"
type="email"
v-model.trim="recipientEmail"
placeholder="name@domain.com"
@keydown.enter="isValidEmail && !isSendingEmail && sendTransferEmail()"
autocomplete="nope"
/>
</div>
<p v-if="recipientEmail && !isValidEmail" class="muted small" style="color: var(--bad); margin-top: 4px;">
Bitte geben Sie eine gültige E-Mail-Adresse ein.
</p>
</div>
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
<button class="ghost-btn" @click="showEmailModal=false" :disabled="isSendingEmail">Abbrechen</button>
<button class="primary-btn" @click="sendTransferEmail" :disabled="!isValidEmail || isSendingEmail" style="min-width: 100px;">
<span v-if="!isSendingEmail">Senden</span>
<span v-else class="btn-loader"></span>
</button>
</div>
</div>
</tt-dialog>
</tt-dialog>
`,
data: () => ({
window: window,
transferInitialLoading: false,
transferMonthlyLoading: false,
transferYear: new Date().getFullYear(),
transferMonth: new Date().getMonth() + 1,
transferYearlyData: null,
transferMonthlyData: null,
transferChartInstance: null,
showYearDropdown: false,
// Email Logic
showEmailModal: false,
isSendingEmail: false,
recipientEmail: ''
}),
computed: {
availableYears() {
const c = new Date().getFullYear(), s = 2021;
if (s > c) return [c];
return Array.from({length: c - s + 1}, (_, i) => c - i);
},
allMonths() {
return Array.from({length: 12}, (_, i) => ({
month: i + 1,
name: new Date(2000, i, 1).toLocaleString('de-DE', {month: 'short'})
}));
},
isValidEmail() {
if (!this.recipientEmail) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail);
}
},
watch: {
show(val) {
if (val && this.username) {
this.transferYear = new Date().getFullYear();
this.transferMonth = new Date().getMonth() + 1;
this.fetchTransferYearData();
} else {
// Cleanup
this.close();
}
}
},
methods: {
close() {
this.$emit('close');
this.transferYearlyData = null;
this.transferMonthlyData = null;
this.showYearDropdown = false;
this.showEmailModal = false;
this.recipientEmail = '';
this.isSendingEmail = false;
if (this.transferChartInstance) {
this.transferChartInstance.destroy();
this.transferChartInstance = null;
}
},
async fetchTransferYearData() {
this.transferInitialLoading = true;
this.transferYearlyData = null;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: {
action2: 'transferStatistic',
username: this.username,
year: this.transferYear,
month: 0
}
});
if (data && data.monthlySummary) {
this.transferYearlyData = data;
const last = [...data.monthlySummary].reverse().find(m => m.grandTotalBytes > 0);
this.transferMonth = last ? last.month : new Date().getMonth() + 1;
await this.fetchTransferMonthData();
} else {
this.transferYearlyData = null;
}
} catch (e) {
console.error(e);
this.transferYearlyData = null;
}
this.transferInitialLoading = false;
},
async fetchTransferMonthData() {
this.transferMonthlyLoading = true;
this.transferMonthlyData = null;
if (this.transferChartInstance) this.transferChartInstance.destroy();
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: {
action2: 'transferStatistic',
username: this.username,
year: this.transferYear,
month: this.transferMonth
}
});
this.transferMonthlyData = data || null;
} catch (e) {
console.error(e);
this.transferMonthlyData = null;
}
this.transferMonthlyLoading = false;
this.$nextTick(() => {
if (this.show) this.renderTransferChart();
});
},
isMonthDisabled(month) {
if (this.transferInitialLoading || this.transferMonthlyLoading) return true;
if (!this.transferYearlyData?.monthlySummary) return true;
const m = this.transferYearlyData.monthlySummary.find(m => m.month === month);
return !m || m.grandTotalBytes === 0;
},
selectYear(year) {
this.showYearDropdown = false;
if (this.transferYear !== year) this.changeTransferYear(year);
},
async changeTransferYear(year) {
this.transferYear = year;
await this.fetchTransferYearData();
},
async changeTransferMonth(month) {
this.transferMonth = month;
await this.fetchTransferMonthData();
},
prepareEmailModal() {
if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return;
this.recipientEmail = '';
this.showEmailModal = true;
},
async sendTransferEmail() {
if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return;
this.isSendingEmail = true;
try {
const chartImageBase64 = this.transferChartInstance.toBase64Image();
const payload = {
username: this.username,
year: this.transferYear,
month: this.transferMonth,
monthlySummary: this.transferMonthlyData.summary,
monthlyDetails: this.transferMonthlyData.details,
chartImage: chartImageBase64,
recipient: this.recipientEmail
};
await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, payload);
window.notify('success', 'E-Mail wurde erfolgreich versendet.');
this.showEmailModal = false;
} catch (e) {
console.error("Failed to send transfer email:", e);
window.notify('error', 'Fehler beim Senden der E-Mail.');
} finally {
this.isSendingEmail = false;
}
},
processChartData(details) {
if (!details || !details.length) return {labels: [], datasets: []};
const daily = details.reduce((a, s) => {
const d = s.startTime.split(' ')[0];
if (!a[d]) a[d] = {downloadBytes: 0, uploadBytes: 0};
a[d].downloadBytes += Number(s.downloadBytes) || 0;
a[d].uploadBytes += Number(s.uploadBytes) || 0;
return a;
}, {});
const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b));
return {
labels: dates,
datasets: [{
label: 'Download',
data: dates.map(d => daily[d].downloadBytes),
borderColor: 'rgba(15, 157, 88, 0.8)',
backgroundColor: 'rgba(15, 157, 88, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
borderWidth: 1.5
}, {
label: 'Upload',
data: dates.map(d => daily[d].uploadBytes),
borderColor: 'rgba(0, 83, 132, 0.8)',
backgroundColor: 'rgba(0, 83, 132, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
borderWidth: 1.5
}]
};
},
renderTransferChart() {
if (this.transferChartInstance) this.transferChartInstance.destroy();
if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return;
const d = this.processChartData(this.transferMonthlyData.details);
if (!d.labels.length) return;
const chartBackgroundColorPlugin = {
id: 'customCanvasBackgroundColor',
beforeDraw: (chart) => {
const {ctx} = chart;
ctx.save();
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, chart.width, chart.height);
ctx.restore();
}
};
this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), {
type: 'line',
data: d,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
time: {unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: {day: 'DD.MM'}},
grid: {display: false},
ticks: {maxRotation: 0, autoSkip: true, maxTicksLimit: 15}
},
y: {
beginAtZero: true,
ticks: {callback: (v) => window.TT_CORE.formatBytes(v, 0)},
grid: {color: 'rgba(0,0,0,0.05)'}
}
},
plugins: {
tooltip: {callbacks: {label: (c) => `${c.dataset.label || ''}: ${window.TT_CORE.formatBytes(c.parsed.y)}`}},
legend: {position: 'bottom', labels: {usePointStyle: true, boxWidth: 8, padding: 20}}
},
interaction: {mode: 'index', intersect: false}
},
plugins: [chartBackgroundColorPlugin]
});
}
}
};
if (window.VueApp) {
window.VueApp.component('radius-transfer-modal', RadiusTransferModal);
}
+146 -28
View File
@@ -1,15 +1,35 @@
/* ===== RadiusUnused.js ===== */
Vue.component('radius-unused-users', {
/* ===== RadiusUnused.js (Vue 3 + TT-Core) ===== */
const RadiusUnusedUsers = {
name: 'RadiusUnusedUsers',
template: `
<div class="radius-scope">
<div class="tt-scope">
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom: 12px;">
<div class="cluster">
<button v-for="f in filters" :key="f.id" class="tab-btn" :class="{active: activeFilter === f.id}" @click="setFilter(f.id)" :disabled="isLoading || !users.length"><i :class="f.icon"></i> {{f.name}}</button>
<button
v-for="f in filters"
:key="f.id"
class="tab-btn"
:class="{active: activeFilter === f.id}"
@click="setFilter(f.id)"
:disabled="isLoading || !users.length">
<i :class="f.icon"></i> {{f.name}}
</button>
</div>
<button class="ghost-btn" @click="fetchUnusedUsers" :disabled="isLoading" style="min-width: 120px;"><span v-if="!isLoading"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span><span v-else class="btn-loader"></span></button>
<button class="ghost-btn" @click="fetchUnusedUsers" :disabled="isLoading" style="min-width: 120px;">
<span v-if="!isLoading"><i class="fa-duotone fa-rotate-right"></i> Neu laden</span>
<span v-else class="btn-loader"></span>
</button>
</div>
<div class="results-container">
<radius-table-view :items="visibleFilteredUsers" :is-loading="isLoading" :has-searched="hasSearched" initial-placeholder-icon="fa-duotone fa-play-circle" initial-placeholder-text="Klicken Sie auf 'Neu laden', um nach inaktiven Benutzern zu suchen." no-results-placeholder-text="Keine Treffer für den aktuellen Filter gefunden.">
<tt-data-table
:items="visibleFilteredUsers"
:is-loading="isLoading"
:has-searched="hasSearched"
initial-placeholder-icon="fa-duotone fa-play-circle"
initial-placeholder-text="Klicken Sie auf 'Neu laden', um nach inaktiven Benutzern zu suchen."
no-results-placeholder-text="Keine Treffer für den aktuellen Filter gefunden."
>
<template #loading-placeholder>
<div class="table-placeholder">
<i class="fa-duotone fa-hourglass-half animated-hourglass" style="font-size: 36px; margin-bottom: 10px; color: var(--brand-blue);"></i>
@@ -17,31 +37,129 @@ Vue.component('radius-unused-users', {
<div class="muted small">Dies kann einen Moment dauern, da große Datenmengen analysiert werden.</div>
</div>
</template>
<template #head><thead><tr><th style="width: 130px;">Kundennummer</th><th style="width: 170px;">Username</th><th style="width: 170px;">Letzter Login</th><th>Info</th><th style="width: 100px; text-align: right;">Sessions</th><th style="width: 150px; text-align: right;">Dauer</th><th style="width: 150px; text-align: right;">Traffic</th></tr></thead></template>
<template #skeleton-row><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td></template>
<template #row="{ item }">
<td><a v-if="item.customerNumber" class="link" target="_blank" :href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber" data-tooltip="Kunden öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a></td>
<td class="nowrap"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.username" data-tooltip="User in Radius öffnen">{{ item.username }}</a></td>
<td class="mono small">{{ item.lastLogin }}</td><td class="mono clamp-2 small">{{ item.info }}</td>
<td style="text-align: right;">{{ item.totalSessions }}</td>
<td style="text-align: right;">{{ window.RadiusUtils.formatDuration(item.totalDurationSeconds) }}</td>
<td style="text-align: right;">{{ window.RadiusUtils.formatBytes(item.totalTrafficBytes) }}</td>
<template #head>
<thead>
<tr>
<th style="width: 130px;">Kundennummer</th>
<th style="width: 170px;">Username</th>
<th style="width: 170px;">Letzter Login</th>
<th>Info</th>
<th style="width: 100px; text-align: right;">Sessions</th>
<th style="width: 150px; text-align: right;">Dauer</th>
<th style="width: 150px; text-align: right;">Traffic</th>
</tr>
</thead>
</template>
<template #observer><div ref="sentinel" style="height: 1px;"></div></template>
</radius-table-view>
<div v-if="hasSearched && !isLoading && filteredUsers.length" class="results-summary">{{ filteredUsers.length }} Treffer gefunden</div>
<template #skeleton-row>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
</template>
<template #row="{ item }">
<td>
<a v-if="item.customerNumber" class="link" target="_blank"
:href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber"
data-tooltip="Kunden öffnen"
data-tooltip-align="right">{{ item.customerNumber }}</a>
</td>
<td class="nowrap">
<a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + item.username"
data-tooltip="User in Radius öffnen">{{ item.username }}</a>
</td>
<td class="mono small">{{ item.lastLogin }}</td>
<td class="mono clamp-2 small">{{ item.info }}</td>
<td style="text-align: right;">{{ item.totalSessions }}</td>
<td style="text-align: right;">{{ window.TT_CORE.formatDuration(item.totalDurationSeconds) }}</td>
<td style="text-align: right;">{{ window.TT_CORE.formatBytes(item.totalTrafficBytes) }}</td>
</template>
<template #observer>
<div ref="sentinel" style="height: 1px;"></div>
</template>
</tt-data-table>
<div v-if="hasSearched && !isLoading && filteredUsers.length" class="results-summary">
{{ filteredUsers.length }} Treffer gefunden
</div>
</div>
</div>
`,
data: () => ({ users: [], isLoading: false, _initialized: false, hasSearched: false, window: window, visibleCount: 50, observer: null, activeFilter: 'all', filters: [{id: 'all', name:'Alle', icon:'fa-duotone fa-globe'},{id:'nat', name:'NAT*', icon:'fa-duotone fa-users'},{id:'st', name:'ST*', icon:'fa-duotone fa-server'},{id:'stf', name:'STF*', icon:'fa-duotone fa-id-card-clip'}] }),
computed: { filteredUsers() { return this.activeFilter === 'all' ? this.users : this.users.filter(u => u.username && u.username.startsWith(this.activeFilter)); }, visibleFilteredUsers() { return this.filteredUsers.slice(0, this.visibleCount); } },
mounted() { this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); },
beforeDestroy() { if (this.observer) this.observer.disconnect(); },
updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } },
data: () => ({
users: [],
isLoading: false,
_initialized: false,
hasSearched: false,
window: window,
visibleCount: 50,
observer: null,
activeFilter: 'all',
filters: [
{id: 'all', name:'Alle', icon:'fa-duotone fa-globe'},
{id:'nat', name:'NAT*', icon:'fa-duotone fa-users'},
{id:'st', name:'ST*', icon:'fa-duotone fa-server'},
{id:'stf', name:'STF*', icon:'fa-duotone fa-id-card-clip'}
]
}),
computed: {
filteredUsers() {
return this.activeFilter === 'all'
? this.users
: this.users.filter(u => u.username && u.username.startsWith(this.activeFilter));
},
visibleFilteredUsers() {
return this.filteredUsers.slice(0, this.visibleCount);
}
},
mounted() {
this.observer = new IntersectionObserver(([e]) => {
if (e && e.isIntersecting) this.loadMore();
}, { root: this.$refs.tableWrap, threshold: 0.1 });
if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
},
beforeUnmount() {
if (this.observer) this.observer.disconnect();
},
updated() {
if (this.observer && this.$refs.sentinel) {
this.observer.disconnect();
this.observer.observe(this.$refs.sentinel);
}
},
methods: {
initIfNeeded() { if (this._initialized) return; this._initialized = true; },
setFilter(filter) { this.activeFilter = filter; this.visibleCount = 50; },
async fetchUnusedUsers() { this.isLoading = true; this.hasSearched = true; this.visibleCount = 50; this.users = []; try { const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=reportUnused`); this.users = res.ok ? (await res.json() || []) : []; } catch (e) { console.error("Failed to fetch unused users:", e); this.users = []; } this.isLoading = false; },
loadMore() { if (this.visibleCount < this.filteredUsers.length) this.visibleCount += 50; }
initIfNeeded() {
if (this._initialized) return;
this._initialized = true;
},
setFilter(filter) {
this.activeFilter = filter;
this.visibleCount = 50;
},
async fetchUnusedUsers() {
this.isLoading = true;
this.hasSearched = true;
this.visibleCount = 50;
this.users = [];
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'reportUnused' }
});
this.users = data || [];
} catch (error) {
console.error("Failed to fetch unused users:", error);
this.users = [];
}
this.isLoading = false;
},
loadMore() {
if (this.visibleCount < this.filteredUsers.length) this.visibleCount += 50;
}
}
});
};
// Register component with Vue 3 app
if (window.VueApp) {
window.VueApp.component('radius-unused-users', RadiusUnusedUsers);
}
+422 -137
View File
@@ -1,138 +1,423 @@
/* ===== RadiusUsers.js ===== */
Vue.component('radius-users', {
    template: `
      <div class="radius-scope">
        <div class="filters-layout">
          <div class="field"><radius-autocomplete v-model="billAddrDisplay" :wide="true" placeholder="Kunde suchen" @select="onAddrSelect" @enter="loadRadiusUsers" @mode-change="onModeChange"/></div>
          <div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span><i class="fa-duotone fa-user input-icon"></i><input class="ri" v-model="username" placeholder="Username" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="text" name="radius-username"/></div></div>
          <div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span><div class="input-wrap"><i class="fa-duotone fa-network-wired input-icon"></i><input class="ri" v-model="ip" placeholder="IP-Adresse" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="decimal" name="radius-ip"/></div></div></div>
          <div class="field"><div class="input-wrap"><i class="fa-duotone fa-note-sticky input-icon"></i><input class="ri" v-model="info" placeholder="Info" @keydown.enter="loadRadiusUsers"/></div></div>
          <div class="cluster" style="gap: 8px;">
            <div class="field"><label class="switch-field"><span class="mini muted">Online-Status</span><span class="switch"><input type="checkbox" v-model="checkOnlineState"><span class="switch-track"><i class="fa-duotone fa-signal-bars-good on"></i><i class="fa-duotone fa-signal-bars-slash off"></i></span></span></label></div>
            <button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;"><span v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span><span v-else class="btn-loader"></span></button>
            <button class="danger-btn" @click="clearFilters" :disabled="!hasFilters" data-tooltip="Eingaben leeren" data-tooltip-align="left"><i class="fa-duotone fa-xmark"></i></button>
          </div>
        </div>
        <div class="results-container mt-between">
          <radius-table-view :items="visibleUsers" :is-loading="isLoading" :has-searched="hasSearched" :skeleton-row-count="6" initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben.">
            <template #head><thead><tr><th style="text-align: center; width: 170px;">Kundennummer</th><th style="text-align: center; width: 183px;">Username</th><th style="text-align: center">Info</th><th style="text-align: center; width: 190px;">Status</th><th style="text-align: center; width: 115px;">Aktionen</th></tr></thead></template>
            <template #skeleton-row><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line" style="height: 36px;"></div></td></template>
            <template #row="{ item }">
              <td><a class="link" target="_blank" :href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber" data-tooltip="Kunden in neuem Tab öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a></td>
              <td class="nowrap"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.username" data-tooltip="User in Radius öffnen" data-tooltip-align="right">{{ item.username }}</a><button class="icon-btn sm" data-tooltip="Kopieren" data-tooltip-align="right" @click="copy(item.username, $event)"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></td>
              <td class="mono clamp-2">{{ item.info }}</td>
              <td><radius-online-state v-if="checkOnlineState" :username="item.username" :key="item.username + '_'+searchCount"/></td>
              <td class="nowrap cluster" style="gap: 4px; justify-content: center;"><button class="ghost-btn" @click="fetchRadacctData(item.username)" data-tooltip="Details"><i class="fa-duotone fa-circle-info"></i></button><button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik" data-tooltip-align="left"><i class="fa-duotone fa-chart-line"></i></button></td>
            </template>
            <template #observer><div ref="sentinel" style="height: 1px;"></div></template>
          </radius-table-view>
          <div v-if="hasSearched" class="results-summary"><span v-if="isLoading">Suche läuft...</span><span v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span></div>
        </div>
        <radius-modal :show="showRadacctModal" title="RADIUS Daten" @close="showRadacctModal=false">
          <div class="kv-redesign">
            <div class="kv-row"><span class="kv-label">Status</span><div class="kv-value"><div v-if="radacctData"><strong class="chip" :class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong></div><div v-else><div class="skeleton-line" style="width: 80px; --h: 24px; margin-left: auto;"></div></div></div></div>
                        <div class="kv-row"><span class="kv-label">IP</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><a v-if="radacctData.ip" class="link" @click="scanIp(radacctData.ip)" data-tooltip="IP-Scan starten" data-tooltip-align="right" style="cursor: pointer;"><code>{{ radacctData.ip }}</code></a><code v-else>—</code><button v-if="radacctData.ip" class="icon-btn sm" @click="copy(radacctData.ip, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 120px; margin-left: auto;"></div></div></div></div>
                        <div class="kv-row"><span class="kv-label">Username</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username" data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a><button class="icon-btn sm" @click="copy(radacctData.username, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 150px; margin-left: auto;"></div></div></div></div>
            <template v-if="radacctData"><div class="kv-row"><span class="kv-label">Kundennummer</span><code class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><code class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div><div class="kv-row"><span class="kv-label">Bandbreite</span><code class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div></template>
            <template v-else><div class="kv-row"><span class="kv-label">Kundennummer</span><div class="kv-value"><div class="skeleton-line" style="width: 70px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value"><div class="skeleton-line" style="width: 200px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value"><div class="skeleton-line" style="--h:14px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><div class="kv-value"><div class="skeleton-line" style="width: 100px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Bandbreite</span><div class="kv-value"><div class="skeleton-line" style="width: 180px; margin-left: auto;"></div></div></div></template>
          </div>
        </radius-modal>
        <radius-modal :show="showTransferModal" :title="'Transfer Statistik für ' + transferModalUsername" @close="closeTransferModal" modal-class="modal-card-wide">
          <div class="modal-body-scrollable">
            <div v-if="transferYearlyData || transferInitialLoading">
              <div class="unselectable">
                <div class="cluster" style="justify-content: space-between; margin-bottom: 16px; flex-wrap: nowrap; margin-top: 4px; padding-left: 8px;">
                  <div class="cluster"><div class="custom-dropdown"><button class="dropdown-toggle" @click="!transferInitialLoading && (showYearDropdown = !showYearDropdown)" :class="{'is-open': showYearDropdown}"><span>{{ transferYear }}</span><i class="fa-solid fa-chevron-down"></i></button><transition name="ac-pop"><div v-if="showYearDropdown" class="dropdown-panel"><div v-for="y in availableYears" :key="y" class="dropdown-item" @click="selectYear(y)">{{ y }}</div></div></transition></div><div class="cluster" style="gap: 4px;"><button v-for="m in allMonths" :key="m.month" class="tab-btn" :class="{active: transferMonth === m.month}" :disabled="isMonthDisabled(m.month)" @click="changeTransferMonth(m.month)">{{ m.name }}</button></div></div>
                  <div class="cluster" style="gap: 16px;"><div class="muted small mono" style="text-align: right; flex-shrink: 0;">Gesamt {{ transferYear }}:<br><strong v-if="transferInitialLoading || !transferYearlyData"><div class="skeleton-line" style="width: 110px; height: 16px; margin-left:auto;"></div></strong><strong v-else>{{ window.RadiusUtils.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}</strong></div><button class="ghost-btn" @click="prepareEmailModal" :disabled="transferInitialLoading || !transferYearlyData || !transferYearlyData.yearlySummary || transferYearlyData.yearlySummary.grandTotalBytes === 0" data-tooltip="Statistik per E-Mail senden" data-tooltip-align="bottom-left" data-tooltip-wrap="true"><i class="fa-duotone fa-paper-plane"></i></button></div>
                </div>
                <div class="grid g-4 cols-4"><div class="stat-card-v2 stat-total"><div class="stat-icon"><i class="fa-duotone fa-grid-2"></i></div><div><div class="stat-label">Monat gesamt</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-download"><div class="stat-icon"><i class="fa-duotone fa-arrow-down-to-line"></i></div><div><div class="stat-label">Download</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-upload"><div class="stat-icon"><i class="fa-duotone fa-arrow-up-from-line"></i></div><div><div class="stat-label">Upload</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-duration"><div class="stat-icon"><i class="fa-duotone fa-hourglass-clock"></i></div><div><div class="stat-label">Dauer</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 80px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span></div></div></div></div>
                <div class="chart-card mt-3" style="height: 250px;"><div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder"><div class="skeleton-line" style="width: 100%; height: 100%; border-radius: var(--radius);"></div></div><div v-else-if="!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length" class="chart-placeholder"><i class="fa-duotone fa-chart-pie"></i><span>Keine Daten in diesem Monat verfügbar</span></div><canvas v-show="!transferMonthlyLoading && !transferInitialLoading && transferMonthlyData?.details?.length" ref="transferChartCanvas"></canvas></div>
              </div>
              <div class="table-wrap mt-3" style="height: 350px;">
                <div v-if="!transferInitialLoading && !transferMonthlyLoading && (!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length)" class="table-placeholder-fixed-height"><i class="fa-duotone fa-database"></i><span>Keine detaillierten Daten für diesen Monat.</span></div>
                <table v-else class="tt-table compact"><thead><tr><th>Startzeit</th><th>Dauer</th><th>IP-Adresse</th><th style="text-align: right;">Download</th><th style="text-align: right;">Upload</th><th style="text-align: right;">Gesamt</th></tr></thead><tbody><template v-if="transferInitialLoading || transferMonthlyLoading"><tr v-for="n in 10" :key="'skel'+n"><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td></tr></template><template v-else><tr v-for="(d, i) in transferMonthlyData.details" :key="i"><td class="mono small">{{ d.startTime }}</td><td class="mono small">{{ window.RadiusUtils.formatDuration(d.durationSeconds) }}</td><td class="mono small">{{ d.ipAddress }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.downloadBytes) }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.uploadBytes) }}</td><td class="mono small" style="text-align: right;"><strong>{{ window.RadiusUtils.formatBytes(d.totalBytes) }}</strong></td></tr></template></tbody></table>
              </div>
            </div>
            <div v-else-if="!transferInitialLoading" class="table-placeholder" style="min-height: 400px;"><i class="fa-duotone fa-wifi-slash"></i><div>Daten konnten nicht geladen werden.</div></div>
          </div>
        </radius-modal>
        <radius-modal :show="showEmailModal" title="Statistik per E-Mail senden" @close="showEmailModal=false">
          <div>
            <div class="field"><label style="margin-bottom: 8px; font-size: 14px;">Empfänger-E-Mail</label><div class="input-wrap"><i class="fa-duotone fa-envelope input-icon"></i><input class="ri" type="email" v-model.trim="recipientEmail" placeholder="name@domain.com" @keydown.enter="isValidEmail && !isSendingEmail && sendTransferEmail()" autocomplete="nope" data-1p-ignore data-lpignore="true" data-form-type="other" data-bwignore></div><p v-if="recipientEmail && !isValidEmail" class="muted small" style="color: var(--bad); margin-top: 4px;">Bitte geben Sie eine gültige E-Mail-Adresse ein.</p></div>
            <div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;"><button class="ghost-btn" @click="showEmailModal=false" :disabled="isSendingEmail">Abbrechen</button><button class="primary-btn" @click="sendTransferEmail" :disabled="!isValidEmail || isSendingEmail" style="min-width: 100px;"><span v-if="!isSendingEmail">Senden</span><span v-else class="btn-loader"></span></button></div>
          </div>
        </radius-modal>
      </div>
    `,
    data: () => ({ window: window, billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '', searchMode: 'autocomplete', radiusUsers: [], checkOnlineState: false, isLoading: false, showRadacctModal: false, radacctData: null, searchCount: 0, hasSearched: false, visibleCount: 50, observer: null, showTransferModal: false, transferInitialLoading: false, transferMonthlyLoading: false, transferModalUsername: '', transferYear: new Date().getFullYear(), transferMonth: new Date().getMonth() + 1, transferYearlyData: null, transferMonthlyData: null, transferChartInstance: null, showYearDropdown: false, isSendingEmail: false, showEmailModal: false, recipientEmail: '' }),
    computed: {
        hasFilters() { return this.billAddrDisplay || this.username || this.ip || this.info; },
        visibleUsers() { return this.radiusUsers.slice(0, this.visibleCount); },
        availableYears() { const c = new Date().getFullYear(), s = 2021; if (s > c) return [c]; return Array.from({length: c - s + 1}, (_, i) => c - i); },
        allMonths() { return Array.from({ length: 12 }, (_, i) => ({ month: i + 1, name: new Date(2000, i, 1).toLocaleString('de-DE', { month: 'short' }) })); },
        isValidEmail() { if (!this.recipientEmail) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); }
    },
    mounted() {
        const urlParams = new URLSearchParams(window.location.search);
        const infoParam = urlParams.get('info');
        if (infoParam) {
            this.info = infoParam;
            this.loadRadiusUsers();
        }
        this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 });
        if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
    },
    beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); },
    updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } },
    methods: {
        onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; },
        onModeChange(newMode) { this.searchMode = newMode; },
        async loadRadiusUsers() { this.isLoading = true; this.radiusUsers = []; this.hasSearched = true; this.visibleCount = 50; try { const p = new URLSearchParams({ username: this.username || '', info: this.info || '', ip: this.ip || '' }); if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); if (r.ok) { const u = await r.json(); if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; this.radiusUsers = Array.isArray(u) ? u : []; } } catch (e) { console.error(e); } this.isLoading = false; this.searchCount++; },
        async fetchRadacctData(username) { this.showRadacctModal = true; this.radacctData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); if (r.ok) this.radacctData = await r.json(); } catch (e) { console.error(e); this.radacctData = {}; } },
        async copy(text, event) { if (!event || !event.currentTarget) return; const btn = event.currentTarget; if (btn.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(text); btn.classList.add('is-copied'); btn.disabled = true; setTimeout(() => { btn.classList.remove('is-copied'); btn.disabled = false; }, 1500); },
        clearFilters() { this.billAddrDisplay = ''; this.billAddrCustnum = ''; this.username = ''; this.ip = ''; this.info = ''; this.radiusUsers = []; this.hasSearched = false; this.searchCount++; this.visibleCount = 50; },
        loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; },
        // ===== NEW METHOD =====
        async scanIp(ip) {
            if (!ip) return;
            if (window.notify) {
                window.notify('info', `Starte Scan für ${ip}...`);
            }
            try {
                const response = await fetch(`http://localhost:8094/scan?ip=${ip}`);
                if (!response.ok) {
                    throw new Error(`HTTP-Fehler! Status: ${response.status}`);
                }
                const data = await response.json();
/* ===== RadiusUsers.js (Vue 3 + TT-Core) ===== */
                if (data.status === 'success' && data.url) {
                    window.open(data.url, '_blank');
                } else if (data.status === 'not_found') {
                    if (window.notify) {
                        window.notify('warning', `Kein Gerät für ${ip} gefunden.`);
                    }
                } else {
                    throw new Error('Ungültige oder unerwartete Antwort vom Scan-Server.');
                }
            } catch (error) {
                console.error('IP-Scan fehlgeschlagen:', error);
                if (window.notify) {
                    window.notify('error', `Scan für ${ip} fehlgeschlagen.`);
                }
            }
        },
        // ===== END NEW METHOD =====
        async openTransferModal(username) { this.showTransferModal = true; this.transferModalUsername = username; this.transferYear = new Date().getFullYear(); this.transferMonth = new Date().getMonth() + 1; await this.fetchTransferYearData(); },
        closeTransferModal() { this.showTransferModal = false; this.transferModalUsername = ''; this.transferYearlyData = null; this.transferMonthlyData = null; this.showYearDropdown = false; this.showEmailModal = false; this.recipientEmail = ''; this.isSendingEmail = false; if (this.transferChartInstance) { this.transferChartInstance.destroy(); this.transferChartInstance = null; } },
        async fetchTransferYearData() { this.transferInitialLoading = true; this.transferYearlyData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); if (r.ok) { const d = await r.json(); if(d && d.monthlySummary) { this.transferYearlyData = d; const last = [...d.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); this.transferMonth = last ? last.month : new Date().getMonth() + 1; await this.fetchTransferMonthData(); }} else this.transferYearlyData = null; } catch (e) { console.error(e); this.transferYearlyData = null; } this.transferInitialLoading = false; },
        async fetchTransferMonthData() { this.transferMonthlyLoading = true; this.transferMonthlyData = null; if (this.transferChartInstance) this.transferChartInstance.destroy(); try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); this.transferMonthlyData = r.ok ? await r.json() : null; } catch (e) { console.error(e); this.transferMonthlyData = null; } this.transferMonthlyLoading = false; this.$nextTick(() => { if(this.showTransferModal) this.renderTransferChart(); }); },
        prepareEmailModal() { if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; this.recipientEmail = ''; this.showEmailModal = true; },
        async sendTransferEmail() { if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; this.isSendingEmail = true; try { const chartImageBase64 = this.transferChartInstance.toBase64Image(); const payload = { username: this.transferModalUsername, year: this.transferYear, month: this.transferMonth, monthlySummary: this.transferMonthlyData.summary, monthlyDetails: this.transferMonthlyData.details, chartImage: chartImageBase64, recipient: this.recipientEmail }; const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { if (window.notify) window.notify('success', 'E-Mail wurde erfolgreich versendet.'); this.showEmailModal = false; } else { throw new Error('Server responded with an error.'); } } catch (e) { console.error("Failed to send transfer email:", e); if (window.notify) window.notify('error', 'Fehler beim Senden der E-Mail.'); } finally { this.isSendingEmail = false; } },
        isMonthDisabled(month) { if (this.transferInitialLoading || this.transferMonthlyLoading) return true; if (!this.transferYearlyData?.monthlySummary) return true; const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); return !m || m.grandTotalBytes === 0; },
        selectYear(year) { this.showYearDropdown = false; if (this.transferYear !== year) this.changeTransferYear(year); },
        async changeTransferYear(year) { this.transferYear = year; await this.fetchTransferYearData(); },
        async changeTransferMonth(month) { this.transferMonth = month; await this.fetchTransferMonthData(); },
        processChartData(details) { if (!details || !details.length) return { labels: [], datasets: [] }; const daily = details.reduce((a, s) => { const d = s.startTime.split(' ')[0]; if (!a[d]) a[d] = { downloadBytes: 0, uploadBytes: 0 }; a[d].downloadBytes += Number(s.downloadBytes) || 0; a[d].uploadBytes += Number(s.uploadBytes) || 0; return a; }, {}); const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); return { labels: dates, datasets: [ { label: 'Download', data: dates.map(d => daily[d].downloadBytes), borderColor: 'rgba(15, 157, 88, 0.8)', backgroundColor: 'rgba(15, 157, 88, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 }, { label: 'Upload', data: dates.map(d => daily[d].uploadBytes), borderColor: 'rgba(0, 83, 132, 0.8)', backgroundColor: 'rgba(0, 83, 132, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 } ] }; },
        renderTransferChart() { if (this.transferChartInstance) this.transferChartInstance.destroy(); if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; const d = this.processChartData(this.transferMonthlyData.details); if (!d.labels.length) return; const chartBackgroundColorPlugin = { id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }; this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { type: 'line', data: d, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: { unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: { day: 'DD.MM' } }, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } }, y: { beginAtZero: true, ticks: { callback: (v) => window.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); }
    }
});
const RadiusUsers = {
name: 'RadiusUsers',
template: `
<div class="tt-scope">
<div class="filters-layout">
<div class="field">
<tt-smart-autocomplete
v-model="billAddrDisplay"
:wide="true"
placeholder="Kunde suchen"
@select="onAddrSelect"
@enter="loadRadiusUsers"
@mode-change="onModeChange"
/>
</div>
<div class="field">
<div class="input-wrap ip-field-wrapper">
<span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span>
<i class="fa-duotone fa-user input-icon"></i>
<input
class="ri"
v-model="username"
placeholder="Username"
@keydown.enter="loadRadiusUsers"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
inputmode="text"
name="radius-username"
/>
</div>
</div>
<div class="field">
<div class="input-wrap ip-field-wrapper">
<span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span>
<div class="input-wrap">
<i class="fa-duotone fa-network-wired input-icon"></i>
<input
class="ri"
v-model="ip"
placeholder="IP-Adresse"
@keydown.enter="loadRadiusUsers"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
inputmode="decimal"
name="radius-ip"
/>
</div>
</div>
</div>
<div class="field">
<div class="input-wrap">
<i class="fa-duotone fa-note-sticky input-icon"></i>
<input
class="ri"
v-model="info"
placeholder="Info"
@keydown.enter="loadRadiusUsers"
/>
</div>
</div>
<div class="cluster" style="gap: 8px;">
<div class="field">
<label class="switch-field">
<span class="mini muted">Online-Status</span>
<span class="switch">
<input type="checkbox" v-model="checkOnlineState">
<span class="switch-track">
<i class="fa-duotone fa-signal-bars-good on"></i>
<i class="fa-duotone fa-signal-bars-slash off"></i>
</span>
</span>
</label>
</div>
<button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;">
<span v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span>
<span v-else class="btn-loader"></span>
</button>
<button
class="danger-btn"
@click="clearFilters"
:disabled="!hasFilters"
data-tooltip="Eingaben leeren"
data-tooltip-align="left"
>
<i class="fa-duotone fa-xmark"></i>
</button>
</div>
</div>
<div class="results-container mt-between">
<tt-data-table
:items="visibleUsers"
:is-loading="isLoading"
:has-searched="hasSearched"
:skeleton-row-count="6"
initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben."
>
<template #head>
<thead>
<tr>
<th style="text-align: center; width: 170px;">Kundennummer</th>
<th style="text-align: center; width: 183px;">Username</th>
<th style="text-align: center">Info</th>
<th style="text-align: center; width: 190px;">Status</th>
<th style="text-align: center; width: 180px;">Aktionen</th>
</tr>
</thead>
</template>
<template #skeleton-row>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton /></td>
<td><tt-skeleton height="36px" /></td>
</template>
<template #row="{ item }">
<td>
<a class="link" target="_blank"
:href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber"
data-tooltip="Kunden in neuem Tab öffnen"
data-tooltip-align="right"
>{{ item.customerNumber }}</a>
</td>
<td class="nowrap">
<a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + item.username"
data-tooltip="User in Radius öffnen"
data-tooltip-align="right"
>{{ item.username }}</a>
<tt-copy-button :text="item.username" tooltip-align="right" />
</td>
<td class="mono clamp-2">{{ item.info || '—' }}</td>
<td>
<tt-status-chip
v-if="checkOnlineState"
:username="item.username"
:key="item.username + '_'+searchCount"
@scan-ip="onScanIp($event, item)"
/>
</td>
<td class="nowrap cluster" style="gap: 4px; justify-content: center;">
<button class="ghost-btn" @click="openRadacctModal(item.username)" data-tooltip="Details">
<i class="fa-duotone fa-circle-info"></i>
</button>
<button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik"
data-tooltip-align="left">
<i class="fa-duotone fa-chart-line"></i>
</button>
<button class="ghost-btn" @click="openRouterManager(item)"
data-tooltip="Router Management" data-tooltip-align="left">
<i class="fa-duotone fa-router"></i>
</button>
</td>
</template>
<template #observer>
<div ref="sentinel" style="height: 1px;"></div>
</template>
</tt-data-table>
<div v-if="hasSearched" class="results-summary">
<span v-if="isLoading">Suche läuft...</span>
<span v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span>
</div>
</div>
<!-- Modals -->
<radius-radacct-modal
:show="showRadacctModal"
:username="selectedUsername"
@close="showRadacctModal = false"
/>
<radius-transfer-modal
:show="showTransferModal"
:username="selectedUsername"
@close="showTransferModal = false"
/>
<radius-router-manager
v-if="showRouterManager"
:show="showRouterManager"
:user-item="selectedUserItem"
@close="showRouterManager = false"
/>
<!-- Extension ID Modal (kept embedded as it's small) -->
<tt-dialog :show="showExtensionIdModal" title="Extension ID Konfigurieren" @close="showExtensionIdModal=false">
<div>
<div class="field">
<label style="margin-bottom: 8px; font-size: 14px;">Chrome Extension ID</label>
<div class="input-wrap">
<i class="fa-duotone fa-puzzle-piece input-icon"></i>
<input
class="ri"
type="text"
v-model.trim="extensionId"
placeholder="z.B. jglijfiddilckddlmbnlojmmlahboffh"
/>
</div>
</div>
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
<button class="primary-btn" @click="saveExtensionId">Speichern</button>
</div>
</div>
</tt-dialog>
</div>
`,
data: () => ({
window: window,
// Filters
billAddrDisplay: '',
billAddrCustnum: '',
username: '',
ip: '',
info: '',
searchMode: 'autocomplete',
// List Data
radiusUsers: [],
checkOnlineState: false,
isLoading: false,
searchCount: 0,
hasSearched: false,
visibleCount: 50,
observer: null,
// Modal State
selectedUsername: '',
selectedUserItem: null,
showRadacctModal: false,
showTransferModal: false,
showRouterManager: false,
// Extension Config
showExtensionIdModal: false,
extensionId: 'jglijfiddilckddlmbnlojmmlahboffh'
}),
computed: {
hasFilters() {
return this.billAddrDisplay || this.username || this.ip || this.info;
},
visibleUsers() {
return this.radiusUsers.slice(0, this.visibleCount);
}
},
mounted() {
const urlParams = new URLSearchParams(window.location.search);
const infoParam = urlParams.get('info');
if (infoParam) {
this.info = infoParam;
this.loadRadiusUsers();
}
this.observer = new IntersectionObserver(([e]) => {
if (e && e.isIntersecting) this.loadMore();
}, {root: this.$refs.tableWrap, threshold: 0.1});
if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel);
const savedExtensionId = localStorage.getItem('radiusExtensionId');
if (savedExtensionId) {
this.extensionId = savedExtensionId;
}
window.addEventListener('keydown', this.handleKeydown);
},
beforeUnmount() {
if (this.observer) this.observer.disconnect();
window.removeEventListener('keydown', this.handleKeydown);
},
updated() {
if (this.observer && this.$refs.sentinel) {
this.observer.disconnect();
this.observer.observe(this.$refs.sentinel);
}
},
methods: {
handleKeydown(e) {
if (e.code === 'KeyE' && e.ctrlKey && e.altKey) {
e.preventDefault();
this.showExtensionIdModal = true;
}
},
saveExtensionId() {
localStorage.setItem('radiusExtensionId', this.extensionId);
this.showExtensionIdModal = false;
window.notify('success', 'Extension ID gespeichert.');
},
onAddrSelect({custnum, display}) {
this.billAddrCustnum = custnum || '';
this.billAddrDisplay = display || '';
},
onModeChange(newMode) {
this.searchMode = newMode;
},
async loadRadiusUsers() {
this.isLoading = true;
this.radiusUsers = [];
this.hasSearched = true;
this.visibleCount = 50;
try {
const params = {
username: this.username || '',
info: this.info || '',
ip: this.ip || ''
};
if (this.searchMode === 'text') params.estmk_nr = this.billAddrDisplay || '';
else params.custnum = this.billAddrCustnum || '';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, { params });
if (Array.isArray(data) && data.length < 6) this.checkOnlineState = true;
this.radiusUsers = Array.isArray(data) ? data : [];
} catch (e) {
console.error(e);
}
this.isLoading = false;
const urlParams = new URLSearchParams(window.location.search);
const connectParam = urlParams.get('connect');
if (connectParam && connectParam.toLowerCase() === 'true' && this.radiusUsers.length === 1) {
try {
const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
params: { action2: 'fetchRadacct', username: this.radiusUsers[0].username }
});
if (radacct?.ip) {
this.onScanIp({ip: radacct.ip}, this.radiusUsers[0]);
}
} catch (error) {
console.error('Error fetching radacct:', error);
}
}
this.searchCount++;
},
clearFilters() {
this.billAddrDisplay = '';
this.billAddrCustnum = '';
this.username = '';
this.ip = '';
this.info = '';
this.radiusUsers = [];
this.hasSearched = false;
this.searchCount++;
this.visibleCount = 50;
},
loadMore() {
if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50;
},
// Modal Openers
openRadacctModal(username) {
this.selectedUsername = username;
this.showRadacctModal = true;
},
openTransferModal(username) {
this.selectedUsername = username;
this.showTransferModal = true;
},
openRouterManager(item) {
this.selectedUserItem = item;
this.showRouterManager = true;
},
// IP Scan Logic (Must remain in parent or be extracted to service, kept here for table interaction)
async onScanIp(payload, item) {
const {ip} = payload;
const info = item.info;
if (!ip) return;
window.notify('info', `Starte Scan für ${ip}...`);
try {
const { data } = await axios.get(`http://localhost:8094/scan`, {
params: { ip }
});
if (data.status === 'success' && data.url) {
const extensionId = this.extensionId;
const message = {
type: "INITIATE_ROUTER_LOGIN",
payload: {
ip: ip,
url: data.url,
info: info
}
};
if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) {
try {
chrome.runtime.sendMessage(extensionId, message, (response) => {
if (chrome.runtime.lastError) {
console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message);
window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden. (Drücke STRG + ALT + E zum Konfigurieren)');
} else {
console.log("Erweiterung hat geantwortet:", response);
}
});
} catch (e) {
console.error("Fehler beim Senden an die Erweiterung:", e);
window.notify('error', 'Fehler beim Senden an die Erweiterung.');
}
} else {
console.warn("Chrome Extension Messaging API nicht verfügbar.");
window.notify('warning', 'Chrome Messaging API nicht gefunden.');
}
} else if (data.status === 'not_found') {
window.notify('warning', `Kein Gerät für ${ip} gefunden.`);
} else {
window.notify('error', 'Ungültige Antwort vom Scan-Server.');
}
} catch (error) {
console.error('IP-Scan fehlgeschlagen:', error);
window.notify('error', `Scan für ${ip} fehlgeschlagen.`);
}
}
}
};
if (window.VueApp) {
window.VueApp.component('radius-users', RadiusUsers);
}
@@ -1,43 +1,546 @@
/* Main card margin */
#app > .card {
margin-top: 1rem;
}
/* Reduce button margin */
#app > .card > .card-body > .mb-3 {
margin-bottom: 0.5rem !important;
}
/* End of Life Row Highlighting */
.end-of-life {
background-color: #f8d7da !important;
}
.warehouse-article-prices > div,
.warehouse-article-distributor > div {
/* Last Edited Row Highlighting */
.last-edited-row {
background-color: #fff3cd !important;
animation: highlight-fade 5s ease-out forwards;
}
@keyframes highlight-fade {
0% {
background-color: #fff3cd;
}
70% {
background-color: #fff3cd;
}
100% {
background-color: transparent;
}
}
/*
* Modal Layout
*/
.modal-dialog.modal-xl .modal-body {
overflow-x: hidden;
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.modal-dialog.modal-xl .modal-content {
max-height: calc(100vh - 50px);
}
/* Disabled checkbox styling */
.wa-checkbox-item.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Disabled form controls styling */
.wa-modal-content .form-control:disabled,
.wa-modal-content .form-control[disabled],
.wa-modal-content textarea:disabled,
.wa-modal-content textarea[disabled] {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
}
.wa-modal-content .tt-select-modern.disabled .tt-select-trigger,
.wa-modal-content .tt-select-trigger[disabled],
.wa-modal-content .tt-select-trigger.disabled {
background-color: #e9ecef !important;
cursor: not-allowed !important;
opacity: 0.7;
pointer-events: none;
}
.wa-modal-content .form-group.disabled {
opacity: 0.7;
pointer-events: none;
}
/* Disabled field styling */
.wa-field-disabled {
opacity: 0.5;
position: relative;
}
.wa-field-disabled::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: not-allowed;
z-index: 5;
}
.wa-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: hidden;
max-width: 100%;
}
.wa-modal-content .row {
margin-left: 0;
margin-right: 0;
}
.wa-modal-content .row > [class*="col-"] {
padding-left: 8px;
padding-right: 8px;
}
.wa-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-section-title {
font-size: 0.9rem;
font-weight: 600;
color: #495057;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #dee2e6;
}
/*
* Article Prices Section - Dense 2-Column Layout
*/
.wa-prices-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-prices-grid-dense {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)) 120px;
gap: 12px;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.wa-price-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
transition: box-shadow 0.15s ease;
}
.wa-price-item:hover {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.wa-price-header {
display: flex;
align-items: center;
padding-bottom: 6px;
border-bottom: 1px solid #e9ecef;
margin-bottom: 8px;
}
.warehouse-article-prices .form-group,
.warehouse-article-distributor .form-group {
.wa-price-body {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 6px;
align-items: center;
}
.wa-price-actions {
display: flex;
align-items: center;
gap: 3px;
}
.wa-price-actions .btn {
padding: 4px 8px;
font-size: 0.75rem;
min-width: 32px;
}
.wa-price-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/*
* Section Title with Action Button
*/
.wa-section-title-with-action {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin: -12px -12px 12px -12px;
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
border-bottom: 1px solid #e9ecef;
border-radius: 6px 6px 0 0;
}
/*
* Distributor Section - With Modal Directory
*/
.wa-distributors-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-directory-header {
display: flex;
align-items: center;
padding: 0;
}
.wa-directory-list {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.wa-directory-group {
margin-bottom: 12px;
}
.wa-directory-group:last-child {
margin-bottom: 0;
}
@media (max-width: 992px) {
.warehouse-article-prices > div,
.warehouse-article-distributor > div {
display: block;
border: 1px solid #eee;
padding: 10px;
.wa-directory-letter {
font-size: 0.9rem;
font-weight: 700;
color: #007bff;
padding: 4px 8px;
background: #e7f3ff;
border-left: 3px solid #007bff;
margin-bottom: 6px;
}
.wa-directory-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 6px;
padding-left: 8px;
}
.wa-directory-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: white;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
color: #495057;
}
.wa-directory-item:hover:not(:disabled) {
background: #007bff;
border-color: #007bff;
color: white;
}
.wa-directory-item.active {
background: #28a745;
border-color: #28a745;
color: white;
cursor: default;
}
.wa-directory-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wa-distributors-grid {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.wa-distributor-row {
display: grid;
grid-template-columns: 180px 1fr auto;
gap: 10px;
align-items: center;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
transition: box-shadow 0.15s ease;
}
.wa-distributor-row:hover {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.wa-distributor-name {
font-size: 0.8rem;
color: #495057;
font-weight: 500;
display: flex;
align-items: center;
}
.wa-distributor-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wa-distributor-actions {
display: flex;
align-items: center;
gap: 3px;
}
.wa-distributor-actions .btn {
padding: 4px 8px;
font-size: 0.75rem;
min-width: 32px;
}
.wa-distributor-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/*
* Custom Checkboxes - Similar to WorkorderMphBase
*/
.wa-checkbox-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 8px;
}
.wa-checkbox-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.wa-checkbox-item:hover {
background: #e9ecef;
border-color: #ced4da;
}
.wa-checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
}
.wa-checkmark {
position: relative;
height: 16px;
width: 16px;
min-width: 16px;
background-color: #fff;
border: 2px solid #adb5bd;
border-radius: 3px;
margin-right: 8px;
transition: all 0.2s ease;
}
.wa-checkbox-item input[type="checkbox"]:checked ~ .wa-checkmark {
background-color: #28a745;
border-color: #28a745;
}
.wa-checkmark:after {
content: "";
position: absolute;
display: none;
left: 4px;
top: 0px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.wa-checkbox-item input[type="checkbox"]:checked ~ .wa-checkmark:after {
display: block;
}
.wa-checkbox-label {
font-size: 0.8rem;
line-height: 1.2;
}
/*
* Form Controls - Dense Sizing (Desktop Only)
*/
@media (min-width: 768px) {
.wa-modal-content .form-group {
margin-bottom: 10px;
}
.warehouse-article-prices > div > *,
.warehouse-article-distributor > div > * {
display: block;
margin-bottom: 10px !important;
.wa-modal-content label {
font-size: 0.8rem;
font-weight: 500;
color: #495057;
margin-bottom: 3px;
}
.warehouse-article-prices > div > div:last-child,
.warehouse-article-distributor > div > div:last-child {
text-align: right;
margin-bottom: 0 !important;
.wa-modal-content .form-control-sm,
.wa-modal-content .tt-select-trigger.sm {
height: 30px;
font-size: 0.8rem;
padding: 4px 8px;
}
.warehouse-article-prices > div > div:last-child > *,
.warehouse-article-distributor > div > div:last-child > * {
margin-left: 5px;
/* Ensure tt-select aligns with tt-input */
.wa-modal-content .tt-select-modern {
display: flex;
flex-direction: column;
}
}
.wa-modal-content .tt-select-modern .form-group {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.wa-modal-content .tt-select-modern label {
order: -1;
}
.wa-modal-content textarea.form-control {
min-height: 60px;
font-size: 0.8rem;
padding: 6px 8px;
}
}
/*
* Additional Attributes Section
*/
.wa-attributes-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-attributes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
/*
* Responsive Design
*/
@media (max-width: 992px) {
.wa-prices-grid-dense {
grid-template-columns: 1fr;
}
.wa-directory-items {
grid-template-columns: 1fr;
}
.wa-checkbox-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.wa-modal-content .row > [class*="col-"] {
padding-left: 12px;
padding-right: 12px;
}
.wa-price-body {
grid-template-columns: 1fr;
gap: 8px;
}
.wa-price-actions {
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid #e9ecef;
}
.wa-distributor-row {
grid-template-columns: 1fr;
gap: 8px;
}
.wa-distributor-name {
padding-bottom: 4px;
border-bottom: 1px solid #e9ecef;
}
.wa-distributor-inputs {
grid-template-columns: 1fr;
}
.wa-distributor-actions {
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid #e9ecef;
}
.wa-checkbox-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.wa-section-title {
font-size: 0.85rem;
}
.wa-modal-content {
gap: 10px;
}
.wa-section {
padding: 10px;
}
.wa-section-title-with-action {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
@@ -1,5 +1,13 @@
// Track last edited article for highlighting
window.TT_CONFIG.lastEditedArticleId = null;
window.TT_CONFIG.CRUD_CONFIG.customRowClass = (row) => {
if (row.isEndOfLife) return 'end-of-life';
const classes = [];
if (row.isEndOfLife) classes.push('end-of-life');
if (window.TT_CONFIG.lastEditedArticleId && row.id == window.TT_CONFIG.lastEditedArticleId) {
classes.push('last-edited-row');
}
return classes.join(' ');
}
async function handleApiResponse(responsePromise) {
@@ -14,46 +22,115 @@ async function handleApiResponse(responsePromise) {
}
Vue.component('warehouse-article-prices', {
props: {id: {type: Number, required: true}},
props: {
id: {type: Number, required: true},
cheapestPurchasePrice: {type: Number, default: null}
},
template: `
<tt-card>
<h4 style="text-align: center">Artikelpreise überschreiben</h4>
<div class="warehouse-article-prices">
<div v-for="(price, typeTitle) in articlePrices" :key="typeTitle">
<div style="align-self: center;">
<i v-if="price.isRobot" class="fa-solid fa-robot"></i> {{ typeTitle }}
<div class="wa-prices-section">
<h5 class="wa-section-title"><i class="fas fa-tags mr-2"></i>Artikelpreise überschreiben</h5>
<div class="wa-prices-grid-dense">
<div v-for="price in sortedPrices" :key="price.typeTitle" class="wa-price-item">
<div class="wa-price-header">
<i v-if="price.isRobot" class="fas fa-robot mr-1 text-muted" style="font-size: 0.75rem;"></i>
<strong style="font-size: 0.8rem;">{{ price.typeTitle }}</strong>
<span class="ml-1 text-muted" style="font-size: 0.75rem;">( {{ formatPrice(calculateCurrentPrice(price)) }} )</span>
<i v-if="price.pendingChanges" class="fas fa-exclamation-triangle text-warning ml-auto"
style="font-size: 0.75rem;" title="Nicht gespeichert"></i>
</div>
<tt-input sm v-model="price.priceMultiplier" label="Preisfaktor" @input="price.priceOverride = null;price.pendingChanges = true"/>
<tt-input sm v-model="price.priceOverride" label="Preis" @input="price.priceMultiplier = null;price.pendingChanges = true"/>
<div style="align-self: end;display: flex;align-items: center;">
<tt-button sm icon="fa-solid fa-save" additional-class="btn-primary" @click="savePrice(price)"/>
<tt-button sm icon="fa-solid fa-trash" additional-class="btn-danger" v-if="!price.isRobot" @click="deletePrice(price)"/>
<i v-if="price.pendingChanges" class="fa-solid fa-triangle-exclamation ml-1 text-warning" style="font-size: 28px" title="Dieser Preis wurde noch nicht gespeichert"></i>
<div class="wa-price-body">
<tt-input
v-model="price.priceMultiplier"
@input="handleFactorInput(price)"
placeholder="Faktor"
type="number"
sm no-form-group/>
<tt-input
v-model="price.priceOverride"
@input="handlePriceInput(price)"
placeholder="Preis €"
type="number"
sm no-form-group/>
<div class="wa-price-actions">
<button class="btn btn-sm btn-danger" @click="deletePrice(price)" :disabled="price.isRobot" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</tt-card>
</div>
`,
data: () => ({window, articlePrices: {}}),
data: () => ({window, articlePrices: {}, priceTypes: []}),
computed: {
sortedPrices() {
// Sort: Verkauf first, Partner second, rest alphabetically
const priceOrder = {'Verkauf': 1, 'Partner': 2, 'Energie Steiermark': 3};
return Object.entries(this.articlePrices)
.map(([typeTitle, price]) => ({...price, typeTitle}))
.sort((a, b) => {
const orderA = priceOrder[a.typeTitle] || 99;
const orderB = priceOrder[b.typeTitle] || 99;
if (orderA !== orderB) return orderA - orderB;
return a.typeTitle.localeCompare(b.typeTitle);
});
}
},
async mounted() {
await this.fetchArticlePrices();
},
methods: {
formatPrice(price) {
if (price === null || price === undefined || isNaN(price)) return '-- €';
return price.toFixed(2).replace('.', ',') + ' €';
},
calculateCurrentPrice(price) {
const basePrice = this.cheapestPurchasePrice;
if (basePrice === null || basePrice === undefined) return null;
// If custom override price is set, use it
if (price.priceOverride !== null && price.priceOverride !== undefined && price.priceOverride !== '') {
return parseFloat(price.priceOverride);
}
// If custom multiplier is set, use it
if (price.priceMultiplier !== null && price.priceMultiplier !== undefined && price.priceMultiplier !== '') {
return basePrice * parseFloat(price.priceMultiplier);
}
// Fall back to default factor from price type
const priceType = this.priceTypes.find(pt => pt.id === price.articlePriceTypeId);
if (priceType && priceType.defaultPriceFactor) {
return basePrice * priceType.defaultPriceFactor;
}
return null;
},
handleFactorInput(price) {
if (price.priceMultiplier) price.priceOverride = null;
price.pendingChanges = true;
},
handlePriceInput(price) {
if (price.priceOverride) price.priceMultiplier = null;
price.pendingChanges = true;
},
async fetchArticlePrices() {
const [pricesRes, typesRes] = await Promise.all([
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/get`, {filters: {articleId: this.id}}),
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePriceType/get`)
]);
this.priceTypes = typesRes.data.rows || [];
const prices = {};
typesRes.data.rows.forEach(type => prices[type.title] = {
this.priceTypes.forEach(type => prices[type.title] = {
isRobot: true,
articlePriceTypeId: type.id,
priceMultiplier: type.defaultPriceFactor,
priceOverride: null
});
pricesRes.data.rows.forEach(pData => {
const type = typesRes.data.rows.find(t => t.id === pData.articlePriceTypeId);
if (type) prices[type.title] = {
const type = this.priceTypes.find(t => t.id === pData.articlePriceTypeId);
if (!type) return;
prices[type.title] = {
id: pData.id,
isRobot: false,
pendingChanges: false,
@@ -64,20 +141,24 @@ Vue.component('warehouse-article-prices', {
});
this.articlePrices = prices;
},
async savePrice(price) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
if (price.isRobot) {
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/create`, payload));
await this.fetchArticlePrices();
} else {
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/update`, {id: price.id, ...payload}));
await this.fetchArticlePrices();
async savePrices() {
// Save all prices with pending changes
const pendingPrices = this.sortedPrices.filter(p => p.pendingChanges);
for (const price of pendingPrices) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
const endpoint = price.isRobot ? 'create' : 'update';
const data = price.isRobot ? payload : {id: price.id, ...payload};
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data);
}
await this.fetchArticlePrices();
},
hasPendingChanges() {
return this.sortedPrices.some(p => p.pendingChanges);
},
async deletePrice(price) {
const payload = {id: price.id, articleId: this.id, articlePriceTypeId: price.articlePriceTypeId}
@@ -87,77 +168,610 @@ Vue.component('warehouse-article-prices', {
}
});
// warehouse-article-distributor.vue.js
Vue.component('warehouse-article-distributor', {
props: {id: {type: Number, required: true}},
Vue.component('warehouse-distributor-directory-modal', {
props: {
show: { type: Boolean, default: false },
allDistributors: { type: Array, default: () => [] },
articleDistributors: { type: Array, default: () => [] }
},
data: () => ({
distributorSearch: ''
}),
watch: {
show(newVal) {
if (newVal) document.documentElement.style.overflow = 'hidden';
}
},
computed: {
filteredDistributors() {
if (!this.distributorSearch) return this.allDistributors;
const search = this.distributorSearch.toLowerCase();
return this.allDistributors.filter(d => d.name.toLowerCase().includes(search));
},
alphabetWithDistributors() {
const letters = new Set();
this.filteredDistributors.forEach(d => {
const firstChar = d.name.charAt(0).toUpperCase();
if (/[A-Z]/.test(firstChar)) letters.add(firstChar);
});
return Array.from(letters).sort();
}
},
methods: {
getDistributorsByLetter(letter) {
return this.filteredDistributors.filter(d => d.name.charAt(0).toUpperCase() === letter);
},
isDistributorAdded(distributorId) {
return this.articleDistributors.some(d => d.distributorId === distributorId);
},
selectDistributor(distributorId) {
this.$emit('select', distributorId);
this.$emit('close');
},
close() {
this.$emit('close');
}
},
template: `
<tt-card>
<h4 style="text-align: center">Lieferanten für diesen Artikel</h4>
<tt-autocomplete :api-url="window['TT_CONFIG']['BASE_PATH'] + '/WarehouseDistributor/autocomplete'"
v-model="newDistributorId" label="Neuen Lieferant hinzufügen"/>
<div class="warehouse-article-distributor">
<div v-for="(distributor, index) in articleDistributors" :key="distributor.id || ('new-' + index)">
<tt-resolver style="align-self: center;" reference="WarehouseDistributor"
:value="distributor.distributorId"></tt-resolver>
<tt-input sm v-model="distributor.externalArticleNumber" label="Externe Artikelnummer" @input="distributor.pendingChanges = true"/>
<tt-input sm v-model="distributor.purchasePrice" label="Einkaufspreis" @input="distributor.pendingChanges = true"/>
<div style="align-self: end; display: flex;align-items: center;">
<tt-button sm icon="fa-solid fa-save" additional-class="btn-primary"
@click="saveDistributor(distributor)"/>
<tt-button sm icon="fa-solid fa-trash" additional-class="btn-danger" v-if="distributor.id"
@click="deleteDistributor(distributor.id)"/>
<i v-if="distributor.pendingChanges || !distributor.id" class="fa-solid fa-triangle-exclamation ml-1 text-warning"
style="font-size: 28px" title="Dieser Preis wurde noch nicht gespeichert"></i>
<div v-if="show" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);" @click.self="close">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-truck mr-2"></i>
Lieferant hinzufügen
</h5>
<button type="button" class="close" @click="close">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div class="wa-directory-header mb-3">
<tt-input
v-model="distributorSearch"
placeholder="Lieferant suchen..."
sm
prefix-icon="fas fa-search"/>
</div>
<div class="wa-directory-list">
<div v-for="letter in alphabetWithDistributors" :key="letter" class="wa-directory-group">
<div class="wa-directory-letter">{{ letter }}</div>
<div class="wa-directory-items">
<button v-for="dist in getDistributorsByLetter(letter)"
:key="dist.id"
class="wa-directory-item"
:class="{ 'active': isDistributorAdded(dist.id) }"
@click="selectDistributor(dist.id)"
:disabled="isDistributorAdded(dist.id)">
{{ dist.name }}
<i v-if="isDistributorAdded(dist.id)" class="fas fa-check ml-1"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</tt-card>
</div>
`
});
Vue.component('warehouse-article-distributor', {
props: {id: {type: Number, required: true}},
template: `
<div class="wa-distributors-section">
<div class="wa-section-title-with-action">
<h5 class="wa-section-title mb-0"><i class="fas fa-truck mr-2"></i>Lieferanten</h5>
<button class="btn btn-sm btn-primary" @click="showDirectoryModal = true">
<i class="fas fa-plus mr-1"></i>Lieferant hinzufügen
</button>
</div>
<warehouse-distributor-directory-modal
:show="showDirectoryModal"
:all-distributors="allDistributors"
:article-distributors="articleDistributors"
@select="addDistributor"
@close="showDirectoryModal = false"/>
<div v-if="articleDistributors.length === 0" class="text-muted text-center py-3" style="font-size: 0.85rem;">
<i class="fas fa-info-circle mr-1"></i>Keine Lieferanten zugewiesen
</div>
<div v-else class="wa-distributors-grid">
<div v-for="(distributor, index) in articleDistributors" :key="distributor.id || ('new-' + index)" class="wa-distributor-row">
<div class="wa-distributor-name">
<strong>{{ getDistributorName(distributor.distributorId) }}</strong>
<i v-if="distributor.pendingChanges || !distributor.id"
class="fas fa-exclamation-triangle text-warning ml-1"
style="font-size: 0.75rem;"
title="Nicht gespeichert"></i>
</div>
<div class="wa-distributor-inputs">
<tt-input
v-model="distributor.externalArticleNumber"
@input="distributor.pendingChanges = true"
placeholder="Externe Art.-Nr."
sm no-form-group/>
<tt-input
v-model="distributor.purchasePrice"
@input="distributor.pendingChanges = true"
placeholder="Einkaufspreis €"
sm no-form-group/>
</div>
<div class="wa-distributor-actions">
<button class="btn btn-sm btn-danger" @click="deleteDistributor(distributor.id)" :disabled="!distributor.id" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
`,
data: () => ({window, articleDistributors: [], newDistributorId: null}),
data: () => ({
window,
articleDistributors: [],
allDistributors: [],
showDirectoryModal: false
}),
async mounted() {
await this.fetchArticleDistributors();
await Promise.all([
this.fetchArticleDistributors(),
this.fetchAllDistributors()
]);
},
methods: {
async fetchAllDistributors() {
const res = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseDistributor/get`, {
pagination: { per_page: 10000 },
order: { key: 'name', order: 'ASC' }
});
this.allDistributors = res.data.rows || [];
},
async fetchArticleDistributors() {
const res = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/get`, {filters: {articleId: this.id}});
this.articleDistributors = res.data.rows;
},
async saveDistributor(distributor) {
delete distributor.pendingChanges;
distributor.purchasePrice = distributor.purchasePrice ? parseFloat(distributor.purchasePrice.toString().replace(',', '.')) : null;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${distributor.id ? 'update' : 'create'}`, distributor));
getDistributorName(id) {
const dist = this.allDistributors.find(d => d.id === id);
return dist ? dist.name : 'Unbekannt';
},
addDistributor(distributorId) {
if (this.articleDistributors.some(d => d.distributorId === distributorId)) return;
this.articleDistributors.push({
articleId: this.id,
distributorId: distributorId,
externalArticleNumber: null,
purchasePrice: null,
pendingChanges: true
});
},
hasPendingChanges() {
return this.articleDistributors.some(d => d.pendingChanges || !d.id);
},
async saveDistributors() {
// Save all distributors with pending changes or newly added ones
const pendingDistributors = this.articleDistributors.filter(d => d.pendingChanges || !d.id);
for (const distributor of pendingDistributors) {
const data = {...distributor};
delete data.pendingChanges;
data.purchasePrice = data.purchasePrice ? parseFloat(data.purchasePrice.toString().replace(',', '.')) : null;
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${data.id ? 'update' : 'create'}`, data);
}
await this.fetchArticleDistributors();
},
async deleteDistributor(distributorId) {
if (!confirm('Lieferant wirklich entfernen?')) return;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/delete`, {id: distributorId, articleId: this.id}));
await this.fetchArticleDistributors();
}
},
watch: {
newDistributorId(newId) {
if (newId) {
if (!this.articleDistributors.some(d => d.distributorId === newId)) {
this.articleDistributors.push({
articleId: this.id,
distributorId: newId,
externalArticleNumber: null,
purchasePrice: null
});
}
this.$nextTick(() => {
this.newDistributorId = null;
});
}
}
}
});
// warehouse-article.vue.js
Vue.component('warehouse-article-modal', {
props: {
id: { type: [Number, String], required: true }
},
template: `
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);" @click.self="close">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-box mr-2"></i>
{{ isEditMode ? 'Artikel bearbeiten' : 'Artikel erstellen' }}
</h5>
<button type="button" class="close" @click="close">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div v-if="loading" class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
</div>
<div v-else class="wa-modal-content">
<!-- Basic Information -->
<div class="wa-section">
<div class="row">
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-input
label="Titel"
v-model="formData.title"
placeholder="Artikel Titel"
required
sm/>
</div>
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-textarea
label="Beschreibung"
v-model="formData.description"
required
rows="3"/>
</div>
<div class="col-md-4">
<tt-select
label="Kategorie"
v-model="formData.category_id"
:options="categoryOptions"
@input="onCategoryChange"
required
sm/>
</div>
<div class="col-md-4">
<tt-input
label="Artikel-Nummer"
v-model="formData.articleNumber"
placeholder="Wird automatisch generiert"
required
disabled
form-label
sm/>
</div>
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Einheit"
v-model="formData.unit"
:options="unitOptions"
required
sm/>
</div>
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Erlöskonto"
v-model="formData.revenueAccount"
:options="revenueAccountOptions"
required
sm/>
</div>
</div>
</div>
<!-- Prices Section -->
<warehouse-article-prices v-if="isEditMode" ref="pricesComponent" :id="Number(id)" :cheapest-purchase-price="cheapestPurchasePrice"/>
<!-- Distributors Section -->
<warehouse-article-distributor v-if="isEditMode" ref="distributorComponent" :id="Number(id)"/>
<!-- Additional Attributes -->
<div class="wa-section" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<h5 class="wa-section-title"><i class="fas fa-cog mr-2"></i>Zusätzliche Artikel Attribute</h5>
<div class="row">
<div class="col-md-6">
<tt-input
label="Warnmenge"
type="number"
v-model.number="formData.warningAmount"
placeholder="0"
required
sm/>
</div>
<div class="col-md-6">
<tt-input
label="Kritische Menge"
type="number"
v-model.number="formData.criticalAmount"
placeholder="0"
required
sm/>
</div>
</div>
<div class="wa-checkbox-grid">
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSerialDocumentation">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Seriennummern</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEndOfLife">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">End of Life</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEShop">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Ist E-Shop</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEShopHide">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">E-Shop Versteckt</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSbidiShop">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Ist SBIDI-Shop</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSbidiShopHide">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">SBIDI-Shop Versteckt</span>
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">Abbrechen</button>
<button type="button" class="btn btn-outline-primary" @click="save(false)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern
</button>
<button type="button" class="btn btn-primary" @click="save(true)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern und Schließen
</button>
</div>
</div>
</div>
</div>
`,
data: () => ({
loading: false,
saving: false,
originalCategoryId: null,
cheapestPurchasePrice: null,
originalFormData: null,
formData: {
title: '',
description: '',
category_id: null,
articleNumber: '',
unit: 'Stk.',
revenueAccount: 0,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
isEndOfLife: false,
isEShop: false,
isEShopHide: false,
isSbidiShop: false,
isSbidiShopHide: false
}
}),
computed: {
isEditMode() {
return this.id !== 'create';
},
categoryOptions() {
const catCol = window.TT_CONFIG.CRUD_CONFIG.columns.find(c => c.key === 'category_id');
return catCol ? [{ value: null, text: '-- Bitte wählen --' }, ...catCol.modal.items] : [];
},
unitOptions() {
return [
{ value: 'Stk.', text: 'Stk.' },
{ value: 'Pau.', text: 'Pau.' },
{ value: 'm.', text: 'm.' },
{ value: 'Std.', text: 'Std.' },
{ value: 'km', text: 'km' }
];
},
revenueAccountOptions() {
return [
{ value: 0, text: 'Dienstleistungen' },
{ value: 1, text: 'Handelswaren' }
];
},
isValid() {
return this.formData.title &&
this.formData.description &&
this.formData.category_id &&
this.formData.articleNumber &&
this.formData.unit;
}
},
mounted() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.paddingRight = scrollbarWidth + 'px';
if (this.isEditMode) this.loadArticle();
else this.resetForm();
},
beforeDestroy() {
document.documentElement.style.overflow = '';
document.documentElement.style.paddingRight = '';
},
methods: {
async loadArticle() {
this.loading = true;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getById`, {
params: { id: Number(this.id) }
});
const data = res.data;
if (data && data.id) {
this.formData = {
title: data.title || '',
description: data.description || '',
category_id: data.category_id,
articleNumber: data.articleNumber || '',
unit: data.unit || 'Stk.',
revenueAccount: data.revenueAccount || 0,
warningAmount: data.warningAmount || 0,
criticalAmount: data.criticalAmount || 0,
isSerialDocumentation: !!data.isSerialDocumentation,
isEndOfLife: !!data.isEndOfLife,
isEShop: !!data.isEShop,
isEShopHide: !!data.isEShopHide,
isSbidiShop: !!data.isSbidiShop,
isSbidiShopHide: !!data.isSbidiShopHide
};
// Store original category to detect changes
this.originalCategoryId = data.category_id;
// Store cheapest purchase price for price calculations
this.cheapestPurchasePrice = data.cheapestPurchasePrice || null;
// Store original form data to detect changes
this.originalFormData = JSON.stringify(this.formData);
}
} catch (e) {
window.notify('error', 'Fehler beim Laden');
} finally {
this.loading = false;
}
},
resetForm() {
this.formData = {
title: '',
description: '',
category_id: null,
articleNumber: '',
unit: 'Stk.',
revenueAccount: 0,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
isEndOfLife: false,
isEShop: false,
isEShopHide: false,
isSbidiShop: false,
isSbidiShopHide: false
};
},
async onCategoryChange(categoryId) {
if (!categoryId) return;
// In edit mode, only regenerate if category actually changed from original
if (this.isEditMode && categoryId == this.originalCategoryId) return;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getNextArticleNumber`, {
params: { categoryId: categoryId }
});
if (res.data.success) {
this.formData.articleNumber = res.data.articleNumber;
}
} catch (e) {
console.error('Failed to get next article number:', e);
}
},
async save(closeAfterSave = true) {
if (!this.isValid) return;
this.saving = true;
try {
let savedPrices = false;
let savedDistributors = false;
let savedArticle = false;
// Save prices and distributors first (only in edit mode)
if (this.isEditMode) {
if (this.$refs.pricesComponent && this.$refs.pricesComponent.hasPendingChanges()) {
await this.$refs.pricesComponent.savePrices();
savedPrices = true;
}
if (this.$refs.distributorComponent && this.$refs.distributorComponent.hasPendingChanges()) {
await this.$refs.distributorComponent.saveDistributors();
savedDistributors = true;
}
}
// Check if main article data actually changed
const currentFormData = JSON.stringify(this.formData);
const articleDataChanged = !this.isEditMode || this.originalFormData !== currentFormData;
if (articleDataChanged) {
const endpoint = this.isEditMode ? 'update' : 'create';
const payload = {
...this.formData,
isSerialDocumentation: this.formData.isSerialDocumentation ? 1 : 0,
isEndOfLife: this.formData.isEndOfLife ? 1 : 0,
isEShop: this.formData.isEShop ? 1 : 0,
isEShopHide: this.formData.isEShopHide ? 1 : 0,
isSbidiShop: this.formData.isSbidiShop ? 1 : 0,
isSbidiShopHide: this.formData.isSbidiShopHide ? 1 : 0
};
if (this.isEditMode) payload.id = Number(this.id);
const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload);
if (res.data.success) {
savedArticle = true;
// Track last edited article for row highlighting
window.TT_CONFIG.lastEditedArticleId = this.isEditMode ? Number(this.id) : res.data.id;
if (!this.isEditMode) {
// For new articles, reopen in edit mode
window.notify('success', res.data.message || 'Gespeichert');
this.$emit('reopen', res.data.id);
return;
}
} else {
window.notify('error', res.data.message || 'Fehler beim Speichern');
return;
}
}
// Show success message if anything was saved
if (savedArticle || savedPrices || savedDistributors) {
window.TT_CONFIG.lastEditedArticleId = Number(this.id);
window.notify('success', 'Gespeichert');
} else {
window.notify('info', 'Keine Änderungen');
}
if (closeAfterSave) {
// Close modal if requested
this.$emit('close');
} else {
// Stay open - reload data to refresh prices/distributors
await this.loadArticle();
if (this.$refs.pricesComponent) await this.$refs.pricesComponent.fetchArticlePrices();
if (this.$refs.distributorComponent) await this.$refs.distributorComponent.fetchArticleDistributors();
}
} catch (e) {
window.notify('error', 'Fehler beim Speichern');
} finally {
this.saving = false;
}
},
close() {
this.$emit('close');
}
}
});
Vue.component('warehouse-article', {
template: `
<tt-card>
<tt-table-crud ref="table" @openHistory="historyModalId = $event.id; historyModal = true">
<warehouse-article-modal
v-if="articleModalId"
:id="articleModalId"
@close="articleModalId = null; $refs.table.$refs.table.refreshTable()"
@reopen="articleModalId = $event"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
<div class="mb-3" v-if="window.TT_CONFIG.WAREHOUSE_ADMIN">
<button @click="articleModalId = 'create'" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Artikel erstellen
</button>
</div>
<tt-table-crud
ref="table"
emit-edit
@openHistory="historyModalId = $event.id; historyModal = true"
@printLabel="printLabel($event)"
@edit="articleModalId = $event.id">
<template v-slot:cheapestsellprice="{ row }">
<template v-for="price in JSON.parse(row.cheapestSellPrice || '[]')">
<span style="white-space:nowrap;" v-if="price && window.TT_CONFIG['WAREHOUSE_ADMIN']">{{ price.title }}: <span
@@ -165,7 +779,7 @@ Vue.component('warehouse-article', {
<span v-else-if="price && price.title === 'Verkauf'">{{ price.price }} €</span>
</template>
</template>
<template v-slot:description="{ row }">
<tt-tooltip
allow-wrapping
@@ -175,16 +789,15 @@ Vue.component('warehouse-article', {
</tt-tooltip>
<span v-else>{{ row.description }}</span>
</template>
<template v-slot:modal-prepend="{ crudModalData }">
<warehouse-article-prices v-if="crudModalData.id" :id="crudModalData.id"/>
<warehouse-article-distributor v-if="crudModalData.id" :id="crudModalData.id"/>
</template>
</tt-table-crud>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`,
data: () => ({window, historyModal: false, historyModalId: null, articleTest: null}),
data: () => ({
window,
historyModal: false,
historyModalId: null,
articleModalId: null
}),
mounted() {
const table = this.$refs.table?.$refs?.table;
if (!table) return;
@@ -197,9 +810,11 @@ Vue.component('warehouse-article', {
if (Object.keys(table.filters).length === 0) table.filters = {};
table.refreshTable();
}
window.addEventListener('refreshTable', () => {
table.refreshTable();
});
},
methods: {
printLabel(event) {
const url = window.TT_CONFIG.BASE_PATH + "/WarehouseArticle/printLabel?id=" + event.id;
window.open(url, '_blank');
}
}
});
});
@@ -54,7 +54,7 @@ Vue.component('warehouse-offer-create-basic-offer-modal', {
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
},
@@ -147,8 +147,7 @@ Vue.component('warehouse-offer-modal', {
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.', notes: '',
},
templateName: '',
ignoreFirstAddressChange: false,
@@ -1,11 +1,701 @@
Vue.component('warehouse-project', {
template: `
<tt-card>
<tt-table-crud ref="table">
</tt-table-crud>
<div class="mb-3">
<button @click="openProjectModal('create')" class="btn btn-primary shadow-sm"><i class="fas fa-plus"></i> Projekt erstellen</button>
</div>
<tt-table-crud emit-edit
ref="table"
:additional-actions="additionalActions"
@edit="openProjectModal($event.id)">
<template v-slot:expandedRow="{ row }">
<warehouse-project-details :project="row" @refresh="$refs.table.$refs.table.refreshTable()"/>
</template>
</tt-table-crud>
<warehouse-project-modal
v-if="projectModalId"
:id="projectModalId"
@close="closeProjectModal"
/>
</tt-card>
`,
data() {
return {window: window}
return {
additionalActions: [],
projectModalId: null
};
},
methods: {
openProjectModal(id) {
this.projectModalId = id;
},
closeProjectModal() {
this.projectModalId = null;
this.$refs.table.$refs.table.refreshTable();
}
}
});
Vue.component('warehouse-project-details', {
props: ['project'],
template: `
<div class="my-3 bg-white p-1" style="cursor: default;">
<!-- Tabs Header -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 p-2">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'overview'}" href="#" @click.prevent="activeTab = 'overview'"><i class="fas fa-info-circle mr-1"></i> Übersicht</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'tasks'}" href="#" @click.prevent="activeTab = 'tasks'"><i class="fas fa-tasks mr-1"></i> Aufgaben</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'team'}" href="#" @click.prevent="activeTab = 'team'"><i class="fas fa-users mr-1"></i> Team</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'logistics'}" href="#" @click.prevent="activeTab = 'logistics'"><i class="fas fa-truck-loading mr-1"></i> Logistik</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'journal'}" href="#" @click.prevent="activeTab = 'journal'"><i class="fas fa-book mr-1"></i> Journal</a>
</li>
</ul>
</div>
</div>
<!-- Tabs Content -->
<div class="">
<!-- OVERVIEW -->
<div v-if="activeTab === 'overview'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6 border-right">
<h5 class="card-title text-primary border-bottom pb-2 mb-3">Projektdaten</h5>
<dl class="row">
<dt class="col-sm-4 text-muted">Status</dt>
<dd class="col-sm-8">
<span :class="statusBadgeClass(project.status)">{{ statusText(project.status) }}</span>
</dd>
<dt class="col-sm-4 text-muted">Projekt-Nr.</dt>
<dd class="col-sm-8 font-weight-bold">{{ project.projectNumber }}</dd>
<dt class="col-sm-4 text-muted">Zeitraum</dt>
<dd class="col-sm-8">
<i class="far fa-calendar-alt text-info mr-1"></i> {{ formatDate(project.startDate) }} - {{ formatDate(project.endDate) }}
</dd>
<dt class="col-sm-4 text-muted">Lagerort</dt>
<dd class="col-sm-8">{{ project.storageLocation || '-' }}</dd>
<dt class="col-sm-4 text-muted">Budget</dt>
<dd class="col-sm-8 font-weight-bold text-success">{{ formatPrice(project.financials) }}</dd>
</dl>
</div>
<div class="col-md-6">
<h5 class="card-title text-primary border-bottom pb-2 mb-3">Details</h5>
<p class="text-uppercase text-secondary small font-weight-bold mb-1">Beschreibung</p>
<div class="p-3 bg-light rounded mb-4 border" style="min-height: 80px;">{{ project.description || 'Keine Beschreibung vorhanden.' }}</div>
<p class="text-uppercase text-secondary small font-weight-bold mb-1">Externes Team / Partner</p>
<div class="p-3 bg-light rounded border">{{ project.externalTeam || '-' }}</div>
</div>
</div>
</div>
</div>
<!-- TASKS -->
<div v-if="activeTab === 'tasks'" class="card shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4 bg-light p-3 rounded border">
<div class="d-flex align-items-center flex-grow-1 mr-4">
<span class="mr-3 font-weight-bold text-muted">Fortschritt:</span>
<div class="progress flex-grow-1 shadow-sm" style="height: 25px;">
<div class="progress-bar bg-success progress-bar-striped progress-bar-animated" role="progressbar" :style="{width: taskProgress + '%'}" :aria-valuenow="taskProgress" aria-valuemin="0" aria-valuemax="100">
{{ taskProgress }}%
</div>
</div>
</div>
<button class="btn btn-success shadow" @click="openTaskModal()"><i class="fas fa-plus-circle"></i> Neue Aufgabe</button>
</div>
<div class="row">
<!-- TODO Column -->
<div class="col-md-4">
<div class="bg-secondary-light p-3 rounded h-100 border shadow-sm" style="background-color: #f8f9fa;">
<h6 class="text-uppercase text-secondary font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="far fa-circle mr-1"></i> Zu Erledigen
<span class="badge badge-secondary badge-pill ml-1">{{ tasksByStatus('todo').length }}</span>
</h6>
<div v-for="task in tasksByStatus('todo')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-warning" style="border-left-width: 5px;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark">{{ task.title }}</h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="openTaskModal(task)"><i class="fas fa-edit mr-2 text-info"></i> Bearbeiten</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'in_progress')"><i class="fas fa-play mr-2 text-primary"></i> Starten</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="#" @click.prevent="deleteTask(task.id)"><i class="fas fa-trash-alt mr-2"></i> Löschen</a>
</div>
</div>
</div>
<p class="card-text small text-muted mb-2 mt-1" v-if="task.description">{{ task.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-muted"><i class="fas fa-user-circle mr-1"></i> {{ task.assignedUserName || 'Niemand' }}</small>
</div>
</div>
</div>
<div v-if="tasksByStatus('todo').length === 0" class="text-center text-muted small mt-5 p-4 border border-dashed rounded">Keine Aufgaben</div>
</div>
</div>
<!-- IN PROGRESS Column -->
<div class="col-md-4">
<div class="bg-primary-light p-3 rounded h-100 border shadow-sm" style="background-color: #eef5ff;">
<h6 class="text-uppercase text-primary font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="fas fa-spinner fa-spin mr-1"></i> In Arbeit
<span class="badge badge-primary badge-pill ml-1">{{ tasksByStatus('in_progress').length }}</span>
</h6>
<div v-for="task in tasksByStatus('in_progress')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-primary" style="border-left-width: 5px;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark">{{ task.title }}</h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="openTaskModal(task)"><i class="fas fa-edit mr-2 text-info"></i> Bearbeiten</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'done')"><i class="fas fa-check mr-2 text-success"></i> Erledigen</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'todo')"><i class="fas fa-undo mr-2 text-secondary"></i> Zurückstellen</a>
</div>
</div>
</div>
<p class="card-text small text-muted mb-2 mt-1" v-if="task.description">{{ task.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-primary font-weight-bold"><i class="fas fa-user-circle mr-1"></i> {{ task.assignedUserName || 'Niemand' }}</small>
</div>
</div>
</div>
</div>
</div>
<!-- DONE Column -->
<div class="col-md-4">
<div class="bg-success-light p-3 rounded h-100 border shadow-sm" style="background-color: #e8f5e9;">
<h6 class="text-uppercase text-success font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="fas fa-check-circle mr-1"></i> Erledigt
<span class="badge badge-success badge-pill ml-1">{{ tasksByStatus('done').length }}</span>
</h6>
<div v-for="task in tasksByStatus('done')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-success" style="border-left-width: 5px; opacity: 0.85;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark"><del>{{ task.title }}</del></h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'in_progress')"><i class="fas fa-undo mr-2 text-primary"></i> Wieder öffnen</a>
<a class="dropdown-item text-danger" href="#" @click.prevent="deleteTask(task.id)"><i class="fas fa-trash-alt mr-2"></i> Löschen</a>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-success"><i class="fas fa-check mr-1"></i> Abgeschlossen</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TEAM -->
<div v-if="activeTab === 'team'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow border-0">
<div class="card-header bg-white text-dark d-flex justify-content-between align-items-center border-bottom">
<h5 class="mb-0 font-weight-bold"><i class="fas fa-users mr-2 text-primary"></i> Projektteam</h5>
<span class="badge badge-pill badge-primary">{{ team.length }} Mitglieder</span>
</div>
<div class="card-body bg-light">
<div class="input-group mb-4 shadow-sm bg-white rounded">
<tt-select
v-model="selectedUserId"
:options="allUsersSelectItems"
placeholder="Mitarbeiter auswählen"
class="form-control border-0"
style="flex: 1;"
></tt-select>
<div class="input-group-append">
<button class="btn btn-success font-weight-bold" @click="addMember" :disabled="!selectedUserId"><i class="fas fa-user-plus mr-1"></i></button>
</div>
</div>
<ul class="list-group list-group-flush rounded shadow-sm">
<li v-for="member in team" :key="member.memberId" class="list-group-item d-flex justify-content-between align-items-center border-bottom hover-bg-light">
<div class="d-flex align-items-center">
<div class="avatar-circle bg-gradient-primary text-white mr-3 d-flex align-items-center justify-content-center shadow-sm"
style="width: 45px; height: 45px; border-radius: 50%; font-weight: bold; font-size: 1.1rem; background: linear-gradient(45deg, #007bff, #0056b3);">
{{ member.name.charAt(0) }}
</div>
<div>
<h6 class="mb-0 font-weight-bold text-dark">{{ member.name }}</h6>
<small class="text-muted"><i class="fas fa-id-badge mr-1"></i> {{ member.role || 'Projektmitarbeiter' }}</small>
</div>
</div>
<button class="btn btn-outline-danger btn-sm rounded-circle shadow-sm" @click="removeMember(member.memberId)" title="Entfernen" style="width: 32px; height: 32px;"><i class="fas fa-times"></i></button>
</li>
<li v-if="team.length === 0" class="list-group-item text-center text-muted py-5">
<div class="opacity-50">
<i class="fas fa-users fa-4x mb-3 text-gray-300"></i>
<p class="lead">Noch keine Teammitglieder zugewiesen.</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LOGISTICS -->
<div v-if="activeTab === 'logistics'" class="card shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4 bg-light p-3 rounded border shadow-sm">
<h5 class="mb-0 text-dark"><i class="fas fa-boxes mr-2"></i> Logistik & Bestellungen</h5>
<button class="btn btn-primary shadow" @click="openLinkOrderModal"><i class="fas fa-link mr-1"></i> Bestellwunsch verknüpfen</button>
</div>
<div class="accordion" id="logisticsAccordion">
<div v-for="request in linkedOrders" :key="request.linkId" class="card mb-3 shadow-sm border-0">
<div class="card-header bg-white d-flex justify-content-between align-items-center pointer border rounded"
:id="'heading' + request.linkId"
data-toggle="collapse"
:data-target="'#collapse' + request.linkId"
aria-expanded="true"
:aria-controls="'collapse' + request.linkId"
style="transition: background-color 0.2s;">
<div class="d-flex align-items-center">
<span class="fa-stack mr-2 text-primary">
<i class="fas fa-circle fa-stack-2x opacity-25"></i>
<i class="fas fa-hashtag fa-stack-1x"></i>
</span>
<div>
<span class="font-weight-bold text-dark">{{ request.requestId }}</span>
<span class="mx-2 text-muted">|</span>
<span class="font-weight-500">{{ request.purpose }}</span>
</div>
</div>
<div class="d-flex align-items-center">
<span v-if="request.status === 'done'" class="badge badge-success px-3 py-2 shadow-sm"><i class="fas fa-check mr-1"></i> Erledigt</span>
<span v-else-if="request.status === 'cancelled'" class="badge badge-danger px-3 py-2 shadow-sm"><i class="fas fa-ban mr-1"></i> Storniert</span>
<span v-else class="badge badge-info px-3 py-2 shadow-sm"><i class="fas fa-clock mr-1"></i> Offen</span>
<button class="btn btn-sm btn-outline-danger ml-3 rounded-circle shadow-sm" style="width: 30px; height: 30px;" @click.stop="unlinkOrder(request.linkId)" title="Verknüpfung lösen"><i class="fas fa-unlink"></i></button>
<i class="fas fa-chevron-down ml-3 text-muted"></i>
</div>
</div>
<div :id="'collapse' + request.linkId" class="collapse show" :aria-labelledby="'heading' + request.linkId">
<div class="card-body bg-light border-left border-right border-bottom p-0">
<div class="p-3">
<h6 class="text-muted text-uppercase small font-weight-bold mb-3 pl-2 border-left-primary" style="border-left: 3px solid #007bff;">Enthaltene Bestellungen</h6>
<div v-if="request.orders && request.orders.length > 0">
<div class="table-responsive bg-white rounded shadow-sm border">
<table class="table table-hover mb-0">
<thead class="thead-light">
<tr>
<th class="border-top-0">Order Nr.</th>
<th class="border-top-0">Status</th>
<th class="border-top-0 text-right">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="order in request.orders" :key="order.id">
<td class="align-middle"><a :href="'/WarehouseOrder?id=' + order.id" target="_blank" class="font-weight-bold text-primary"><i class="fas fa-file-invoice mr-1"></i> {{ order.orderNumber }}</a></td>
<td class="align-middle">
<span class="badge badge-light border text-secondary px-2 py-1">{{ order.status || 'Unbekannt' }}</span>
</td>
<td class="align-middle text-right">
<a :href="'/WarehouseOrder?id=' + order.id" target="_blank" class="btn btn-sm btn-light border shadow-sm"><i class="fas fa-external-link-alt text-secondary"></i> Öffnen</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="text-muted small font-italic p-3 text-center">
Keine direkten Bestellungen verknüpft oder Daten noch nicht verfügbar.
</div>
</div>
</div>
</div>
</div>
<div v-if="linkedOrders.length === 0" class="text-center text-muted py-5 border rounded bg-light shadow-sm">
<i class="fas fa-truck-loading fa-4x mb-3 text-gray-300"></i>
<p class="h5 font-weight-light">Keine verknüpften Bestellwünsche.</p>
<button class="btn btn-outline-primary mt-2 btn-sm" @click="openLinkOrderModal">Jetzt verknüpfen</button>
</div>
</div>
</div>
</div>
<!-- JOURNAL -->
<div v-if="activeTab === 'journal'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<!-- Stylish Input Area -->
<div class="card shadow-sm mb-3 border-0 bg-light">
<div class="card-body p-2">
<div class="d-flex align-items-start">
<div class="avatar-circle bg-primary text-white mr-3 d-flex align-items-center justify-content-center rounded-circle mt-1 shadow-sm" style="width: 35px; height: 35px; font-size: 0.9rem;">
<i class="fas fa-pen"></i>
</div>
<div class="flex-grow-1 position-relative">
<textarea v-model="newJournalMessage"
class="form-control border-0 bg-white shadow-sm"
rows="2"
style="resize: none; border-radius: 1rem; padding: 10px; font-size: 0.9rem;"
placeholder="Schreiben Sie eine Notiz..."></textarea>
<button class="btn btn-primary rounded-circle shadow position-absolute"
style="bottom: -10px; right: 10px; width: 35px; height: 35px; display: flex; align-items: center; justify-content: center;"
@click="postJournal"
:disabled="!newJournalMessage.trim()">
<i class="fas fa-paper-plane small"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Scrollable Feed -->
<div class="journal-feed pr-2" style="max-height: 500px; overflow-y: auto;">
<div v-for="log in journal" :key="log.id" class="media mb-3 p-2 bg-white shadow-sm rounded border-left-info position-relative" style="border-left: 3px solid #17a2b8;">
<div class="mr-2 text-center" style="width: 45px;">
<div class="text-muted small font-weight-bold" style="font-size: 0.75rem;">{{ new Date(log.create * 1000).toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit'}) }}</div>
<div class="text-muted small" style="font-size: 0.7rem;">{{ new Date(log.create * 1000).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) }}</div>
</div>
<div class="media-body">
<h6 class="mt-0 mb-1 font-weight-bold text-dark d-flex justify-content-between" style="font-size: 0.9rem;">
{{ log.userName }}
</h6>
<div class="text-secondary" style="white-space: pre-wrap; font-size: 0.85rem; line-height: 1.4;">{{ log.text }}</div>
</div>
</div>
<div v-if="journal.length === 0" class="text-center text-muted py-4 opacity-50">
<i class="fas fa-history fa-2x mb-2 text-gray-300"></i>
<p class="small">Noch keine Journal-Einträge.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow border-0">
<div class="card-header bg-secondary text-white py-2">
<h6 class="mb-0 small"><i class="fas fa-paperclip mr-1"></i> Dokumente</h6>
</div>
<div class="card-body text-center text-muted bg-light" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">
<i class="fas fa-cloud-upload-alt fa-2x mb-2 text-gray-400"></i>
<p class="small mb-0">Dateien hierher ziehen</p>
<p class="small text-muted font-italic" style="font-size: 0.7rem;">(WIP: Upload Funktion)</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Task Modal -->
<div class="modal fade" id="taskModal" tabindex="-1" role="dialog" ref="taskModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold"><i class="fas fa-tasks mr-2"></i> {{ currentTask.id ? 'Aufgabe bearbeiten' : 'Neue Aufgabe' }}</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body bg-light">
<form @submit.prevent="saveTask">
<div class="form-group">
<label class="font-weight-bold text-dark">Titel <span class="text-danger">*</span></label>
<input type="text" class="form-control shadow-sm" v-model="currentTask.title" required placeholder="Was muss erledigt werden?">
</div>
<div class="form-group">
<label class="font-weight-bold text-dark">Beschreibung</label>
<textarea class="form-control shadow-sm" v-model="currentTask.description" rows="4" placeholder="Details zur Aufgabe..."></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="font-weight-bold text-dark">Zugewiesen an</label>
<select class="form-control shadow-sm" v-model="currentTask.assignedUserId">
<option value="">-- Unzugewiesen --</option>
<option v-for="u in allUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="font-weight-bold text-dark">Status</label>
<select class="form-control shadow-sm" v-model="currentTask.status">
<option value="todo">ToDo</option>
<option value="in_progress">In Arbeit</option>
<option value="done">Erledigt</option>
</select>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer bg-white">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary shadow" @click="saveTask">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Link Order Modal -->
<div class="modal fade" id="linkOrderModal" tabindex="-1" role="dialog" ref="linkOrderModal">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold"><i class="fas fa-link mr-2"></i> Bestellwunsch verknüpfen</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body bg-light" style="max-height: 60vh; overflow-y: auto;">
<div v-if="availableOrders.length === 0" class="alert alert-info shadow-sm text-center py-4">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<p class="mb-0">Keine offenen Bestellwünsche gefunden.</p>
</div>
<div v-else class="list-group shadow-sm">
<button v-for="req in availableOrders" :key="req.id" type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center p-3 hover-bg-light" @click="linkOrder(req.id)">
<div>
<h6 class="mb-1 font-weight-bold text-primary">#{{ req.id }} - {{ req.purpose }}</h6>
<small class="text-muted"><i class="far fa-clock mr-1"></i> Erstellt am: {{ formatDateTime(req.create) }}</small>
</div>
<i class="fas fa-plus-circle text-success fa-2x"></i>
</button>
</div>
</div>
<div class="modal-footer bg-white">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
activeTab: 'overview',
tasks: [],
team: [],
linkedOrders: [],
journal: [],
allUsers: [],
availableOrders: [],
selectedUserId: '',
newJournalMessage: '',
currentTask: {
id: null,
title: '',
description: '',
assignedUserId: '',
status: 'todo'
}
};
},
computed: {
taskProgress() {
if (!this.tasks || !this.tasks.length) return 0;
const done = this.tasks.filter(t => t.status === 'done').length;
return Math.round((done / this.tasks.length) * 100);
},
allUsersSelectItems() {
return this.allUsers.map(u => ({ value: u.id, text: u.name }));
}
},
mounted() {
this.loadData();
},
methods: {
async loadData() {
try {
await Promise.all([
this.fetchTasks(),
this.fetchTeam(),
this.fetchLinkedOrders(),
this.fetchJournal(),
this.fetchUsers()
]);
} catch (e) {
console.error("Error loading project data", e);
}
},
async fetchTasks() {
try {
const res = await axios.get('/WarehouseProject/getTasks?id=' + this.project.id);
this.tasks = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.tasks = [];
}
},
async fetchTeam() {
try {
const res = await axios.get('/WarehouseProject/getTeam?id=' + this.project.id);
this.team = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.team = [];
}
},
async fetchLinkedOrders() {
try {
const res = await axios.get('/WarehouseProject/getLinkedOrders?id=' + this.project.id);
this.linkedOrders = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.linkedOrders = [];
}
},
async fetchJournal() {
try {
const res = await axios.get('/WarehouseProject/getJournal?id=' + this.project.id);
this.journal = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.journal = [];
}
},
async fetchUsers() {
try {
const res = await axios.get('/WarehouseProject/getUsers');
this.allUsers = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.allUsers = [];
}
},
async fetchAvailableOrders() {
try {
const res = await axios.get('/WarehouseProject/getAvailableOrderRequests');
this.availableOrders = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.availableOrders = [];
}
},
// Helpers
tasksByStatus(status) {
if (!this.tasks) return [];
return this.tasks.filter(t => t.status === status);
},
statusLabel(status) {
const map = { todo: 'ToDo', in_progress: 'In Arbeit', done: 'Erledigt' };
return map[status] || status;
},
statusText(status) {
const map = { new: 'Neu', wip: 'In Bearbeitung', finished: 'Abgeschlossen', cancelled: 'Storniert' };
return map[status] || status;
},
statusBadgeClass(status) {
const map = { new: 'badge badge-primary', wip: 'badge badge-warning', finished: 'badge badge-success', cancelled: 'badge badge-danger' };
return map[status] || 'badge badge-secondary';
},
formatDate(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleDateString('de-DE');
},
formatDateTime(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString('de-DE');
},
formatPrice(val) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val || 0);
},
// Actions
openTaskModal(task = null) {
if (task) {
this.currentTask = { ...task };
} else {
this.currentTask = { id: null, title: '', description: '', assignedUserId: '', status: 'todo' };
}
$(this.$refs.taskModal).modal('show');
},
async saveTask() {
if (!this.currentTask.title) return alert("Titel erforderlich");
try {
await axios.post('/WarehouseProject/saveTask', { ...this.currentTask, projectId: this.project.id });
$(this.$refs.taskModal).modal('hide');
this.fetchTasks();
this.fetchJournal();
} catch(e) {
console.error(e);
alert("Fehler beim Speichern: " + (e.response?.data?.error || 'Unbekannt'));
}
},
async updateTaskStatus(id, status) {
await axios.post('/WarehouseProject/updateTaskStatus', { id, status });
this.fetchTasks();
this.fetchJournal();
},
async deleteTask(id) {
if(!confirm("Wirklich löschen?")) return;
await axios.get('/WarehouseProject/deleteTask?id=' + id);
this.fetchTasks();
this.fetchJournal();
},
async addMember() {
if (!this.selectedUserId) return;
try {
await axios.post('/WarehouseProject/addTeamMember', { projectId: this.project.id, userId: this.selectedUserId });
this.fetchTeam();
this.fetchJournal();
this.selectedUserId = '';
} catch (e) {
alert("Fehler beim Hinzufügen (evtl. bereits im Team?)");
}
},
async removeMember(id) {
await axios.get('/WarehouseProject/removeTeamMember?id=' + id);
this.fetchTeam();
this.fetchJournal();
},
async openLinkOrderModal() {
await this.fetchAvailableOrders();
$(this.$refs.linkOrderModal).modal('show');
},
async linkOrder(orderId) {
if (!orderId) return;
try {
await axios.post('/WarehouseProject/linkOrder', { projectId: this.project.id, orderId });
$(this.$refs.linkOrderModal).modal('hide');
this.fetchLinkedOrders();
this.fetchJournal();
} catch (e) {
alert("Fehler beim Verknüpfen");
}
},
async unlinkOrder(id) {
await axios.get('/WarehouseProject/unlinkOrder?id=' + id);
this.fetchLinkedOrders();
this.fetchJournal();
},
async postJournal() {
if (!this.newJournalMessage.trim()) return;
await axios.post('/WarehouseProject/createJournalEntry', { projectId: this.project.id, message: this.newJournalMessage });
this.newJournalMessage = '';
this.fetchJournal();
}
}
});
@@ -0,0 +1,146 @@
Vue.component('warehouse-project-modal', {
props: {
id: { type: [String, Number], required: true },
},
data() {
return {
window: window,
loading: false,
allUsers: [],
project: {
title: '',
description: '',
startDate: null,
endDate: null,
storageLocation: '',
externalTeam: '',
internalTeam: [], // Array of user IDs
status: 'new',
financials: 0.00
},
statusOptions: [
{ text: 'Neu', value: 'new' },
{ text: 'In Bearbeitung', value: 'wip' },
{ text: 'Abgeschlossen', value: 'finished' },
{ text: 'Storniert', value: 'cancelled' }
]
};
},
//language=Vue
template: `
<tt-modal :show="true"
:delete="false"
:title="id === 'create' ? 'Projekt erstellen' : 'Projekt bearbeiten'"
@update:show="$emit('close')"
@submit="submit"
:save-loading="loading"
>
<div style="width: 99%">
<tt-input v-model="project.title" label="Bezeichnung *" sm row required/>
<div class="row">
<div class="col-md-6">
<tt-input v-model="project.startDate" label="Startdatum *" type="date" sm row required/>
</div>
<div class="col-md-6">
<tt-input v-model="project.endDate" label="Enddatum *" type="date" sm row required/>
</div>
</div>
<tt-input v-model="project.storageLocation" label="Lagerort" sm row/>
<div class="form-group row">
<label class="col-sm-3 col-form-label col-form-label-sm">Status *</label>
<div class="col-sm-9">
<select class="form-control form-control-sm" v-model="project.status" required>
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.text }}</option>
</select>
</div>
</div>
<!-- Internal Team Select (Create Only or simplified list) -->
<div class="form-group row" v-if="id === 'create'">
<label class="col-sm-3 col-form-label col-form-label-sm">Internes Team</label>
<div class="col-sm-9">
<select class="form-control form-control-sm" v-model="project.internalTeam" multiple style="height: 100px;">
<option v-for="u in allUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
<small class="text-muted">Strg+Klick für Mehrfachauswahl</small>
</div>
</div>
<tt-textarea v-model="project.externalTeam" label="Externes Team" sm row placeholder="Namen, Firmen, Kontakte..."/>
<tt-textarea v-model="project.description" label="Beschreibung" sm row rows="4"/>
<div v-if="id !== 'create'" class="form-group row">
<label class="col-sm-3 col-form-label col-form-label-sm">Finanzen</label>
<div class="col-sm-9">
<input type="text" class="form-control form-control-sm" :value="formatPrice(project.financials)" disabled>
<small class="text-muted">Wird automatisch berechnet.</small>
</div>
</div>
</div>
</tt-modal>
`,
async mounted() {
this.fetchUsers();
if (this.id !== 'create') {
this.loading = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/getById`, { params: { id: this.id } });
if (data) {
this.project = { ...this.project, ...data };
// Ensure dates are in YYYY-MM-DD format for input type="date"
if (this.project.startDate) this.project.startDate = this.formatDateForInput(this.project.startDate);
if (this.project.endDate) this.project.endDate = this.formatDateForInput(this.project.endDate);
}
} catch (e) {
console.error(e);
window.notify('error', 'Projekt konnte nicht geladen werden.');
} finally {
this.loading = false;
}
}
},
methods: {
async fetchUsers() {
try {
const res = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/getUsers`);
this.allUsers = res.data;
} catch(e) { console.error(e); }
},
formatDateForInput(timestamp) {
if (!timestamp) return null;
return new Date(timestamp * 1000).toISOString().split('T')[0];
},
formatPrice(val) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val || 0);
},
async submit() {
if (!this.project.title || !this.project.startDate || !this.project.endDate) {
return window.notify('error', 'Bitte füllen Sie alle Pflichtfelder aus (*).');
}
this.loading = true;
try {
const payload = { ...this.project };
if (payload.startDate) payload.startDate = Math.floor(new Date(payload.startDate).getTime() / 1000);
if (payload.endDate) payload.endDate = Math.floor(new Date(payload.endDate).getTime() / 1000);
const url = `${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/${this.id === 'create' ? 'create' : 'update'}`;
const response = await axios.post(url, payload);
if (response.data.success) {
this.$emit('close');
window.notify('success', response.data.message || 'Gespeichert.');
} else {
window.notify('error', response.data.message || 'Fehler beim Speichern.');
}
} catch (e) {
console.error(e);
window.notify('error', 'Ein Systemfehler ist aufgetreten.');
} finally {
this.loading = false;
}
}
}
});
@@ -44,6 +44,12 @@ window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [
"key": "print",
"title": "Drucken",
"class": "fas fa-print text-primary",
},
{
"key": "createManualInvoice",
"title": "Rechnung erstellen",
"class": "fas fa-file-invoice text-primary",
"condition": (row) => row.status === 'accepted',
}
]
@@ -547,6 +553,7 @@ Vue.component('warehouse-shipping-note', {
@status_to_cancelled="changeStatus($event.id, 'cancelled')"
@status_to_new="changeStatus($event.id, 'new')"
@add_log="addLogModalId = $event.id"
@createManualInvoice="createManualInvoice($event)"
@edit="shippingNoteModalId = $event.id"
ref="table">
@@ -678,6 +685,30 @@ Vue.component('warehouse-shipping-note', {
this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
}
},
async createManualInvoice(row) {
try {
// Fetch shipping note with enriched article data
const res = await axios.get(
`${window.TT_CONFIG.BASE_PATH}/WarehouseShippingNote/getShippingNoteForInvoice`,
{ params: { id: row.id } }
);
if (!res.data.success) {
window.notify('error', res.data.message || 'Fehler beim Laden der Lieferscheindaten');
return;
}
// Store in localStorage as array (to match WarehouseOrder pattern)
localStorage.setItem('ManualInvoice_create', JSON.stringify([res.data.data]));
// Navigate to ManualInvoice module
window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice`;
} catch (error) {
console.error('Error creating manual invoice:', error);
window.notify('error', 'Fehler beim Erstellen der Rechnung');
}
},
}
})
@@ -277,20 +277,83 @@ Vue.component('warehouse-shipping-note-modal', {
this.loading = false;
},
async updateCarId(userId) {
if (!userId) return this.$refs.hoursManager.updateField('carId', null);
if (!userId) {
this.$refs.hoursManager.updateField('carId', null);
this.$refs.hoursManager.updateField('kilometerCount', null);
return;
}
this.hoursLoading = true;
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + userId);
if (data.status === 'USER_NO_CAR') this.$refs.hoursManager.updateField('carId', null);
else this.$refs.hoursManager.updateField('carId', data.id);
this.hoursLoading = false;
try {
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + userId);
if (data.status === 'USER_NO_CAR') {
this.$refs.hoursManager.updateField('carId', null);
this.$refs.hoursManager.updateField('kilometerCount', null);
this.hoursLoading = false;
} else {
this.$refs.hoursManager.updateField('carId', data.id);
// Trigger kilometer calculation after car is set
// Note: updateKilometer will set hoursLoading = false when done
await this.$nextTick();
await this.updateKilometer(data.id);
}
} catch (error) {
console.error('Error fetching car for user:', error);
window.notify('error', 'Fehler beim Laden des Fahrzeugs');
this.hoursLoading = false;
}
},
async updateKilometer(carId) {
if (!carId || carId === '0' && this.$refs.hoursManager) return this.$refs.hoursManager?.updateField('kilometerCount', null);
async updateKilometer(carIdParam) {
if (!this.$refs.hoursManager) {
this.hoursLoading = false;
return;
}
// Get current form data from the hours manager
const currentFormData = this.$refs.hoursManager.formData || {};
// Use passed carId, or fall back to current form state
const carId = carIdParam !== undefined ? carIdParam : currentFormData.carId;
const externalCar = currentFormData.externalCar;
// Check if we have a car selected OR external car is checked
const hasVehicle = (carId && carId !== '0' && carId !== 0) || externalCar;
if (!hasVehicle) {
this.$refs.hoursManager.updateField('kilometerCount', null);
this.hoursLoading = false;
return;
}
// Check if delivery address is complete enough for distance calculation
const deliveryAddr = this.shippingNote.deliveryAddressLine?.trim();
const deliveryPLZ = this.shippingNote.deliveryAddressPLZ?.trim();
const deliveryCity = this.shippingNote.deliveryAddressCity?.trim();
if (!deliveryAddr || !deliveryPLZ || !deliveryCity) {
this.hoursLoading = false;
return; // Don't calculate without complete address
}
this.hoursLoading = true;
const delAddr = this.shippingNote.deliveryAddressLine + ' ' + this.shippingNote.deliveryAddressPLZ + ' ' + this.shippingNote.deliveryAddressCity;
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance?from=Xinon%20GmbH&to=' + delAddr);
this.$refs.hoursManager.updateField('kilometerCount', data.distance);
this.hoursLoading = false;
try {
const delAddr = `${deliveryAddr} ${deliveryPLZ} ${deliveryCity}`;
const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance', {
params: {
from: 'Xinon GmbH',
to: delAddr
}
});
if (data.success === false) {
window.notify('error', data.message || 'Fehler bei der Kilometerberechnung');
} else if (data.distance !== undefined) {
this.$refs.hoursManager.updateField('kilometerCount', data.distance);
}
} catch (error) {
console.error('Error calculating distance:', error);
window.notify('error', 'Fehler bei der Kilometerberechnung');
} finally {
this.hoursLoading = false;
}
}
}
})
@@ -0,0 +1,224 @@
/* Stocktake Progress Fullscreen Modal */
.stocktake-progress-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1050;
background: #f5f6f8;
display: flex;
flex-direction: column;
}
/* Header - dark background with white text */
.stocktake-progress-header {
background: #343a40;
color: #ffffff !important;
padding: 0.75rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #007bff;
}
.stocktake-progress-header * {
color: #ffffff !important;
}
.stocktake-progress-header h4 {
font-size: 1.1rem;
font-weight: 600;
color: #ffffff !important;
margin: 0;
}
.stocktake-progress-header .badge {
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
margin-left: 0.75rem;
background: #ffffff !important;
color: #343a40 !important;
}
.stocktake-progress-header .btn-outline-light {
border-color: rgba(255,255,255,0.5);
color: #ffffff !important;
}
.stocktake-progress-header .btn-outline-light:hover {
background: rgba(255,255,255,0.1);
}
/* Body */
.stocktake-progress-body {
flex: 1;
padding: 1rem 1.5rem;
overflow-y: auto;
}
/* Stat cards - clean white cards with colored left border */
.stocktake-progress-fullscreen .stat-card {
background: #ffffff !important;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-left: 4px solid;
}
.stocktake-progress-fullscreen .stat-card .card-body {
padding: 0.75rem 1rem;
}
.stocktake-progress-fullscreen .stat-card h6 {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.2rem !important;
color: #6c757d !important;
opacity: 1 !important;
}
.stocktake-progress-fullscreen .stat-card h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0 !important;
color: #212529 !important;
}
.stocktake-progress-fullscreen .stat-card i.fa-2x {
font-size: 1.25rem;
opacity: 0.4;
}
/* Card border colors */
.stocktake-progress-fullscreen .stat-card.card-primary {
border-left-color: #007bff;
}
.stocktake-progress-fullscreen .stat-card.card-primary i {
color: #007bff;
}
.stocktake-progress-fullscreen .stat-card.card-success {
border-left-color: #28a745;
}
.stocktake-progress-fullscreen .stat-card.card-success i {
color: #28a745;
}
.stocktake-progress-fullscreen .stat-card.card-info {
border-left-color: #17a2b8;
}
.stocktake-progress-fullscreen .stat-card.card-info i {
color: #17a2b8;
}
.stocktake-progress-fullscreen .stat-card.card-warning {
border-left-color: #ffc107;
}
.stocktake-progress-fullscreen .stat-card.card-warning i {
color: #ffc107;
}
.stocktake-progress-fullscreen .stat-card.card-secondary {
border-left-color: #6c757d;
}
.stocktake-progress-fullscreen .stat-card.card-secondary i {
color: #6c757d;
}
.stocktake-progress-fullscreen .stat-card.card-danger {
border-left-color: #dc3545;
}
.stocktake-progress-fullscreen .stat-card.card-danger i {
color: #dc3545;
}
/* Main content card */
.stocktake-progress-fullscreen .card {
border: 1px solid #e0e0e0;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-radius: 6px;
}
.stocktake-progress-fullscreen .card-header {
padding: 0.625rem 1rem;
border-bottom: 1px solid #e9ecef;
background: #ffffff;
}
.stocktake-progress-fullscreen .card-header h5 {
font-size: 0.9rem;
font-weight: 600;
color: #212529;
}
.stocktake-progress-fullscreen .card-body {
padding: 0;
}
/* Table styling */
.stocktake-progress-fullscreen .table {
margin-bottom: 0;
font-size: 0.85rem;
}
.stocktake-progress-fullscreen .table th {
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
padding: 0.5rem 0.75rem;
white-space: nowrap;
background: #f8f9fa;
}
.stocktake-progress-fullscreen .table td {
padding: 0.5rem 0.75rem;
vertical-align: middle;
color: #212529;
}
.stocktake-progress-fullscreen .table tbody tr:hover {
background-color: #f8f9fa;
}
.stocktake-progress-fullscreen code {
background: #e9ecef;
padding: 0.15rem 0.35rem;
border-radius: 3px;
font-size: 0.8rem;
color: #007bff;
}
/* Info bar styling */
.stocktake-progress-fullscreen .info-bar {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.85rem;
color: #212529;
}
.stocktake-progress-fullscreen .info-bar .refresh-info {
color: #6c757d;
font-size: 0.8rem;
}
/* Empty state styling */
.stocktake-progress-fullscreen .fa-inbox {
opacity: 0.3;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.stocktake-progress-header {
padding: 0.75rem 1rem;
flex-direction: column;
gap: 0.75rem;
}
.stocktake-progress-body {
padding: 0.75rem;
}
}
@@ -0,0 +1,401 @@
// Stocktake Progress Modal Component - Fullscreen
Vue.component('stocktake-progress-modal', {
props: {
show: { type: Boolean, default: false },
id: { type: Number, default: null }
},
//language=Vue
template: `
<div v-if="show" class="stocktake-progress-fullscreen">
<div class="stocktake-progress-header">
<div class="d-flex align-items-center">
<h4 class="mb-0">
<i class="fas fa-clipboard-check me-2"></i>
Inventur Fortschritt
<span v-if="stocktake" class="badge bg-light text-dark">{{ stocktake.stocktakeNumber }}</span>
</h4>
</div>
<button class="btn btn-sm btn-outline-light" @click="close">
<i class="fas fa-times me-1"></i> Schließen
</button>
</div>
<div class="stocktake-progress-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status" style="width: 2.5rem; height: 2.5rem;">
<span class="visually-hidden">Laden...</span>
</div>
<p class="mt-2 text-muted mb-0">Lade Inventurdaten...</p>
</div>
<div v-else-if="stocktake" class="h-100 d-flex flex-column">
<!-- Stats Cards -->
<div class="row g-2 mb-3">
<div class="col-md-2">
<div class="card stat-card card-primary h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Inventur</h6>
<h5 class="card-title mb-0">{{ stocktake.title }}</h5>
</div>
<i class="fas fa-clipboard-list fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stat-card card-success h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Gescannte Artikel</h6>
<h5 class="card-title mb-0">{{ stocktake.totalScannedItems }}</h5>
</div>
<i class="fas fa-barcode fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stat-card card-info h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Lagerort</h6>
<h5 class="card-title mb-0">{{ stocktake.locationName }}</h5>
</div>
<i class="fas fa-warehouse fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card card-warning h-100">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Gesamtwert (Einkauf)</h6>
<h5 class="card-title mb-0">{{ formatCurrency(summary.totalValue) }}</h5>
</div>
<i class="fas fa-euro-sign fa-lg"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100" :class="statusCardClass">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="card-subtitle small">Status</h6>
<h5 class="card-title mb-0">{{ statusText }}</h5>
</div>
<i class="fas fa-info-circle fa-lg"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Started At Info Bar -->
<div v-if="stocktake.startedAt" class="info-bar d-flex align-items-center mb-3">
<i class="fas fa-clock me-2 text-primary"></i>
<span>Gestartet am: <strong>{{ stocktake.startedAt }}</strong></span>
<span class="ms-auto refresh-info">
<i class="fas fa-sync-alt me-2" :class="{ 'fa-spin': refreshing }"></i>
<span v-if="refreshing">Aktualisiere...</span>
<span v-else>Nächste Aktualisierung in {{ countdown }}s</span>
</span>
</div>
<!-- Scanned Items Table -->
<div class="card flex-grow-1">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-list me-2"></i>
Gescannte Artikel
</h5>
<span class="badge bg-secondary">{{ items.length }} Einträge</span>
</div>
<div class="card-body" style="overflow-y: auto; max-height: calc(100vh - 320px);">
<table class="table table-striped table-hover">
<thead class="sticky-top bg-light">
<tr>
<th style="width: 120px;">Artikel-Nr.</th>
<th>Artikel</th>
<th class="text-end" style="width: 100px;">Einzelpreis</th>
<th class="text-end" style="width: 80px;">Menge</th>
<th class="text-end" style="width: 110px;">Gesamtpreis</th>
<th style="width: 80px;">Regal</th>
<th style="width: 80px;">Fach</th>
<th style="width: 140px;">Gescannt am</th>
<th style="width: 130px;">Gescannt von</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id" :class="{ 'table-secondary text-decoration-line-through': item.isOverwritten }">
<td><code class="text-primary">{{ item.articleNumber }}</code></td>
<td>{{ item.articleTitle }}</td>
<td class="text-end">{{ formatCurrency(item.unitPrice) }}</td>
<td class="text-end"><strong class="text-success">{{ item.countedQuantity }}</strong></td>
<td class="text-end"><strong>{{ formatCurrency(item.lineTotal) }}</strong></td>
<td>{{ item.rack || '-' }}</td>
<td>{{ item.shelf || '-' }}</td>
<td class="text-nowrap">{{ item.scannedAt || '-' }}</td>
<td>{{ item.scannedBy || '-' }}</td>
<td>
<span v-if="item.isOverwritten" class="badge bg-warning text-dark">Überschrieben</span>
</td>
</tr>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="4" class="text-end">Summe:</th>
<th class="text-end text-primary">{{ formatCurrency(summary.totalValue) }}</th>
<th colspan="5"></th>
</tr>
</tfoot>
</table>
<!-- Empty State -->
<div v-if="items.length === 0" class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-2"></i>
<h6 class="text-muted">Noch keine Artikel gescannt</h6>
<p class="text-muted small mb-0">
Scannen Sie Artikel mit der PWA-App, um sie hier zu sehen.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
loading: false,
refreshing: false,
stocktake: null,
items: [],
summary: { totalValue: 0, totalQuantity: 0 },
refreshInterval: null,
countdownInterval: null,
countdown: 5,
};
},
computed: {
statusText() {
if (!this.stocktake) return '';
const statusMap = {
'planned': 'Geplant',
'in_progress': 'In Bearbeitung',
'completed': 'Abgeschlossen',
'cancelled': 'Abgebrochen'
};
return statusMap[this.stocktake.status] || this.stocktake.status;
},
statusCardClass() {
if (!this.stocktake) return 'card-secondary';
const classMap = {
'planned': 'card-secondary',
'in_progress': 'card-warning',
'completed': 'card-success',
'cancelled': 'card-danger'
};
return classMap[this.stocktake.status] || 'card-secondary';
}
},
watch: {
show(newVal) {
if (newVal && this.id) {
document.body.style.overflow = 'hidden';
this.loadProgress();
this.startAutoRefresh();
} else {
document.body.style.overflow = '';
this.stopAutoRefresh();
}
},
id(newVal) {
if (this.show && newVal) {
this.loadProgress();
}
}
},
methods: {
close() {
this.$emit('update:show', false);
},
async loadProgress() {
if (!this.id) return;
if (!this.stocktake) {
this.loading = true;
} else {
this.refreshing = true;
}
try {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/getProgress`, {
params: { id: this.id }
});
if (response.data.success) {
this.stocktake = response.data.stocktake;
this.items = response.data.items;
this.summary = response.data.summary || { totalValue: 0, totalQuantity: 0 };
}
} catch (error) {
console.error('Failed to load progress:', error);
} finally {
this.loading = false;
this.refreshing = false;
this.countdown = 5;
}
},
startAutoRefresh() {
this.stopAutoRefresh();
this.countdown = 5;
// Countdown timer
this.countdownInterval = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
}
}, 1000);
// Refresh data
this.refreshInterval = setInterval(() => {
if (this.show && this.id) {
this.loadProgress();
}
}, 5000);
},
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
},
formatCurrency(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
}
},
beforeDestroy() {
document.body.style.overflow = '';
this.stopAutoRefresh();
}
});
// Main Stocktake Component
Vue.component('warehouse-stocktake', {
//language=Vue
template: `
<tt-card>
<tt-table-crud
@openHistory="historyModal = true; historyModalId = $event.id"
@startStocktake="startStocktake($event)"
@viewProgress="viewProgress($event)"
@completeStocktake="completeStocktake($event)"
@applyToStock="applyToStock($event)"
@exportReport="exportReport($event)"
>
<template v-slot:actions="{ row, actions }">
<template v-for="action in actions">
<!-- Hide start button if not planned -->
<span v-if="action.key === 'startStocktake' && row.rawStatus !== 'planned'" :key="action.key"></span>
<!-- Hide complete button if not in_progress -->
<span v-else-if="action.key === 'completeStocktake' && row.rawStatus !== 'in_progress'" :key="action.key"></span>
<!-- Hide apply button if not completed -->
<span v-else-if="action.key === 'applyToStock' && row.rawStatus !== 'completed'" :key="action.key"></span>
<!-- Show other actions normally -->
<button v-else
:key="action.key"
class="btn btn-sm btn-link p-1"
:title="action.title"
@click="$emit(action.key, row)">
<i :class="action.class"></i>
</button>
</template>
</template>
</tt-table-crud>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
<stocktake-progress-modal :show.sync="progressModal" :id="progressModalId"/>
</tt-card>
`,
data() {
return {
historyModal: false,
historyModalId: null,
progressModal: false,
progressModalId: null,
};
},
methods: {
async startStocktake(event) {
const row = event;
if (row.rawStatus !== 'planned') {
window.notify('warning', 'Inventur kann nur im Status "Geplant" gestartet werden');
return;
}
if (!confirm('Möchten Sie diese Inventur wirklich starten?')) {
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/startStocktake`, {
id: row.id
});
if (response.data.success) {
window.notify('success', response.data.message);
window.dispatchEvent(new Event('refreshTable'));
} else {
window.notify('error', response.data.message);
}
} catch (error) {
window.notify('error', 'Fehler beim Starten der Inventur');
}
},
viewProgress(event) {
this.progressModalId = event.id;
this.progressModal = true;
},
async completeStocktake(event) {
const row = event;
if (!confirm('Möchten Sie diese Inventur wirklich abschließen?')) {
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/completeStocktake`, {
id: row.id
});
if (response.data.success) {
window.notify('success', response.data.message);
window.dispatchEvent(new Event('refreshTable'));
} else {
window.notify('error', response.data.message);
}
} catch (error) {
window.notify('error', 'Fehler beim Abschließen der Inventur');
}
},
applyToStock(event) {
window.notify('warning', 'Aktuell noch nicht möglich');
},
exportReport(event) {
window.open(`${window.TT_CONFIG.BASE_PATH}/WarehouseStocktake/exportReport?id=${event.id}`, '_blank');
}
}
});
@@ -0,0 +1,242 @@
// WorkorderMphAdmin.js
Vue.component('workorder-mph-admin', {
template: `
<tt-card>
<tt-table-crud ref="table" :crud-config="crudConfig">
<template v-slot:hausnummerinfo="{ row }">
<span class="small">{{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template>, {{ row.plz }} {{ row.city }}</span>
</template>
<template v-slot:status="{ row }">
<traffic-light-mph :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
</template>
<template v-slot:companyname="{ row }">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1">
<div v-if="editingWorkorderId === row.id">
<div v-if="companiesLoading" class="spinner-border spinner-border-sm"></div>
<tt-select v-else :options="companies" :value="row.companyId"
@input="assignCompany(row, $event)" @blur="editingWorkorderId = null"
@keydown.esc.native="editingWorkorderId = null"
placeholder="Firma zuweisen..." sm no-form-group/>
</div>
<div v-else-if="row.status === 'new'">
<tt-select :options="companies" :value="row.companyId"
@input="assignCompany(row, $event)" @focus="loadCompanies"
placeholder="Firma zuweisen..." sm no-form-group/>
</div>
<div v-else><span>{{ row.companyName || 'N/A' }}</span></div>
</div>
<div style="display: grid; grid-template-columns: repeat(2, auto); gap: 0px; padding-left: 8px;">
<tt-button v-if="!['completed', 'new'].includes(row.status)" icon="fas fa-edit"
@click="startCompanyEdit(row)" additional-class="btn-link workorder-mph-button"
title="Zuweisung ändern"/>
<tt-button v-if="!['completed', 'cancelled'].includes(row.status)" icon="fas fa-ban text-danger"
@click="cancelWorkorderModalData = row" additional-class="btn-link workorder-mph-button"
title="Auftrag stornieren"/>
</div>
</div>
</template>
<template v-slot:additionalinfo="{ row }">
<div v-if="editingAdditionalInfoId === row.id">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" rows="3" no-form-group sm ref="editTextarea"/>
<div class="mt-2 d-flex justify-content-end">
<tt-button text="Abbrechen" @click="cancelEdit" sm additional-class="btn-secondary mr-2"/>
<tt-button text="Speichern" @click="updateAdditionalInfo(row)" sm additional-class="btn-success"/>
</div>
</div>
<div v-else class="d-flex align-items-start">
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
<tt-button icon="fas fa-edit" @click="startAdditionalInfoEdit(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Zusatz-Info bearbeiten"/>
</div>
</template>
<template v-slot:deadlinedate="{ row }">
<div v-if="editingDeadlineId === row.id">
<tt-date-picker :value="row.deadlineDate" :date-range="false"
@input="updateDeadline(row, $event)" @blur="editingDeadlineId = null"
sm no-form-group/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ formatDate(row.deadlineDate) }}</span>
<tt-button icon="fas fa-edit" @click="editingDeadlineId = row.id"
additional-class="btn-link btn-sm p-0 ml-2" title="Deadline ändern"/>
</div>
</template>
<template v-slot:appointmentdate="{ row }">{{ formatDate(row.appointmentDate, true) }}</template>
<template v-slot:expandedRow="{ row }">
<workorder-mph-data-provider :workorder-mph-id="row.id" v-slot="{ docs, journals, refresh }">
<div class="workorder-mph-expanded-wrapper">
<div class="row g-2">
<!-- Left Column (1/4): Docs Checkbox, Journal, Review -->
<div class="col-xl-3 col-lg-4">
<div class="mph-details-stack">
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="true" @refresh="refresh"/>
<workorder-mph-journal :journals="journals" :workorder-mph-id="row.id" :is-admin="true" @refresh="refresh"/>
<workorder-mph-admin-review :docs="docs" :workorder-mph-id="row.id" @refresh="refresh"/>
</div>
</div>
<!-- Right Column (3/4): Wohneinheiten, Documents -->
<div class="col-xl-9 col-lg-8">
<div class="mph-details-stack">
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="true" @refresh="refresh"/>
<workorder-mph-documents :docs="docs" :workorder-mph-id="row.id" :is-admin="true" @refresh="refresh"/>
</div>
</div>
</div>
</div>
</workorder-mph-data-provider>
</template>
</tt-table-crud>
<tt-modal v-if="cancelWorkorderModalData" :show.sync="cancelWorkorderModalData"
title="Auftrag stornieren" @submit="cancelWorkorder">
<p>Soll der Auftrag <strong>#{{ cancelWorkorderModalData.id }}</strong> wirklich storniert werden?</p>
<tt-textarea label="Grund (optional)" v-model="cancelWorkorderModalData.reason" sm row/>
</tt-modal>
</tt-card>
`,
data() {
return {
window,
editingWorkorderId: null,
editingDeadlineId: null,
editingAdditionalInfoId: null,
tempAdditionalInfo: '',
companies: [],
companiesLoading: false,
cancelWorkorderModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled', 'archived'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
return 'tt-mph-workorder-ontrack';
}
}
}
},
methods: {
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp, withTime = false) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
},
async loadCompanies() {
if (this.companies.length > 0) return;
this.companiesLoading = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/getCompanies`);
this.companies = data;
} catch (e) {
window.notify('error', 'Firmenliste konnte nicht geladen werden.');
} finally {
this.companiesLoading = false;
}
},
async startCompanyEdit(row) {
await this.loadCompanies();
this.editingWorkorderId = row.id;
},
async assignCompany(workorder, companyId) {
if (!companyId) {
this.editingWorkorderId = null;
return;
}
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/assignWorkorder`, {
workorderId: workorder.id,
companyId: companyId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
this.editingWorkorderId = null;
},
async updateDeadline(workorder, newDate) {
if (!newDate) {
this.editingDeadlineId = null;
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateDeadline`, {
workorderId: workorder.id,
deadlineDate: newDate
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.editingDeadlineId = null;
}
},
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';
this.$nextTick(() => this.$refs.editTextarea?.$el.querySelector('textarea').focus());
},
cancelEdit() {
this.editingAdditionalInfoId = null;
this.tempAdditionalInfo = '';
},
async updateAdditionalInfo(row) {
if (row.additionalInfo === this.tempAdditionalInfo) {
this.cancelEdit();
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateAdditionalInfo`, {
workorderMphId: row.id,
additionalInfo: this.tempAdditionalInfo
});
if (data.success) {
window.notify('success', data.message);
row.additionalInfo = data.newInfo;
} else {
window.notify('error', data.message || 'Update fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.cancelEdit();
}
},
async cancelWorkorder() {
const { id, reason } = this.cancelWorkorderModalData;
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/cancelWorkorder`, {
workorderId: id,
reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.cancelWorkorderModalData = null;
} else {
window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
}
}
}
});
@@ -0,0 +1,472 @@
/*
* CSS for WorkorderMph Table Row Highlighting
*/
/* Urgent: Deadline passed or less than 1 week away */
.table-hover .tt-mph-workorder-urgent:hover,
.tt-mph-workorder-urgent {
background-color: #fbe9e7 !important; /* Soft Red */
}
/* Medium: Deadline less than 3 weeks away */
.table-hover .tt-mph-workorder-medium:hover,
.tt-mph-workorder-medium {
background-color: #fff8e1 !important; /* Soft Yellow */
}
/* On Track: Deadline more than 3 weeks away */
.table-hover .tt-mph-workorder-ontrack:hover,
.tt-mph-workorder-ontrack {
background-color: #e8f5e9 !important; /* Soft Green */
}
/* Irrelevant: No deadline or status makes it not applicable */
.table-hover .tt-mph-workorder-irrelevant:hover,
.tt-mph-workorder-irrelevant {
background-color: #fafafa !important; /* Very light grey */
}
.table-hover .tt-mph-workorder-high:hover,
.tt-mph-workorder-high {
background-color: #f8d7da !important; /* A slightly more intense red for high priority issues */
}
/*
* Wohneinheit Manager - Dense Table Layout
*/
.wohneinheit-manager-container {
min-height: 500px;
max-height: 800px;
overflow-y: auto;
border-bottom: 1px solid #dee2e6;
}
.wohneinheit-manager .we-splice .we-splice-checkbox .checkmark:after {
left: 5px;
top: 1px;
}
.wohneinheit-manager .we-table {
display: table;
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.wohneinheit-manager .we-header {
display: table-header-group;
}
.wohneinheit-manager .we-header-row {
display: table-row;
background-color: #f8f9fa;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.wohneinheit-manager .we-header .we-cell {
display: table-cell;
padding: 8px 6px;
color: #495057;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 2px solid #dee2e6;
background-color: #f8f9fa; /* Needed for sticky */
}
.wohneinheit-manager .we-row {
display: table-row;
border-bottom: 1px solid #e9ecef;
transition: background-color 0.15s ease;
}
.wohneinheit-manager .we-row:hover {
background-color: #f8f9fa;
}
.wohneinheit-manager .we-row.locked-row {
background-color: #e7f5ff;
}
.wohneinheit-manager .we-row.locked-row:hover {
background-color: #d0ebff;
}
.wohneinheit-manager .we-cell {
display: table-cell;
padding: 6px 6px;
vertical-align: top;
font-size: 0.85rem;
}
.wohneinheit-manager .we-zusatz {
width: 30%;
}
.wohneinheit-manager .we-tuer {
width: 15%;
}
.wohneinheit-manager .we-status {
width: 20%;
}
.wohneinheit-manager .we-splice {
width: 12%;
text-align: center;
}
.wohneinheit-manager .we-documents {
width: 12%;
text-align: center;
}
.wohneinheit-manager .we-actions {
width: 8%;
text-align: center;
}
/* Input Sizing for Dense View */
.wohneinheit-manager .form-control-sm {
height: calc(1.4em + 0.5rem + 2px);
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.wohneinheit-manager .we-splice .we-splice-checkbox .checkmark {
width: 18px;
height: 18px;
}
.wohneinheit-manager .badge {
font-size: 0.65rem;
padding: 2px 4px;
}
.workorder-mph-button {
padding: 2px !important;
}
/*
* Custom Checkboxes - Compact
*/
.custom-checkbox-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.custom-checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
}
.custom-checkbox-item .checkmark {
position: relative;
height: 20px;
width: 20px;
background-color: #fff;
border: 2px solid #adb5bd;
border-radius: 4px;
margin-right: 0;
transition: all 0.2s ease;
}
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark {
background-color: #28a745;
border-color: #28a745;
}
.custom-checkbox-item .checkmark:after {
content: "";
position: absolute;
display: none;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark:after {
display: block;
}
/*
* Expanded View Layout
*/
.workorder-mph-expanded-wrapper {
background: #f1f3f5;
padding: 16px;
border-radius: 8px;
margin: -8px;
}
.workorder-mph-expanded-wrapper .row.g-2 {
margin-right: -0.5rem;
margin-left: -0.5rem;
}
.workorder-mph-expanded-wrapper .row.g-2 > .col,
.workorder-mph-expanded-wrapper .row.g-2 > [class*="col-"] {
padding-right: 0.5rem;
padding-left: 0.5rem;
}
/* Card Styling */
.workorder-mph-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 16px;
display: flex;
flex-direction: column;
}
.workorder-mph-card:last-child {
margin-bottom: 0;
}
.workorder-mph-card-header {
padding: 8px 12px;
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
border-bottom: 1px solid #e9ecef;
font-weight: 600;
font-size: 0.85rem;
color: #495057;
display: flex;
align-items: center;
justify-content: flex-start; /* Changed from space-between */
}
.workorder-mph-card-header > div:last-child,
.workorder-mph-card-header > span:last-child:not(:first-child) {
margin-left: auto;
}
/* Drag and Drop Zone */
.mph-drop-zone {
border: 2px dashed #ced4da;
border-radius: 6px;
padding: 16px;
text-align: center;
background: #f8f9fa;
transition: all 0.2s ease;
cursor: pointer;
position: relative;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.mph-drop-zone:hover,
.mph-drop-zone.dragging {
background: #e9ecef;
border-color: #007bff;
}
.mph-drop-zone input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 10;
}
.mph-drop-zone-icon {
font-size: 1.8rem;
color: #adb5bd;
margin-bottom: 8px;
transition: color 0.2s;
}
.mph-drop-zone:hover .mph-drop-zone-icon {
color: #007bff;
}
.mph-drop-zone-text {
color: #495057;
font-weight: 500;
font-size: 0.85rem;
}
.mph-drop-zone-hint {
color: #6c757d;
font-size: 0.75rem;
}
.mph-file-list {
margin-top: 10px;
text-align: left;
width: 100%;
}
.mph-file-item {
display: flex;
align-items: center;
padding: 6px 10px;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
margin-bottom: 4px;
font-size: 0.85rem;
}
.mph-file-item:last-child {
margin-bottom: 0;
}
.mph-file-item .remove-file {
margin-left: auto;
color: #dc3545;
cursor: pointer;
padding: 2px 6px;
}
.mph-file-item .remove-file:hover {
background: #ffe3e3;
border-radius: 4px;
}
.workorder-mph-card-header i {
margin-right: 8px;
color: #6c757d;
}
.workorder-mph-card-body {
padding: 12px;
flex: 1;
}
.workorder-mph-card-body-compact {
padding: 8px;
}
/* Documentation Checkboxes - Vertical List for Side Panel */
.mph-checkbox-vertical {
display: flex;
flex-direction: column;
gap: 4px;
}
.mph-checkbox-item {
display: flex;
align-items: center;
padding: 6px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.mph-checkbox-item:hover:not(.disabled) {
background: #e9ecef;
border-color: #ced4da;
}
.mph-checkbox-item .checkmark {
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 8px;
}
.mph-checkbox-item .checkmark:after {
left: 4px;
top: 0px;
width: 4px;
height: 8px;
}
.mph-checkbox-item .label {
font-size: 0.8rem;
line-height: 1.2;
}
/* Details Stack */
.mph-details-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Admin Review Checklist */
.mph-doc-checklist {
display: flex;
flex-direction: column;
gap: 4px;
}
.mph-doc-check-item {
display: flex;
align-items: center;
padding: 4px 8px;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.8rem;
border: 1px solid transparent;
}
.mph-doc-check-item.has-doc {
background-color: #f0fff4;
border-color: #c3e6cb;
}
/* Journal List */
.mph-journal-list {
max-height: 200px;
overflow-y: auto;
margin: -12px;
}
/* Custom Scrollbar for Journal */
.mph-journal-list::-webkit-scrollbar {
width: 8px;
}
.mph-journal-list::-webkit-scrollbar-track {
background: #f1f3f5;
border-radius: 4px;
}
.mph-journal-list::-webkit-scrollbar-thumb {
background: #adb5bd;
border-radius: 4px;
}
.mph-journal-list::-webkit-scrollbar-thumb:hover {
background: #868e96;
}
.mph-journal-item {
padding: 8px 12px;
border-bottom: 1px solid #f1f3f5;
font-size: 0.8rem;
}
.mph-journal-item-header {
font-size: 0.75rem;
color: #868e96;
margin-bottom: 2px;
}
/* File Gallery Adjustments */
.mph-docs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
@@ -0,0 +1,627 @@
// WorkorderMphBase.js - Shared components for WorkorderMph module
// Traffic light component
Vue.component('traffic-light-mph', {
props: ['deadline', 'status'],
computed: {
lightInfo() {
const deadlineDate = moment.unix(this.deadline);
const daysLeft = deadlineDate.diff(moment(), 'days');
if (['completed', 'new', 'cancelled'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
if (deadlineDate.isBefore(moment())) return { color: '#dc3545', title: 'Deadline überschritten' };
if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' };
if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' };
return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' };
}
},
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">&#9679;</span>`
});
// Data Provider Component
Vue.component('workorder-mph-data-provider', {
props: {
workorderMphId: { type: [Number, String], required: true }
},
data: () => ({
loading: true,
docs: [],
journals: []
}),
methods: {
async fetchData() {
this.loading = true;
try {
// Try to detect context (Admin or Company) based on URL or guess
const isCompany = window.location.pathname.includes('Company');
const basePath = isCompany ? '/WorkorderMphCompany' : '/WorkorderMphAdmin';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getDocumentation`, {
params: { workorderMphId: this.workorderMphId }
});
this.docs = data.docs || [];
this.journals = data.journals || [];
} catch (e) {
console.error("Failed to fetch data", e);
} finally {
this.loading = false;
}
}
},
mounted() {
this.fetchData();
},
render() {
return this.$scopedSlots.default({
loading: this.loading,
docs: this.docs,
journals: this.journals,
refresh: this.fetchData
});
}
});
// Wohneinheit Status Manager Component
Vue.component('wohneinheit-status-manager', {
props: {
workorderMphId: { type: Number, required: true },
isAdmin: { type: Boolean, default: false }
},
template: `
<div class="workorder-mph-card mph-wohneinheit-card">
<div class="workorder-mph-card-header">
<span><i class="fas fa-building"></i> Wohneinheiten</span>
<div>
<span v-if="loading" class="mr-2"><i class="fas fa-spinner fa-spin text-muted"></i></span>
<a v-if="isAdmin && hausnummerId" :href="'/AddressDB/view/?id=' + hausnummerId" target="_blank" class="small text-muted">
<i class="fas fa-external-link-alt mr-1"></i>AddressDB #{{ hausnummerId }}
</a>
</div>
</div>
<div v-if="wohneinheiten.length === 0 && !loading" class="workorder-mph-card-body">
<div class="alert alert-info mb-0 py-2 small">
<i class="fas fa-info-circle mr-1"></i> Keine Wohneinheiten gefunden.
</div>
</div>
<div v-else class="wohneinheit-manager-container">
<div class="wohneinheit-manager">
<div class="we-table">
<div class="we-header">
<div class="we-header-row">
<div class="we-cell we-zusatz">Zusatz / Kontakt</div>
<div class="we-cell we-tuer">Tür</div>
<div class="we-cell we-status">Status</div>
<div class="we-cell we-splice">Spleiß</div>
<div class="we-cell we-documents">Docs</div>
<div class="we-cell we-actions"></div>
</div>
</div>
<div v-for="we in wohneinheiten" :key="we.wohneinheitId" class="we-row" :class="{ 'locked-row': isLocked(we) }">
<div class="we-cell we-zusatz">
<div class="d-flex align-items-center mb-1">
<input type="text" class="form-control form-control-sm mr-1" v-model="we.zusatz"
:disabled="isLocked(we)" placeholder="Zusatz">
</div>
<!-- OAID: light red, vertical -->
<div v-if="we.oaid" class="text-danger small mb-1" style="font-weight: 500;">
<i class="fas fa-fingerprint mr-1"></i>OAID: {{ we.oaid }}
</div>
<div v-if="we.contact" class="text-muted small text-truncate" :title="we.contact">
<i class="fas fa-user mr-1"></i>{{ we.contact }}
</div>
<div v-else-if="we.preorderContact" class="text-info small text-truncate" :title="'VB: ' + we.preorderContact">
<i class="fas fa-user-check mr-1"></i>{{ we.preorderContact }}
</div>
</div>
<div class="we-cell we-tuer">
<input type="text" class="form-control form-control-sm" v-model="we.tuer"
:disabled="isLocked(we)" placeholder="Tür">
</div>
<div class="we-cell we-status">
<div v-if="isLocked(we)" class="locked-status small">
<i class="fas fa-lock mr-1"></i>{{ getStatusText(we.status) }}
</div>
<tt-select v-else v-model="we.status" :options="filteredStatusOptions" sm no-form-group/>
</div>
<div class="we-cell we-splice">
<label class="custom-checkbox-item we-splice-checkbox justify-content-center" title="Spleiß im HÜP/HAK erledigt">
<input type="checkbox" v-model="we.spliceCompleted" :disabled="isLocked(we)">
<span class="checkmark"></span>
</label>
</div>
<div class="we-cell we-documents">
<button class="btn btn-sm btn-outline-secondary position-relative workorder-mph-button" @click="openDocumentsModal(we)" title="Dokumente">
<i class="fas fa-camera"></i>
<span v-if="we.documentCount" class="badge badge-pill badge-danger position-absolute" style="top: -5px; right: -5px;">{{ we.documentCount }}</span>
</button>
</div>
<div class="we-cell we-actions">
<button class="btn btn-sm btn-primary workorder-mph-button" @click="saveWohneinheit(we)" :disabled="isLocked(we) || we.saving" title="Speichern">
<i v-if="we.saving" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Documents Modal -->
<tt-modal :show.sync="documentsModal.show" :title="'Dokumente für WE ' + documentsModal.zusatz" size="lg" :delete="false" submit-text="Schließen" @submit="documentsModal.show = false">
<div v-if="documentsModal.loading" class="text-center"><i class="fas fa-spinner fa-spin"></i></div>
<div v-else>
<div class="bg-light p-3 mb-3 border rounded">
<div class="row">
<div class="col-md-5">
<label class="small font-weight-bold mb-1">Datei(en)</label>
<div class="mph-drop-zone"
:class="{ 'dragging': documentsModal.isDragging }"
@dragover.prevent="documentsModal.isDragging = true"
@dragleave.prevent="documentsModal.isDragging = false"
@drop.prevent="handleDrop">
<input type="file" ref="weFileInput" @change="handleFileSelect" multiple accept="image/*,.pdf">
<i class="fas fa-cloud-upload-alt mph-drop-zone-icon"></i>
<div class="mph-drop-zone-text">Dateien auswählen</div>
</div>
<div v-if="documentsModal.files.length" class="mph-file-list">
<div v-for="(file, index) in documentsModal.files" :key="index" class="mph-file-item">
<span class="text-truncate small flex-grow-1">{{ file.name }}</span>
<span class="remove-file" @click="removeFile(index)"><i class="fas fa-times"></i></span>
</div>
</div>
</div>
<div class="col-md-7 d-flex flex-column justify-content-end">
<div class="form-group mb-2">
<label class="small font-weight-bold mb-1">Beschreibung</label>
<input type="text" class="form-control form-control-sm" v-model="documentsModal.uploadDescription" placeholder="Optional">
</div>
<tt-button text="Upload starten" @click="uploadWohneinheitDocument" :loading="documentsModal.uploading" additional-class="btn-primary btn-sm w-100" icon="fas fa-upload"/>
</div>
</div>
</div>
<tt-file-gallery :files="documentsModal.docs" :edit-mode="false" :delete-mode="!isAdmin" @delete-file="deleteWohneinheitDocument"/>
</div>
</tt-modal>
</div>
`,
data: () => ({
loading: true,
wohneinheiten: [],
statusOptions: [],
hausnummerId: null,
debounceTimers: {},
documentsModal: {
show: false,
loading: false,
uploading: false,
isDragging: false,
wohneinheitId: null,
zusatz: '',
docs: [],
files: [],
uploadDescription: ''
}
}),
computed: {
filteredStatusOptions() { return this.statusOptions.filter(opt => opt.code !== 300); }
},
methods: {
async fetchWohneinheiten() {
this.loading = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheiten`, { params: { workorderMphId: this.workorderMphId } });
this.wohneinheiten = data.wohneinheiten.map(we => ({ ...we, spliceCompleted: !!we.spliceCompleted, saving: false }));
this.statusOptions = data.statusOptions || [];
this.hausnummerId = data.hausnummerId;
} catch (e) { console.error(e); } finally { this.loading = false; }
},
isLocked(we) { const status = this.statusOptions.find(s => s.value === we.status); return status && status.code === 300; },
debouncedSave(we) {
we.saving = true;
if (this.debounceTimers[we.wohneinheitId]) clearTimeout(this.debounceTimers[we.wohneinheitId]);
this.debounceTimers[we.wohneinheitId] = setTimeout(() => this.saveWohneinheit(we), 1000);
},
async saveWohneinheit(we) {
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/updateWohneinheit`, {
workorderMphId: this.workorderMphId, wohneinheitId: we.wohneinheitId, status: we.status, spliceCompleted: we.spliceCompleted ? 1 : 0, tuer: we.tuer, zusatz: we.zusatz
});
this.$emit('wohneinheit-updated');
this.$emit('refresh');
} catch (e) { window.notify('error', 'Fehler beim Speichern'); } finally { we.saving = false; }
},
getStatusText(val) { const o = this.statusOptions.find(opt => opt.value === val); return o ? o.text : ''; },
async openDocumentsModal(we) {
this.documentsModal.show = true;
this.documentsModal.wohneinheitId = we.wohneinheitId;
this.documentsModal.zusatz = we.zusatz || we.oaid;
this.documentsModal.loading = true;
this.documentsModal.files = [];
this.documentsModal.uploadDescription = '';
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheitDocuments`, { params: { wohneinheitId: we.wohneinheitId } });
this.documentsModal.docs = data.docs || []; we.documentCount = this.documentsModal.docs.length;
} catch (e) { console.error(e); } finally { this.documentsModal.loading = false; }
},
handleFileSelect(event) {
this.addFiles(event.target.files);
},
handleDrop(event) {
this.documentsModal.isDragging = false;
this.addFiles(event.dataTransfer.files);
},
addFiles(fileList) {
if (!fileList.length) return;
this.documentsModal.files = [...this.documentsModal.files, ...Array.from(fileList)];
},
removeFile(index) {
this.documentsModal.files.splice(index, 1);
},
async uploadWohneinheitDocument() {
if (!this.documentsModal.files.length) return;
this.documentsModal.uploading = true;
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
let successCount = 0;
for (const file of this.documentsModal.files) {
const formData = new FormData();
formData.append('wohneinheitId', this.documentsModal.wohneinheitId);
formData.append('description', this.documentsModal.uploadDescription);
formData.append('file', file);
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/uploadWohneinheitDocument`, formData);
if (data.success) successCount++;
} catch (e) { console.error(e); }
}
if (successCount > 0) {
window.notify('success', `${successCount} Datei(en) hochgeladen`);
this.documentsModal.files = [];
this.documentsModal.uploadDescription = '';
this.$refs.weFileInput.value = '';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheitDocuments`, { params: { wohneinheitId: this.documentsModal.wohneinheitId } });
this.documentsModal.docs = data.docs || [];
// Update document count in wohneinheit list
const we = this.wohneinheiten.find(w => w.wohneinheitId === this.documentsModal.wohneinheitId);
if (we) we.documentCount = this.documentsModal.docs.length;
} else {
window.notify('error', 'Upload fehlgeschlagen');
}
this.documentsModal.uploading = false;
},
async deleteWohneinheitDocument(file) {
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/deleteWohneinheitDocument`, { documentationId: file.id });
window.notify('success', 'Gelöscht');
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheitDocuments`, { params: { wohneinheitId: this.documentsModal.wohneinheitId } });
this.documentsModal.docs = data.docs || [];
// Update document count in wohneinheit list
const we = this.wohneinheiten.find(w => w.wohneinheitId === this.documentsModal.wohneinheitId);
if (we) we.documentCount = this.documentsModal.docs.length;
} catch (e) { window.notify('error', 'Fehler beim Löschen'); }
}
},
mounted() { this.fetchWohneinheiten(); }
});
// Checkbox Documentation Component
Vue.component('checkbox-documentation', {
props: { workorderMphId: { type: Number, required: true }, isAdmin: { type: Boolean, default: false } },
template: `
<div class="workorder-mph-card">
<div class="workorder-mph-card-header">
<span><i class="fas fa-tasks"></i> Dokumentation</span>
<span v-if="saving" class="text-muted small"><i class="fas fa-sync fa-spin"></i></span>
</div>
<div class="workorder-mph-card-body-compact">
<div v-if="loading" class="text-center py-2"><i class="fas fa-spinner fa-spin text-muted"></i></div>
<div v-else class="mph-checkbox-vertical">
<label class="mph-checkbox-item" v-for="(label, key) in labels" :key="key">
<input type="checkbox" v-model="checkboxes[key]" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="label">{{ label }}</span>
</label>
</div>
</div>
</div>
`,
data: () => ({
loading: true, saving: false,
checkboxes: { easement: false, btb: false, fttxLocationSupplied: false, conduitToHuepLaid: false, huepMounted: false, dropCableAvailable: false },
labels: {
easement: 'Leitungsrecht', btb: 'Bautechnische Begehung', fttxLocationSupplied: 'FTTx Location versorgt',
conduitToHuepLaid: 'Leerrohr bis HAK', huepMounted: 'HAK montiert', dropCableAvailable: 'Dropkabel vorhanden'
}
}),
methods: {
async fetchCheckboxes() {
this.loading = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWorkorderById`, { params: { id: this.workorderMphId } });
for (let key in this.checkboxes) this.checkboxes[key] = !!data[key];
} catch (e) { console.error(e); } finally { this.loading = false; }
},
async saveCheckboxes() {
this.saving = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/updateCheckboxes`, { workorderMphId: this.workorderMphId, ...this.checkboxes });
this.$emit('refresh');
} catch (e) { window.notify('error', 'Speichern fehlgeschlagen'); } finally { this.saving = false; }
}
},
mounted() { this.fetchCheckboxes(); }
});
// Journal Component
Vue.component('workorder-mph-journal', {
props: { journals: { type: Array, default: () => [] }, workorderMphId: { type: [Number, String], required: true }, isAdmin: { type: Boolean, default: false } },
data: () => ({ newMessage: '', adding: false }),
computed: {
canSend() { return this.newMessage.trim().length >= 3; },
sendButtonClass() { return this.canSend ? 'btn-primary' : 'btn-secondary'; }
},
template: `
<div class="workorder-mph-card">
<div class="workorder-mph-card-header"><i class="fas fa-history"></i> Journal</div>
<div class="workorder-mph-card-body-compact">
<div class="mph-journal-list" v-if="journals.length">
<div v-for="log in journals" :key="log.id" class="mph-journal-item">
<div class="mph-journal-item-header">{{ formatDate(log.create) }} - {{ log.createByName }}</div>
<div style="white-space: pre-wrap; color: #495057;">{{ log.text }}</div>
<div v-if="log.statusChange" class="badge badge-light border mt-1">{{ log.statusChange }}</div>
</div>
</div>
<div v-else class="text-center text-muted small py-2">Keine Einträge</div>
</div>
<div class="p-2 border-top bg-light">
<tt-textarea v-model="newMessage" placeholder="Notiz..." rows="2" no-form-group sm/>
<tt-button text="Senden" @click="addEntry" :loading="adding" :disabled="!canSend" :additional-class="sendButtonClass + ' btn-sm btn-block mt-1'"/>
</div>
</div>
`,
methods: {
formatDate(ts) { return window.moment.unix(ts).format('DD.MM HH:mm'); },
async addEntry() {
if (!this.canSend) return;
this.adding = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/addJournal`, { workorderMphId: this.workorderMphId, text: this.newMessage });
if (data.success) { this.newMessage = ''; this.$emit('refresh'); }
} catch (e) { window.notify('error', 'Fehler'); } finally { this.adding = false; }
}
}
});
// Documents Component (Upload + List)
Vue.component('workorder-mph-documents', {
props: { docs: { type: Array, default: () => [] }, workorderMphId: { type: [Number, String], required: true }, isAdmin: { type: Boolean, default: false } },
data: () => ({
uploading: false,
isDragging: false,
uploadData: { files: [], documentType: '', description: '' },
requiredDocs: [
{ key: 'photo_hak_mounted', label: 'Foto vom montierten HAK' },
{ key: 'photo_hak_open', label: 'Foto von dem offenen HAK' },
{ key: 'photo_splice_cassette_hak', label: 'Foto der Spleißkasette - HAK' },
{ key: 'photo_splice_cassette_fcp', label: 'Foto der Spleißkasette - FCP' },
{ key: 'photo_fcp_labeled', label: 'Foto vom FCP beschriftet' },
{ key: 'photo_patch_pos_osp', label: 'Foto der Patch-Position - OSP-Seite' },
{ key: 'photo_patch_pos_anb', label: 'Foto der Patch-Position - ANB-Seite' },
{ key: 'otdr_measurement', label: 'ODTR - Messung (1310nm & 1550nm)' },
{ key: 'other', label: 'Sonstige' }
]
}),
computed: {
docOptions() {
return [
{ value: '', text: '-- Bitte wählen --' },
...this.requiredDocs.map(d => ({ value: d.key, text: d.label }))
];
}
},
template: `
<div>
<div class="workorder-mph-card mb-3">
<div class="workorder-mph-card-header"><i class="fas fa-upload"></i> Dokument hochladen</div>
<div class="workorder-mph-card-body">
<div class="row">
<div class="col-md-6 mb-2">
<tt-select label="Typ *" :options="docOptions" v-model="uploadData.documentType" sm no-form-group class="mb-2"/>
<label class="small font-weight-bold mb-1">Beschreibung</label>
<input type="text" class="form-control form-control-sm mb-2" v-model="uploadData.description" placeholder="Optional">
</div>
<div class="col-md-6 mb-2">
<label class="small font-weight-bold mb-1">Datei(en)</label>
<div class="mph-drop-zone"
:class="{ 'dragging': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop">
<input type="file" ref="fileInput" @change="handleFileSelect" multiple accept="image/*,.pdf">
<i class="fas fa-cloud-upload-alt mph-drop-zone-icon"></i>
<div class="mph-drop-zone-text">Dateien auswählen</div>
<div class="mph-drop-zone-hint">oder hierher ziehen</div>
</div>
<div v-if="uploadData.files.length" class="mph-file-list">
<div v-for="(file, index) in uploadData.files" :key="index" class="mph-file-item">
<i class="fas fa-file mr-2 text-muted"></i>
<span class="text-truncate" style="max-width: 180px;">{{ file.name }}</span>
<span class="remove-file" @click="removeFile(index)"><i class="fas fa-times"></i></span>
</div>
</div>
</div>
</div>
<div class="text-right mt-2">
<tt-button icon="fas fa-upload" text="Hochladen" @click="uploadFiles" :loading="uploading" additional-class="btn-primary btn-sm"/>
</div>
</div>
</div>
<div class="workorder-mph-card">
<div class="workorder-mph-card-header">
<span><i class="fas fa-folder-open"></i> Dokumente</span>
<span class="badge badge-secondary">{{ docs.length }}</span>
</div>
<div class="workorder-mph-card-body">
<div v-if="docs.length === 0" class="text-muted small text-center">Keine Dokumente</div>
<tt-file-gallery v-else :files="docs" :edit-mode="false" :delete-mode="!isAdmin" @delete-file="deleteDoc"/>
</div>
</div>
</div>
`,
methods: {
handleFileSelect(event) {
this.addFiles(event.target.files);
},
handleDrop(event) {
this.isDragging = false;
this.addFiles(event.dataTransfer.files);
},
addFiles(fileList) {
if (!fileList.length) return;
// Convert FileList to Array and append
this.uploadData.files = [...this.uploadData.files, ...Array.from(fileList)];
},
removeFile(index) {
this.uploadData.files.splice(index, 1);
},
async uploadFiles() {
if (!this.uploadData.files.length || !this.uploadData.documentType) return window.notify('error', 'Bitte Typ und Datei wählen');
this.uploading = true;
const formData = new FormData();
formData.append('workorderMphId', this.workorderMphId);
formData.append('documentType', this.uploadData.documentType);
formData.append('description', this.uploadData.description);
// Handle multiple files
for (let i = 0; i < this.uploadData.files.length; i++) {
formData.append('files[]', this.uploadData.files[i]);
}
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/uploadDocumentation`, formData);
if (data.success) {
window.notify('success', 'OK');
this.uploadData = { files: [], documentType: '', description: '' };
this.$refs.fileInput.value='';
this.$emit('refresh');
} else {
window.notify('error', data.message || 'Upload fehlgeschlagen');
}
} catch (e) { window.notify('error', 'Upload Fehler'); } finally { this.uploading = false; }
},
async deleteDoc(file) {
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/deleteDocumentation`, { documentationId: file.id });
window.notify('success', 'Gelöscht'); this.$emit('refresh');
} catch (e) { window.notify('error', 'Fehler'); }
}
}
});
// Admin Review Component
Vue.component('workorder-mph-admin-review', {
props: { docs: { type: Array, default: () => [] }, workorderMphId: { type: [Number, String], required: true } },
data: () => ({
checklist: [
{ key: 'photo_hak_mounted', label: 'Foto vom montierten HAK', icon: 'fas fa-camera' },
{ key: 'photo_hak_open', label: 'Foto von dem offenen HAK', icon: 'fas fa-camera' },
{ key: 'photo_splice_cassette_hak', label: 'Foto der Spleißkasette - HAK', icon: 'fas fa-camera' },
{ key: 'photo_splice_cassette_fcp', label: 'Foto der Spleißkasette - FCP', icon: 'fas fa-camera' },
{ key: 'photo_fcp_labeled', label: 'Foto vom FCP beschriftet', icon: 'fas fa-tag' },
{ key: 'photo_patch_pos_osp', label: 'Foto der Patch-Position - OSP-Seite', icon: 'fas fa-ethernet' },
{ key: 'photo_patch_pos_anb', label: 'Foto der Patch-Position - ANB-Seite', icon: 'fas fa-ethernet' },
{ key: 'otdr_measurement', label: 'ODTR - Messung (1310nm & 1550nm)', icon: 'fas fa-chart-line' }
],
acceptModal: false
}),
computed: {
canAccept() {
return this.checklist.every(item => this.hasDoc(item.key));
}
},
methods: {
hasDoc(key) { return this.docs.some(d => d.documentType === key); },
async accept() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/acceptDocumentation`, { workorderId: this.workorderMphId });
if (data.success) { window.notify('success', 'Akzeptiert'); this.acceptModal = false; this.$emit('refresh'); }
} catch (e) { window.notify('error', 'Fehler'); }
}
},
template: `
<div class="workorder-mph-card">
<div class="workorder-mph-card-header"><i class="fas fa-check-circle"></i> Prüfung & Freigabe</div>
<div class="workorder-mph-card-body-compact">
<div class="mph-doc-checklist mb-2">
<div v-for="item in checklist" :key="item.key" class="mph-doc-check-item" :class="{ 'has-doc': hasDoc(item.key) }">
<i :class="item.icon" class="mr-2" style="width: 16px; text-align: center;"></i>
<span class="flex-grow-1">{{ item.label }}</span>
<i v-if="hasDoc(item.key)" class="fas fa-check text-success"></i>
<i v-else class="fas fa-times text-muted" style="opacity: 0.3"></i>
</div>
</div>
<tt-button text="Akzeptieren & Abschluss" @click="acceptModal = true" :disabled="!canAccept" additional-class="btn-success btn-sm btn-block" icon="fas fa-check-double"/>
<tt-modal :show.sync="acceptModal" title="Freigabe" @submit="accept" :delete="false">
Dokumentation akzeptieren und Auftrag abschließen?
</tt-modal>
</div>
</div>
`
});
// Legacy Wrapper for compatibility
Vue.component('workorder-mph-details-manager', {
props: { workorderMphId: { type: [Number, String], required: true }, isAdmin: { type: Boolean, default: false } },
template: `
<workorder-mph-data-provider :workorder-mph-id="workorderMphId" v-slot="{ docs, journals, refresh, loading }">
<div class="mph-details-stack">
<div v-if="loading && !docs.length" class="text-center"><i class="fas fa-spinner fa-spin"></i></div>
<template v-else>
<workorder-mph-admin-review v-if="isAdmin" :docs="docs" :workorder-mph-id="workorderMphId" @refresh="refresh" />
<!-- Company Complete Logic (Inline simplified) -->
<div v-if="!isAdmin" class="workorder-mph-card mb-3">
<div class="workorder-mph-card-header"><i class="fas fa-check-double"></i> Abschluss</div>
<div class="workorder-mph-card-body-compact">
<tt-button text="Fertigmelden" @click="completeWorkorder" additional-class="btn-success btn-block" icon="fas fa-flag-checkered"/>
</div>
</div>
<workorder-mph-documents :docs="docs" :workorder-mph-id="workorderMphId" :is-admin="isAdmin" @refresh="refresh" />
<workorder-mph-journal :journals="journals" :workorder-mph-id="workorderMphId" :is-admin="isAdmin" @refresh="refresh" />
</template>
</div>
</workorder-mph-data-provider>
`,
methods: {
async completeWorkorder() {
if (!confirm('Wirklich abschließen?')) return;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, { workorderId: this.workorderMphId });
if (data.success) { window.notify('success', 'Erledigt'); this.$emit('workorder-completed'); }
} catch (e) { window.notify('error', 'Fehler'); }
}
}
});
@@ -0,0 +1,266 @@
// WorkorderMphCompany.js
Vue.component('workorder-mph-company', {
template: `
<tt-card>
<tt-table-crud ref="table" :crud-config="crudConfig">
<template v-slot:hausnummerinfo="{ row }">
<span class="small">{{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template>, {{ row.plz }} {{ row.city }}</span>
</template>
<template v-slot:status="{ row }">
<traffic-light-mph :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
</template>
<template v-slot:additionalinfo="{ row }">
<div v-if="editingAdditionalInfoId === row.id">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" rows="3" no-form-group sm ref="editTextarea"/>
<div class="mt-2 d-flex justify-content-end">
<tt-button text="Abbrechen" @click="cancelEdit" sm additional-class="btn-secondary mr-2"/>
<tt-button text="Speichern" @click="updateAdditionalInfo(row)" sm additional-class="btn-success"/>
</div>
</div>
<div v-else class="d-flex align-items-start">
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
<tt-button 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="Notiz bearbeiten"/>
</div>
</template>
<template v-slot:deadlinedate="{ row }">{{ formatDate(row.deadlineDate) }}</template>
<template v-slot:appointmentdate="{ row }">
<div v-if="!row.appointmentDate && canSchedule(row)">
<tt-date-picker placeholder="Termin festlegen..." :date-range="false"
@input="scheduleAppointment(row, $event)" sm no-form-group
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, drops: 'up' }"/>
</div>
<div v-else-if="row.appointmentDate" class="d-flex align-items-center">
<span>{{ formatDate(row.appointmentDate, true) }}</span>
<tt-button v-if="canSchedule(row)"
icon="fas fa-edit" @click="openRescheduleModal(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Termin ändern"/>
</div>
<span v-else></span>
</template>
<template v-slot:expandedRow="{ row }">
<workorder-mph-data-provider :workorder-mph-id="row.id" v-slot="{ docs, journals, refresh }">
<div class="workorder-mph-expanded-wrapper">
<!-- Action Buttons -->
<div class="mb-3" v-if="!['completed', 'cancelled', 'documented'].includes(row.status)">
<div class="btn-group" role="group">
<tt-button v-if="row.status === 'scheduled'" text="Arbeit beginnen"
@click="startWork(row)" icon="fas fa-play" additional-class="btn-success"/>
<tt-button v-if="row.status === 'in_progress'" text="Auftrag abschließen"
@click="openCompleteModal(row)" icon="fas fa-check-double"
additional-class="btn-success"/>
</div>
</div>
<div class="row g-2">
<!-- Left Column (1/4): Docs Checkbox, Journal -->
<div class="col-xl-3 col-lg-4">
<div class="mph-details-stack">
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="false" @refresh="refresh"/>
<workorder-mph-journal :journals="journals" :workorder-mph-id="row.id" :is-admin="false" @refresh="refresh"/>
</div>
</div>
<!-- Right Column (3/4): Wohneinheiten, Documents -->
<div class="col-xl-9 col-lg-8">
<div class="mph-details-stack">
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="false" @refresh="refresh"/>
<workorder-mph-documents :docs="docs" :workorder-mph-id="row.id" :is-admin="false" @refresh="refresh"/>
</div>
</div>
</div>
</div>
</workorder-mph-data-provider>
</template>
</tt-table-crud>
<tt-modal v-if="rescheduleModalData" :show.sync="rescheduleModalData"
title="Termin verschieben" @submit="rescheduleAppointment">
<p>Aktueller Termin: <strong>{{ formatDate(rescheduleModalData.currentDate, true) }}</strong></p>
<tt-date-picker label="Neuer Termin" v-model="rescheduleModalData.newDate"
:date-range="false" sm row required
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, singleDatePicker: true }"/>
<tt-textarea label="Grund" v-model="rescheduleModalData.reason" sm row required/>
</tt-modal>
<tt-modal v-if="completeModalData" :show.sync="completeModalData"
title="Auftrag abschließen" @submit="completeWorkorder" :delete="false">
<p>Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?</p>
<div class="alert alert-warning small">
<i class="fas fa-exclamation-triangle mr-2"></i>
Bitte stellen Sie sicher, dass alle Wohneinheiten dokumentiert sind und alle erforderlichen Dokumente hochgeladen wurden.
</div>
</tt-modal>
</tt-card>
`,
data() {
return {
window,
editingAdditionalInfoId: null,
tempAdditionalInfo: '',
rescheduleModalData: null,
completeModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled', 'archived'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
return 'tt-mph-workorder-ontrack';
}
}
}
},
methods: {
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp, withTime = false) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
},
canSchedule(row) {
return ['assigned', 'scheduled', 'in_progress'].includes(row.status);
},
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';
this.$nextTick(() => this.$refs.editTextarea?.$el.querySelector('textarea').focus());
},
cancelEdit() {
this.editingAdditionalInfoId = null;
this.tempAdditionalInfo = '';
},
async updateAdditionalInfo(row) {
if (row.additionalInfo === this.tempAdditionalInfo) {
this.cancelEdit();
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateAdditionalInfo`, {
workorderMphId: row.id,
additionalInfo: this.tempAdditionalInfo
});
if (data.success) {
window.notify('success', data.message);
row.additionalInfo = data.newInfo;
} else {
window.notify('error', data.message || 'Update fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.cancelEdit();
}
},
async scheduleAppointment(row, newDate) {
if (!newDate) return;
const hour = parseInt(moment.unix(newDate).format('H'));
if (hour >= 23 || hour < 1) {
window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
this.$refs.table.$refs.table.refreshTable();
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/scheduleAppointment`, {
workorderId: row.id,
appointmentDate: newDate
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
}
},
openRescheduleModal(row) {
this.rescheduleModalData = {
workorderId: row.id,
currentDate: row.appointmentDate,
newDate: null,
reason: ''
};
},
async rescheduleAppointment() {
if (!this.rescheduleModalData.newDate || !this.rescheduleModalData.reason) {
return window.notify('error', 'Bitte füllen Sie alle Felder aus.');
}
const hour = parseInt(moment.unix(this.rescheduleModalData.newDate).format('H'));
if (hour >= 23 || hour < 1) {
return window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/rescheduleAppointment`, {
workorderId: this.rescheduleModalData.workorderId,
appointmentDate: this.rescheduleModalData.newDate,
reason: this.rescheduleModalData.reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.rescheduleModalData = null;
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
}
},
async startWork(row) {
if (!confirm(`Möchten Sie mit der Arbeit an Auftrag #${row.id} beginnen?`)) return;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/startWork`, {
workorderId: row.id
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
}
},
openCompleteModal(row) {
this.completeModalData = { workorderId: row.id };
},
async completeWorkorder() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, {
workorderId: this.completeModalData.workorderId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.completeModalData = null;
} else {
window.notify('error', data.message || 'Abschluss fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
}
});
@@ -78,6 +78,11 @@ Vue.component('workorder-tenant-config', {
<div class="col-md-6">
<h6 class="mb-3">Optionen</h6>
<div v-if="editingId === config.id">
<tt-checkbox label="Workorder aktivieren"
v-model="editableItem.enableWorkorder" sm/>
<tt-checkbox label="WorkorderMPH aktivieren"
v-model="editableItem.enableWorkorderMph" sm/>
<hr>
<tt-checkbox label="Dokumentation für Tiefbau erforderlich"
v-model="editableItem.civilEngineeringDocsRequired" sm/>
<tt-checkbox label="Kabellänge erforderlich"
@@ -86,6 +91,9 @@ Vue.component('workorder-tenant-config', {
v-model="editableItem.requireCableType" sm/>
</div>
<div v-else>
<p>Workorder: <strong>{{ config.enableWorkorder ? 'Aktiviert' : 'Deaktiviert' }}</strong></p>
<p>WorkorderMPH: <strong>{{ config.enableWorkorderMph ? 'Aktiviert' : 'Deaktiviert' }}</strong></p>
<hr>
<p>Tiefbau-Doku: <strong>{{ config.civilEngineeringDocsRequired ? '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>
@@ -324,7 +332,9 @@ Vue.component('workorder-tenant-config', {
workorderActiveFilters: '{}',
civilEngineeringDocsRequired: 0,
requireCableLength: 0,
requireCableType: 0
requireCableType: 0,
enableWorkorder: 1,
enableWorkorderMph: 1
}
: {visibleForAddressId: []};
this.showModal = true;