Feature/rework vue schema
This commit is contained in:
@@ -1,256 +0,0 @@
|
||||
<?php /** @noinspection PhpUndefinedClassInspection
|
||||
* @var string $mfLayoutPackage
|
||||
* @var TYPE_NAME $git_merge_ts
|
||||
*/
|
||||
|
||||
//additional css /css/views/RaspberryDisplay.css
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Domains",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Domain Management", "href" => self::getUrl("Domain")],
|
||||
["text" => "Domains"]
|
||||
],
|
||||
"DOMAIN_API_URL" => self::getUrl("Domain/api"),
|
||||
];
|
||||
|
||||
$additionalJS = [
|
||||
"plugins/vue/vue.js",
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/moment/moment.min.js",
|
||||
"plugins/daterangepicker/daterangepicker.js",
|
||||
"plugins/xlsx/xlsx.min.js",
|
||||
"plugins/vue/tt-components/tt-table.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-select.js",
|
||||
"plugins/vue/tt-components/tt-datepicker.js",
|
||||
"plugins/vue/tt-components/tt-input.js",
|
||||
"plugins/vue/tt-components/tt-autocomplete.js",
|
||||
"plugins/vue/tt-components/tt-icon-select.js",
|
||||
"plugins/vue/tt-components/tt-number-range.js",
|
||||
];
|
||||
$additionalCSS = [
|
||||
"plugins/daterangepicker/daterangepicker.css",
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
];
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
|
||||
<div id="app">
|
||||
<!-- start page title -->
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDomains'" :config="domainsTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="reloadDomains">
|
||||
<template v-if="reloadDomainsLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Reload Domains
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="checkDomainInput" placeholder="Neue Domain überprüfen">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" @click="checkDomainAvailability">
|
||||
<template v-if="checkDomainLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Slot to show DNS records button -->
|
||||
<template v-slot:inwxroid="{ row }">
|
||||
<button type="button" class="btn btn-primary" @click="showDnsRecordsModal(row.domain)"
|
||||
:class="dnsRecordsModalLoading === row.domain ? 'disabled' : ''">
|
||||
<template v-if="dnsRecordsModalLoading === row.domain">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<span v-else>DNS</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Registrant Admin Tech Billing from domainContacts -->
|
||||
<template v-slot:registrant="{ row }">
|
||||
{{ domainContacts[row.registrant] ? domainContacts[row.registrant]["name"] : '' }}
|
||||
</template>
|
||||
<template v-slot:admin="{ row }">{{ domainContacts[row.admin] ? domainContacts[row.admin]["name"] : ''}}
|
||||
</template>
|
||||
<template v-slot:tech="{ row }">{{ domainContacts[row.tech] ? domainContacts[row.tech]["name"] : ''}}
|
||||
</template>
|
||||
<template v-slot:billing="{ row }">{{ domainContacts[row.billing] ? domainContacts[row.billing]["name"] : ''}}
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
<!-- Bootstrap Modal to query and show all DNS records for a domain -->
|
||||
<div class="modal show d-block" tabindex="-1" role="dialog" style="background: rgba(0, 0, 0, 0.5);" ref="dnsRecordsModal" @click="dnsRecordsModal.domain = null" @keydown.esc="dnsRecordsModal.domain = null"
|
||||
v-if="dnsRecordsModal.domain">
|
||||
<div class="modal-dialog" role="document" style="max-width: fit-content" @click.stop>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">DNS Records for {{ dnsRecordsModal.domain ?? '' }}</h5>
|
||||
<button type="button" class="close" @click="dnsRecordsModal.domain = null">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="tt-table table-striped table-bordered table-hover table-sm table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Record Class</th>
|
||||
<th>Record Type</th>
|
||||
<th>Record Host</th>
|
||||
<th>Record Value</th>
|
||||
<th>Record TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in dnsRecordsModal.records">
|
||||
<td>{{ record.class }}</td>
|
||||
<td>{{ record.type }} {{ record.pri ? '(' + record.pri + ')' : '' }}</td>
|
||||
<td>{{ record.host }}</td>
|
||||
<td>{{ record.value }}</td>
|
||||
<td>{{ record.ttl }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="dnsRecordsModal.domain = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
window: window,
|
||||
domainContacts: {},
|
||||
reloadDomainsLoading: false,
|
||||
dnsRecordsModalLoading: null,
|
||||
dnsRecordsModal: {
|
||||
domain: null,
|
||||
records: []
|
||||
},
|
||||
checkDomainInput: '',
|
||||
checkDomainResult: null,
|
||||
checkDomainLoading: false
|
||||
},
|
||||
mounted() {
|
||||
this.fetchDomainContacts()
|
||||
},
|
||||
computed: {
|
||||
domainsTableConfig() {
|
||||
const base = {
|
||||
headers: [
|
||||
{text: "DNS", key: "inwxRoId", filter: false, sortable: false},
|
||||
{text: "Domain", key: "domain"},
|
||||
{text: "Plesk", key: "pleskId", filter: 'iconSelect', filterOptions: [
|
||||
{value: 1, text: 'Yes', icon: 'fas fa-check text-success'},
|
||||
{value: 0, text: 'No', icon: 'fas fa-times text-danger'}
|
||||
], sortable: false},
|
||||
{text: "Created Date", key: "crDate", filter: "date"},
|
||||
{text: "Expiration Date", key: "exDate", filter: "date"},
|
||||
{text: "Renewal Date", key: "reDate", filter: "date"},
|
||||
{text: "Updated Date", key: "upDate", filter: "date"},
|
||||
{text: "Transfer Lock", key: "transferLock", filter: 'iconSelect', filterOptions: [
|
||||
{value: 1, text: 'Locked', icon: 'fas fa-lock text-danger'},
|
||||
{value: 0, text: 'Unlocked', icon: 'fas fa-unlock text-success'}
|
||||
]},
|
||||
{text: "Authorization Code", key: "authCode", sortable: false},
|
||||
{text: "Registrant ID", key: "registrant", sortable: false},
|
||||
{text: "Admin ID", key: "admin", sortable: false},
|
||||
{text: "Tech ID", key: "tech", sortable: false},
|
||||
{text: "Billing ID", key: "billing", sortable: false},
|
||||
{text: "Name Servers", key: "ns"}
|
||||
],
|
||||
tableHeader: 'Bestellungen',
|
||||
}
|
||||
|
||||
const domainContactsSorted = Object.entries(this.domainContacts).sort(([, a], [, b]) => a.name.localeCompare(b.name))
|
||||
const domainContactsFilterOptions = domainContactsSorted.map(([, contact]) => {
|
||||
return {text: contact.name, value: contact.inwxRoId}
|
||||
})
|
||||
|
||||
// for registrant admin tech billing set filter to select with domainContacts if domainContacts is not empty
|
||||
if (Object.keys(this.domainContacts).length > 0) {
|
||||
base.headers = base.headers.map(header => {
|
||||
if (['registrant', 'admin', 'tech', 'billing'].includes(header.key)) {
|
||||
header.filter = 'select'
|
||||
header.filterOptions = domainContactsFilterOptions
|
||||
}
|
||||
return header
|
||||
})
|
||||
}
|
||||
return base
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async showDnsRecordsModal(domain) {
|
||||
this.dnsRecordsModalLoading = domain
|
||||
this.dnsRecordsModal = {
|
||||
domain: null,
|
||||
records: []
|
||||
}
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDnsRecords&domain=' + domain)
|
||||
this.dnsRecordsModal.domain = domain
|
||||
this.dnsRecordsModal.records = response.data.map(record => {
|
||||
if (typeof record.entries === 'object') {
|
||||
record.value = record.entries[0]
|
||||
} else {
|
||||
record.value = record.target || record.txt || record.ip
|
||||
}
|
||||
if (record.type === 'SOA') {
|
||||
record.value = record.mname + ' ' + record.rname + ' ' + record.serial + ' ' + record.refresh + ' ' + record.retry + ' ' + record.expire
|
||||
|
||||
}
|
||||
return record
|
||||
})
|
||||
this.dnsRecordsModalLoading = null
|
||||
this.$nextTick(() => {
|
||||
this.$refs.dnsRecordsModal.focus()
|
||||
})
|
||||
},
|
||||
async fetchDomainContacts() {
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDomainContacts')
|
||||
this.domainContacts = response.data
|
||||
},
|
||||
async reloadDomains() {
|
||||
this.reloadDomainsLoading = true
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=importAllDomains')
|
||||
window.notify('success', response.data["importMessages"].join('<br>'))
|
||||
await Promise.all([
|
||||
this.fetchDomainContacts(),
|
||||
this.$refs.table.fetchData(this.$refs.table.pagination.page)
|
||||
])
|
||||
this.reloadDomainsLoading = false
|
||||
},
|
||||
//TODO: make this cleaner
|
||||
async checkDomainAvailability() {
|
||||
this.checkDomainLoading = true
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=checkDomain&domain=' + this.checkDomainInput)
|
||||
const priceInformation = response.data.price.domain[this.checkDomainInput]
|
||||
window.notify(response.data.status === 'free' ? 'success' : 'error',
|
||||
`Domain ist ${response.data.status === 'free' ? 'verfügbar. Registrieren um' : 'nicht frei. Transfer um'} ${priceInformation.price}${priceInformation.currency}/${priceInformation.period === '1Y' ? 'Jahr' : priceInformation.period}`)
|
||||
this.checkDomainLoading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
<?php /** @noinspection PhpUndefinedClassInspection
|
||||
* @var string $mfLayoutPackage
|
||||
* @var TYPE_NAME $git_merge_ts
|
||||
*/
|
||||
|
||||
//additional css /css/views/RaspberryDisplay.css
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Historische Tickets",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Historische Tickets", "href" => self::getUrl("HistoricTicket")]
|
||||
],
|
||||
"HISTORIC_TICKET_API_URL" => self::getUrl("HistoricTicket/api"),
|
||||
];
|
||||
|
||||
$additionalJS = [
|
||||
"plugins/vue/vue.js",
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/moment/moment.min.js",
|
||||
"plugins/daterangepicker/daterangepicker.js",
|
||||
"plugins/xlsx/xlsx.min.js",
|
||||
"plugins/vue/tt-components/tt-table.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-select.js",
|
||||
"plugins/vue/tt-components/tt-datepicker.js",
|
||||
"plugins/vue/tt-components/tt-input.js",
|
||||
"plugins/vue/tt-components/tt-autocomplete.js",
|
||||
"plugins/vue/tt-components/tt-icon-select.js",
|
||||
"plugins/vue/tt-components/tt-number-range.js",
|
||||
];
|
||||
$additionalCSS = [
|
||||
"plugins/daterangepicker/daterangepicker.css",
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
];
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
|
||||
<div id="app">
|
||||
<!-- start page title -->
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['HISTORIC_TICKET_API_URL'] + '?do=getHistoricTickets'"
|
||||
:config="historicTicketTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<!-- add input for global search with label and bootstrap class-->
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" placeholder="Globale Suche" v-model="globalSearch" @keydown.enter="doGlobalSearch">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="button" @click="doGlobalSearch">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:first_name="{ row }">
|
||||
{{ row.first_name }} {{ row.last_name }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ctime="{ row }">
|
||||
{{ new Date(row.ctime * 1000).toLocaleString() }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ticket_number="{ row }">
|
||||
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
|
||||
<!-- Bootstrap Modal to show global search results -->
|
||||
<div class="modal show d-block" tabindex="0" role="dialog" style="background: rgba(0, 0, 0, 0.5);" @click="globalSearchModal = false" @keydown.esc="globalSearchModal = false" ref="globalSearchModal"
|
||||
v-if="globalSearchModal">
|
||||
<div class="modal-dialog" role="document" @click.stop
|
||||
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Suchergebnisse</h5>
|
||||
<button type="button" class="close" @click="globalSearchModal = false">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<tt-table
|
||||
:fetch-url="`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=findHistoricTicket&query=${globalSearch}`"
|
||||
:config="globalSearchModalTableConfig"
|
||||
small ref="table">
|
||||
|
||||
<template v-slot:ctime="{ row }">
|
||||
{{ window.moment(row.ctime * 1000).format('DD.MM.YYYY HH:mm') }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ticket_number="{ row }">
|
||||
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
|
||||
</template>
|
||||
</tt-table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="globalSearchModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap Modal to show ticket messages -->
|
||||
<div class="modal show d-block" tabindex="0" role="dialog" style="background: rgba(0, 0, 0, 0.5);" @click="selectedTicketNumber = null" @keydown.esc="selectedTicketNumber = null" ref="selectedTicketModal"
|
||||
v-if="selectedTicketNumber">
|
||||
<div class="modal-dialog" role="document" @click.stop
|
||||
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Ticket {{ selectedTicketNumber }}</h5>
|
||||
<button type="button" class="close" @click="selectedTicketNumber = null">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="selectedTicketData">
|
||||
<h5>{{ selectedTicketData.ticket.subject }}</h5>
|
||||
<p>{{ selectedTicketData.ticket.message }}</p>
|
||||
<div v-for="message in selectedTicketData.messages">
|
||||
<hr>
|
||||
<h6>{{ new Date(message.ctime * 1000).toLocaleString()}}</h6>
|
||||
<p style="word-break: break-all;" v-html="message.content?.replaceAll('\n', '<br>')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="spinner-border text-primary" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
window: window,
|
||||
selectedTicketNumber: null,
|
||||
selectedTicketData: null,
|
||||
globalSearch: '',
|
||||
globalSearchModal: false,
|
||||
globalSearchModalTableConfig: {
|
||||
headers: [
|
||||
{text: 'Ticket Number', key: 'ticket_number', filter: false, sortable: false},
|
||||
{text: 'Erstellt', key: 'ctime', filter: false, sortable: false},
|
||||
{text: 'Subject', key: 'ticket_subject', filter: false, sortable: false},
|
||||
{text: 'Message', key: 'ticket_message', filter: false, sortable: false},
|
||||
],
|
||||
tableHeader: 'Suchergebnisse',
|
||||
key: 'HistoricTicketGlobalSearch',
|
||||
},
|
||||
historicTicketTableConfig: {
|
||||
headers: [
|
||||
{text: 'Ticket Number', key: 'ticket_number', filter: 'search'},
|
||||
{text: 'Erstellt', key: 'ctime', filter: false},
|
||||
{text: 'Subject', key: 'subject', filter: 'search', sortable: false},
|
||||
{text: 'Type', key: 'type', filter: 'select', filterOptions: [
|
||||
{value: 'BACKOFFICE', text: 'BACKOFFICE'},
|
||||
{value: 'KUNDENANFRAGEN', text: 'KUNDENANFRAGEN'},
|
||||
{value: 'STÖRUNGEN', text: 'STÖRUNGEN'},
|
||||
{value: 'ALLGEMEINES', text: 'ALLGEMEINES'},
|
||||
{value: 'TERMIN VEREINBART', text: 'TERMIN VEREINBART'},
|
||||
{value: 'VERRECHNEN AB DATUM', text: 'VERRECHNEN AB DATUM'},
|
||||
{value: 'ONLINE-TICKETS', text: 'ONLINE-TICKETS'},
|
||||
{value: 'KÜNDIGUNG', text: 'KÜNDIGUNG'},
|
||||
{value: 'BESTELLUNGEN', text: 'BESTELLUNGEN'},
|
||||
{value: 'PORTIERUNG', text: 'PORTIERUNG'},
|
||||
{value: 'KABEL-TV', text: 'KABEL-TV'},
|
||||
{value: 'TIEFBAU', text: 'TIEFBAU'},
|
||||
{value: 'ENERGIE STEIERMARK', text: 'ENERGIE STEIERMARK'},
|
||||
{value: 'INTERN', text: 'INTERN'},
|
||||
{value: 'FELIX', text: 'FELIX'},
|
||||
{value: '0', text: '0'},
|
||||
{value: 'JETTEN', text: 'JETTEN'},
|
||||
]},
|
||||
{text: 'Status', key: 'status', filter: 'select', filterOptions: [
|
||||
{value: 'Geschlossen', text: 'Geschlossen'},
|
||||
{value: 'In Evidenz', text: 'In Evidenz'},
|
||||
{value: 'In Bearbeitung', text: 'In Bearbeitung'},
|
||||
{value: 'Business In Bearbeitung', text: 'Business In Bearbeitung'},
|
||||
{value: 'Business Angebot gelegt', text: 'Business Angebot gelegt'},
|
||||
]},
|
||||
{text: 'Name', key: 'first_name', filter: 'search', sortable: false},
|
||||
{text: 'Email', key: 'email', filter: 'search', sortable: false},
|
||||
{text: 'Phone', key: 'phone', filter: 'search', sortable: false},
|
||||
],
|
||||
defaultPageSize: 25,
|
||||
tableHeader: 'Historische Tickets',
|
||||
key: 'HistoricTicket',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async doGlobalSearch() {
|
||||
if (this.globalSearch.length > 0) {
|
||||
this.globalSearchModal = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.globalSearchModal.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
async clickTicketNumber(ticketNumber) {
|
||||
this.globalSearchModal = false;
|
||||
this.selectedTicketData = null;
|
||||
|
||||
this.selectedTicketNumber = ticketNumber;
|
||||
const response = await axios.post(`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=getHistoricTicketMessages`, {ticketNumber});
|
||||
this.selectedTicketData = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.selectedTicketModal.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php /** @noinspection PhpUndefinedClassInspection
|
||||
* @var string $mfLayoutPackage
|
||||
* @var TYPE_NAME $git_merge_ts
|
||||
*/
|
||||
|
||||
//additional css /css/views/RaspberryDisplay.css
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("RaspberryDisplay"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Raspberry Displays",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Raspberry Displays", "href" => self::getUrl("RaspberryDisplay")]
|
||||
]
|
||||
];
|
||||
|
||||
$additionalJS = ["plugins/vue/vue.min.js",
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-loader.js"];
|
||||
$additionalCSS = ["css/views/RaspberryDisplay.css", "plugins/vue/tt-components/css/tt-loader.css"];
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
|
||||
<div id="app">
|
||||
<!-- start page title -->
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<div class="card">
|
||||
<tt-loader v-if="loading"></tt-loader>
|
||||
|
||||
<div class="p-2">
|
||||
<h3>8322 Studenzen NOC Displays</h3>
|
||||
|
||||
<div class="display-grid">
|
||||
<div v-for="display in displays" :key="display.id"
|
||||
:class="['display', display['display_label'].includes('-B-') ? 'big-42-inch' : 'small-27-inch']"
|
||||
:style="display['custom_style']" style="">
|
||||
<div style="display: grid; grid-template-columns: max-content auto max-content; justify-items: center;width:100%; padding: 0 2px">
|
||||
<div>
|
||||
<!-- FONT AWESOME ONLINE GREEN CIRCLE -->
|
||||
<i class="fas fa-circle" data-toggle="tooltip" title="ONLINE" style="color: green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div @click.prevent="enableDisplayURLEditMode(display.id)" style="cursor: pointer">
|
||||
<span v-if="displaysURLEditMode !== display.id">{{ display['display_url'] | cleanupURL }}</span>
|
||||
<input v-else-if="displaysURLEditMode === display.id"
|
||||
v-model="display['display_url']"
|
||||
@keyup.enter="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
@blur="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
ref="displayURLEditInput"
|
||||
class="form-control"
|
||||
type="text">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="cursor: pointer">
|
||||
<!-- FONT AWESOME REBOOT ICON -->
|
||||
<i class="fas fa-red fa-sync-alt" data-toggle="tooltip" title="Reboot this Raspberry"
|
||||
@click="rebootRaspberry(display.id)"
|
||||
style="color: green"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Checkbox for Auto Refresh Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="`Auto refresh is ${display['auto_refresh_enabled'] ? 'enabled' : 'disabled'}.`">
|
||||
<input type="checkbox" :id="'auto_refresh_enabled_checkbox_' + display.id"
|
||||
v-model="display['auto_refresh_enabled']"
|
||||
@change="submitChanges(display.id, 'auto_refresh_enabled', display['auto_refresh_enabled'])">
|
||||
<label :for="'auto_refresh_enabled_checkbox_' + display.id">ARF</label>
|
||||
</div>
|
||||
|
||||
<!-- This will only display if both are true, consider adjusting logic as needed -->
|
||||
<span style="margin: 0 4px"> | </span>
|
||||
|
||||
<!-- Checkbox for Margin Hotfix Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="`Margin Hotfix is ${display['margin_hot_fix_enabled'] ? 'enabled' : 'disabled'}.`">
|
||||
|
||||
<input type="checkbox" :id="'margin_hot_fix_enabled_checkbox_' + display.id"
|
||||
v-model="display['margin_hot_fix_enabled']"
|
||||
@change="submitChanges(display.id, 'margin_hot_fix_enabled', display['margin_hot_fix_enabled'])">
|
||||
<label :for="'margin_hot_fix_enabled_checkbox_' + display.id">MHF</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-text="display['display_label']"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script src="<?=self::getResourcePath()?>js/pages/raspberryDisplay.js?<?=$git_merge_ts?>"></script>
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php /** @noinspection PhpUndefinedClassInspection
|
||||
* @var string $mfLayoutPackage
|
||||
* @var TYPE_NAME $git_merge_ts
|
||||
*/
|
||||
|
||||
//additional css /css/views/RaspberryDisplay.css
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("VoiceCallActive"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Active Voice Calls",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Active Voice Calls", "href" => self::getUrl("VoiceCallActive")]
|
||||
],
|
||||
"VOICE_CALL_ACTIVE_API_URL" => self::getUrl("VoiceCallActive/api"),
|
||||
];
|
||||
|
||||
$additionalJS = [
|
||||
"plugins/vue/vue.js",
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/moment/moment.min.js",
|
||||
"plugins/daterangepicker/daterangepicker.js",
|
||||
"plugins/xlsx/xlsx.min.js",
|
||||
"plugins/vue/tt-components/tt-table.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-select.js",
|
||||
"plugins/vue/tt-components/tt-datepicker.js",
|
||||
"plugins/vue/tt-components/tt-input.js",
|
||||
"plugins/vue/tt-components/tt-autocomplete.js",
|
||||
"plugins/vue/tt-components/tt-icon-select.js",
|
||||
"plugins/vue/tt-components/tt-number-range.js",
|
||||
];
|
||||
$additionalCSS = [
|
||||
"plugins/daterangepicker/daterangepicker.css",
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
];
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
|
||||
<div id="app">
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['VOICE_CALL_ACTIVE_API_URL'] + '?do=getActiveCalls'" :config="VoiceCallActiveTableConfig"
|
||||
small ssr ref="table">
|
||||
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="refresh" data-toggle="tooltip" data-placement="bottom" title="Refreshing too often will run into API-Rate limits and will cause errors.">
|
||||
<template v-if="refreshLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<div class="d-flex">
|
||||
<label style="margin-bottom: 0 !important;">
|
||||
<input type="checkbox" id="autoRefresh" data-toggle="toggle" data-size="lg" @change="clickedCheckBox(this.checked)">
|
||||
<span class="ml-2">Auto Refresh (5sec)</span>
|
||||
</label>
|
||||
<span style="width: 50px"></span>
|
||||
<div class="voice-yellow p-2">Ringing</div>
|
||||
<div class="voice-red p-2">Outgoing</div>
|
||||
<div class="voice-green p-2">Ingoing</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:answer_time="{ row }">
|
||||
{{ !isNaN(new Date(row.answer_time)) ? window.moment(row.answer_time, 'YYYY-MM-DD HH:mm:ss Z').format('DD.MM.YYYY HH:mm:ss') : 'Call is not running' }}
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<i v-if="!row.dst_device_extension && row.status !== 'Ringing'" class="fas fa-phone-arrow-up-right"></i>
|
||||
<i v-else-if="!row.dst_device_extension && row.status === 'Ringing'" class="fas fa-phone-arrow-up-right fa-shake"></i>
|
||||
<i v-else-if="row.status === 'Ringing'" class="fas fa-phone-arrow-down-left fa-shake"></i>
|
||||
<i v-else class="fas fa-phone-arrow-down-left"></i>
|
||||
{{ row.status }}
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.voice-green {
|
||||
background-color: #6CAE75 !important
|
||||
}
|
||||
.voice-yellow {
|
||||
background-color: #E8E288 !important
|
||||
}
|
||||
.voice-red {
|
||||
background-color: #FF8360 !important
|
||||
}
|
||||
|
||||
.table-sm td {
|
||||
padding: 4px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
window: window,
|
||||
VoiceCallActiveTableConfig: {
|
||||
customRowClass: function (row) {
|
||||
if (row.status.toLowerCase() === 'ringing') {
|
||||
return 'voice-yellow';
|
||||
}
|
||||
if (!row.dst_device_extension) {
|
||||
return 'voice-red';
|
||||
}
|
||||
if (row.status.toLowerCase() === 'answered') {
|
||||
return 'voice-green';
|
||||
}
|
||||
},
|
||||
headers: [
|
||||
{text: 'Call ID', key: 'id', filter: false, sortable: false},
|
||||
{text: 'Status', key: 'status', filter: false, sortable: false},
|
||||
{text: 'Answer Time', key: 'answer_time', filter: false, sortable: false},
|
||||
{text: 'Duration', key: 'duration', filter: false, sortable: false},
|
||||
{text: 'Source', key: 'src', filter: false, sortable: false},
|
||||
{text: 'Device Type', key: 'device_type', filter: false, sortable: false},
|
||||
{text: 'Destination', key: 'localized_dst', filter: false, sortable: false},
|
||||
{text: 'Destination User', key: 'dst_user', filter: false, sortable: false},
|
||||
{text: 'Destination Device Extension', key: 'dst_device_extension', filter: false, sortable: false},
|
||||
],
|
||||
tableHeader: 'Active Voice Calls',
|
||||
},
|
||||
refreshLoading: false,
|
||||
autoRefresh: null,
|
||||
},
|
||||
mounted() {
|
||||
//TODO: create vue tooltip component
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
const _this = this;
|
||||
$('#autoRefresh').change(function () {
|
||||
console.log(this.checked);
|
||||
if (this.checked) {
|
||||
_this.autoRefresh = setInterval(function () {
|
||||
_this.refresh();
|
||||
}, 5000);
|
||||
} else {
|
||||
clearInterval(_this.autoRefresh);
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
this.refreshLoading = true;
|
||||
this.$refs.table.loading = true;
|
||||
await this.$refs.table.fetchData();
|
||||
$('.tooltip').tooltip('hide');
|
||||
this.$refs.table.loading = false;
|
||||
this.refreshLoading = false;
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php /** @noinspection PhpUndefinedClassInspection
|
||||
* @var string $mfLayoutPackage
|
||||
* @var TYPE_NAME $git_merge_ts
|
||||
*/
|
||||
|
||||
//additional css /css/views/RaspberryDisplay.css
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Domains",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Voice Calls History", "href" => self::getUrl("VoiceCallHistory")]
|
||||
],
|
||||
"VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"),
|
||||
];
|
||||
|
||||
$additionalJS = [
|
||||
"plugins/vue/vue.js",
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/moment/moment.min.js",
|
||||
"plugins/daterangepicker/daterangepicker.js",
|
||||
"plugins/xlsx/xlsx.min.js",
|
||||
"plugins/vue/tt-components/tt-table.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-select.js",
|
||||
"plugins/vue/tt-components/tt-datepicker.js",
|
||||
"plugins/vue/tt-components/tt-input.js",
|
||||
"plugins/vue/tt-components/tt-autocomplete.js",
|
||||
"plugins/vue/tt-components/tt-icon-select.js",
|
||||
"plugins/vue/tt-components/tt-number-range.js",
|
||||
];
|
||||
$additionalCSS = [
|
||||
"plugins/daterangepicker/daterangepicker.css",
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
];
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
|
||||
|
||||
<div id="app">
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=getCalls'" :config="VoiceCallHistoryTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="importCallsFromToday">
|
||||
<template v-if="importCallsFromTodayLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Re-Import Calls from Today
|
||||
</template>
|
||||
</button>
|
||||
|
||||
</template>
|
||||
</tt-table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
window: window,
|
||||
VoiceCallHistoryTableConfig: {
|
||||
headers: [
|
||||
{text: "Call-ID", key: "uid"},
|
||||
{text: "Voice Account", key: "voice_account"},
|
||||
{text: "Time Range", key: "start", filter: "date"},
|
||||
{text: "Source", key: "source"},
|
||||
{text: "Destination", key: "destination"},
|
||||
{text: "Billable", key: "billable", filter: "iconSelect", filterOptions: [
|
||||
{value: 1, text: 'Yes', icon: 'fas fa-check text-success'},
|
||||
{value: 0, text: 'No', icon: 'fas fa-times text-danger'}
|
||||
]},
|
||||
{text: "Duration", key: "duration"},
|
||||
],
|
||||
tableHeader: 'Voice Call History',
|
||||
key: 'VoiceCallHistory',
|
||||
},
|
||||
importCallsFromTodayLoading: false,
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
async importCallsFromToday() {
|
||||
this.importCallsFromTodayLoading = true;
|
||||
const response = await axios.get(window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=importCallsFromToday');
|
||||
window.notify(response.data.status === 'success' ? 'success' : 'error', response.data.message);
|
||||
await this.$refs.table.fetchData();
|
||||
this.importCallsFromTodayLoading = false;
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
|
||||
49
Layout/default/VueViews/Vue.php
Normal file
49
Layout/default/VueViews/Vue.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
if (!isset($vueViewName)) {
|
||||
die("vueViewName is not set");
|
||||
}
|
||||
|
||||
if (!isset($mfLayoutPackage)) {
|
||||
die("mfLayoutPackage is not set");
|
||||
}
|
||||
|
||||
$additionalCSS = $additionalCSS ?? [];
|
||||
$additionalJS = $additionalJS ?? [];
|
||||
|
||||
$additionalJS = [
|
||||
...$additionalJS,
|
||||
"bundler.php",
|
||||
"js/pages/" . $vueViewName . "/" . $vueViewName . ".js",
|
||||
];
|
||||
|
||||
$additionalCSS = [
|
||||
...$additionalCSS,
|
||||
'plugins/daterangepicker/daterangepicker.css',
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
'plugins/vue/tt-components/css/tt-loader.css',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert PascalCase to snake_case (e.g. PascalCase to pascal-case)
|
||||
* @param $str string PascalCase string
|
||||
* @return string snake-case string
|
||||
*/
|
||||
function pascalToSnakeCase(string $str): string {
|
||||
$snakeCase = preg_replace('/(?<!^)([A-Z])/', '-$1', $str);
|
||||
return strtolower($snakeCase);
|
||||
}
|
||||
|
||||
$vueTagName = pascalToSnakeCase($vueViewName);
|
||||
|
||||
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/vueHeader.php"); ?>
|
||||
|
||||
|
||||
<div id="app">
|
||||
<<?php echo $vueTagName; ?>></<?php echo $vueTagName; ?>>
|
||||
</div>
|
||||
<script>
|
||||
const view = new Vue({el: '#app'});
|
||||
</script>
|
||||
|
||||
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>
|
||||
@@ -16,14 +16,14 @@
|
||||
<?php else: ?>
|
||||
<?php if(!$me->is("Admin")): ?>
|
||||
<li class="has-submenu">
|
||||
<a href="<?=self::getUrl("Dashboard")?>"><i class="fe-airplay"></i> Dashboard</a>
|
||||
<a href="<?=self::getUrl("Dashboard")?>"><i class="fa-solid fa-airplay"></i> Dashboard</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if($me->is("Admin")): ?>
|
||||
<li class="has-submenu">
|
||||
<a href="<?=self::getUrl("Dashboard")?>"><i class="fe-airplay"></i> Dashboard <div class="arrow-down"></div></a>
|
||||
<a href="<?=self::getUrl("Dashboard")?>"><i class="fa-solid fa-airplay"></i> Dashboard <div class="arrow-down"></div></a>
|
||||
<ul class="submenu">
|
||||
<li><a href="<?=self::getUrl("News")?>"><i class="far fa-fw fa-th-list text-info"></i> News</a></li>
|
||||
<?php if($me->is("employee")): ?>
|
||||
@@ -114,6 +114,7 @@
|
||||
<?php if($me->isAdmin() || $me->can("Cpeprovisioning")): ?><li><a href="<?=self::getUrl("Cpeprovisioning")?>"><i class="fad fa-fw fa-hdd text-info"></i> CPE Provisioning</a></li><?php endif; ?>
|
||||
<?php if($me->isAdmin() || $me->can("Cpeshipping")): ?><li><a href="<?=self::getUrl("Cpeshipping")?>"><i class="fad fa-fw fa-shipping-fast text-info"></i> CPE Versand</a></li><?php endif; ?>
|
||||
<?php if($me->isAdmin()) : ?><li><a href="<?=self::getUrl("Domain")?>"><i class="fad fa-fw fa-globe text-info"></i> Domains</a></li><?php endif; ?>
|
||||
<?php if($me->isAdmin()) : ?><li><a href="<?=self::getUrl("IpNetwork")?>"><i class="fa-solid fa-network-wired text-info"></i> IPAM</a></li><?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
77
Layout/default/vueHeader.php
Normal file
77
Layout/default/vueHeader.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php /** @var TYPE_NAME $git_merge_ts */ ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title><?=MFAPPNAME_FULL?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!-- App favicon -->
|
||||
<link rel="shortcut icon" href="<?=self::getResourcePath()?>assets/images/favicon.ico">
|
||||
|
||||
<link href="<?=self::getResourcePath()?>fontawesome/css/all.min.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>fontawesome/css/sharp-solid.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>fontawesome/css/sharp-regular.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>fontawesome/css/sharp-light.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>fontawesome/css/sharp-thin.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>assets/css/bootstrap.min.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>assets/css/app.min.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>plugins/notification/notify.min.css" rel="stylesheet" type="text/css" />
|
||||
<link href="<?=self::getResourcePath()?>assets/css/thetool.css?<?=$git_merge_ts?>" rel="stylesheet" type="text/css" />
|
||||
|
||||
|
||||
|
||||
<?php if(isset($additionalCSS) && is_array($additionalCSS) && count($additionalCSS)): ?>
|
||||
<?php foreach($additionalCSS as $css): ?>
|
||||
<link rel="stylesheet" href="<?=self::getResourcePath()?><?=$css?>?<?=$git_merge_ts?>" />
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.mfNotify = <?=isset($mfNotify) ? json_encode($mfNotify) : "null"; ?>;
|
||||
window.TT_CONFIG = {};
|
||||
|
||||
<?php
|
||||
if(isset($JSGlobals) && is_array($JSGlobals) && count($JSGlobals)):
|
||||
foreach($JSGlobals as $key => $value): ?>
|
||||
window.TT_CONFIG.<?=$key?> = <?=is_array($value) ? json_encode($value) : "'$value'"; ?>;
|
||||
<?php endforeach; endif;?>
|
||||
</script>
|
||||
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>assets/js/bootstrap-select.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js"></script>
|
||||
|
||||
<?php if(isset($additionalJS) && is_array($additionalJS) && count($additionalJS)): ?>
|
||||
<?php foreach($additionalJS as $js): ?>
|
||||
<script src="<?=self::getResourcePath()?><?=$js?>?<?=$git_merge_ts?>"></script>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if(MFAPPNAME == "devthetool"): ?>
|
||||
<style type="text/css">
|
||||
body {
|
||||
border-left: 8px dashed #f672a7;
|
||||
}
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
baseurl = '<?=self::getResourcePath()?>';
|
||||
</script>
|
||||
|
||||
<!-- Navigation Bar-->
|
||||
<header id="topnav">
|
||||
<?php include(realpath(dirname(__FILE__)."/")."/topbar.php"); ?>
|
||||
<?php include(realpath(dirname(__FILE__)."/")."/menu.php"); ?>
|
||||
</header>
|
||||
<!-- End Navigation Bar-->
|
||||
|
||||
|
||||
<div class="wrapper pl-0 pl-lg-1 pr-0 pr-lg-1 ">
|
||||
<div class="container-fluid">
|
||||
@@ -5,7 +5,7 @@ class DocumentationCheckController extends mfBaseController {
|
||||
private string $ZABBIX_API_URL = ZABBIX_API_URL;
|
||||
private string $ZABBIX_API_KEY = ZABBIX_API_KEY;
|
||||
|
||||
private Zabbix $zabbix;
|
||||
// private Zabbix $zabbix;
|
||||
|
||||
|
||||
protected function init(): void {
|
||||
@@ -18,7 +18,7 @@ class DocumentationCheckController extends mfBaseController {
|
||||
$this->redirect("dashboard");
|
||||
}
|
||||
|
||||
$this->zabbix = new Zabbix($this->ZABBIX_API_URL, $this->ZABBIX_API_KEY);
|
||||
// $this->zabbix = new Zabbix($this->ZABBIX_API_URL, $this->ZABBIX_API_KEY);
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
|
||||
@@ -26,7 +26,21 @@ class DomainController extends mfBaseController {
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
$this->layout()->setTemplate("Domain/Index");
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Domains",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Domain Management", "href" => self::getUrl("Domain")],
|
||||
["text" => "Domains"]
|
||||
],
|
||||
"DOMAIN_API_URL" => self::getUrl("Domain/api"),
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "Domain");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
@@ -128,7 +142,7 @@ class DomainController extends mfBaseController {
|
||||
"rows" => $domains,
|
||||
"pagination" => [
|
||||
"page" => $page,
|
||||
"total_pages" => ceil($totalRows / $perPage),
|
||||
"total_pages" => ceil($filtered_available / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => intval($filtered_available),
|
||||
"total_rows" => intval($totalRows)
|
||||
|
||||
@@ -73,25 +73,6 @@ class DomainModel {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL Filter condition (space separated) for a given column.
|
||||
*
|
||||
* @param string|null $filterValue The filter value to match against.
|
||||
* @param string $columnName The name of the column in the database table.
|
||||
* @return string The SQL condition generated based on the filter value and column name.
|
||||
*/
|
||||
public static function generateFilterCondition(?string $filterValue, string $columnName): string {
|
||||
$sql = "";
|
||||
if (!empty($filterValue)) {
|
||||
$filterItems = explode(" ", $filterValue);
|
||||
foreach ($filterItems as $item) {
|
||||
$sql .= " AND `$columnName` LIKE '%" . $item . "%'";
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
public static function getSqlFilter($filters): string {
|
||||
$sql = isset($filters['domain']) ? Helper::generateFilterCondition($filters['domain'], "domain") : "";
|
||||
$sql .= isset($filters['crDate']) ? Helper::generateFilterCondition($filters['crDate'], "crDate") : "";
|
||||
@@ -101,10 +82,10 @@ class DomainModel {
|
||||
$sql .= isset($filters['status']) ? Helper::generateFilterCondition($filters['status'], "status") : "";
|
||||
$sql .= isset($filters['transferLock']) ? Helper::generateFilterCondition($filters['transferLock'], "transferLock") : "";
|
||||
$sql .= isset($filters['authCode']) ? Helper::generateFilterCondition($filters['authCode'], "authCode") : "";
|
||||
$sql .= isset($filters['registrant']) && $filters['registrant'] !== 'all' ? " AND `registrant` = " . $filters['registrant'] : "";
|
||||
$sql .= isset($filters['admin']) && $filters['admin'] !== 'all' ? " AND `admin` = " . $filters['admin'] : "";
|
||||
$sql .= isset($filters['tech']) && $filters['tech'] !== 'all' ? " AND `tech` = " . $filters['tech'] : "";
|
||||
$sql .= isset($filters['billing']) && $filters['billing'] !== 'all' ? " AND `billing` = " . $filters['billing'] : "";
|
||||
$sql .= isset($filters['registrant']) ? Helper::generateFilterCondition($filters['registrant'], "registrant", true) : "";
|
||||
$sql .= isset($filters['admin']) ? Helper::generateFilterCondition($filters['admin'], "admin", true) : "";
|
||||
$sql .= isset($filters['tech']) ? Helper::generateFilterCondition($filters['tech'], "tech", true) : "";
|
||||
$sql .= isset($filters['billing']) ? Helper::generateFilterCondition($filters['billing'], "billing", true) : "";
|
||||
$sql .= isset($filters['ns']) ? Helper::generateFilterCondition($filters['ns'], "ns") : "";
|
||||
$sql .= isset($filters['pleskId']) ? " AND `pleskId` " . ($filters['pleskId'] === "0" ? "IS NULL" : "IS NOT NULL") : "";
|
||||
return $sql;
|
||||
|
||||
@@ -15,7 +15,21 @@ class HistoricTicketController extends mfBaseController {
|
||||
if (!$this->me->is("employee")) {
|
||||
$this->redirect("dashboard");
|
||||
}
|
||||
$this->layout()->setTemplate("HistoricTicket/Index");
|
||||
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Historische Tickets",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Historische Tickets", "href" => self::getUrl("HistoricTicket")]
|
||||
],
|
||||
"HISTORIC_TICKET_API_URL" => self::getUrl("HistoricTicket/api"),
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "HistoricTicket");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
@@ -66,7 +80,7 @@ class HistoricTicketController extends mfBaseController {
|
||||
"rows" => $historicTickets,
|
||||
"pagination" => [
|
||||
"page" => $page,
|
||||
"total_pages" => ceil($totalRows / $perPage),
|
||||
"total_pages" => ceil($filtered_available / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => intval($filtered_available),
|
||||
"total_rows" => intval($totalRows)
|
||||
|
||||
@@ -2,82 +2,18 @@
|
||||
|
||||
use phpseclib3\Net\SSH2;
|
||||
|
||||
class RaspberryDisplayController extends mfBaseController
|
||||
{
|
||||
class RaspberryDisplayController extends mfBaseController {
|
||||
private int $port = 22;
|
||||
private string $username = XINON_RASPBERRY_DISPLAY_SSH_USER;
|
||||
private string $password = XINON_RASPBERRY_DISPLAY_SSH_PASS;
|
||||
|
||||
protected function init(): void
|
||||
{
|
||||
protected function init(): void {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$this->me = $me;
|
||||
$this->layout()->set("me", $me);
|
||||
}
|
||||
|
||||
protected function restartRaspberryPi($id) {
|
||||
$display = RaspberryDisplayModel::get($id);
|
||||
|
||||
$ssh = new SSH2($display->ip_address, $this->port);
|
||||
$ssh->login($this->username, $this->password);
|
||||
$ssh->exec('sudo reboot now');
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getDisplaysApi(): array
|
||||
{
|
||||
$displays = RaspberryDisplayModel::getAll();
|
||||
$result = [];
|
||||
foreach ($displays as $display) {
|
||||
$result[] = [
|
||||
"display_label" => $display->display_label,
|
||||
"hostname" => $display->hostname,
|
||||
"ip" => $display->ip_address,
|
||||
"display_url" => $display->display_url,
|
||||
"auto_refresh_enabled" => $display->auto_refresh_enabled === "1",
|
||||
"margin_hot_fix_enabled" => $display->margin_hot_fix_enabled === "1",
|
||||
"custom_style" => $display->custom_style,
|
||||
"id" => $display->id,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function change()
|
||||
{
|
||||
$displayID = $this->request->displayID;
|
||||
$field = $this->request->field;
|
||||
$value = $this->request->value;
|
||||
$value = $value === "true" ? 1 : ($value === "false" ? 0 : $value);
|
||||
$display = RaspberryDisplayModel::get($displayID);
|
||||
if ($display === null) {
|
||||
return false;
|
||||
}
|
||||
$display->$field = $value;
|
||||
$display->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getConfig() {
|
||||
$hostname = $this->request->hostname;
|
||||
|
||||
$displays = RaspberryDisplayModel::getByHostname($hostname);
|
||||
|
||||
if ($displays === null) {
|
||||
die("No display found for this hostname and ip:" . $hostname . " X ");
|
||||
}
|
||||
|
||||
return array_map(function ($display) {
|
||||
return [
|
||||
"display_url" => $display->data->display_url,
|
||||
"auto_refresh_enabled" => $display->data->auto_refresh_enabled === "1",
|
||||
"margin_hot_fix_enabled" => $display->data->margin_hot_fix_enabled === "1",
|
||||
"id" => $display->id,
|
||||
];
|
||||
}
|
||||
, $displays);
|
||||
}
|
||||
protected function apiAction() {
|
||||
$do = $this->request->do;
|
||||
|
||||
@@ -111,7 +47,7 @@ class RaspberryDisplayController extends mfBaseController
|
||||
$this->returnJson($data);
|
||||
}
|
||||
|
||||
if(!is_array($return) || !count($return)) {
|
||||
if (!is_array($return) || !count($return)) {
|
||||
$data = ["status" => "error"];
|
||||
$this->returnJson($data);
|
||||
}
|
||||
@@ -120,9 +56,77 @@ class RaspberryDisplayController extends mfBaseController
|
||||
$this->returnJson($data);
|
||||
}
|
||||
|
||||
protected function indexAction(): void
|
||||
{
|
||||
$this->layout()->setTemplate("RaspberryDisplay/Index");
|
||||
protected function getDisplaysApi(): array {
|
||||
$displays = RaspberryDisplayModel::getAll();
|
||||
$result = [];
|
||||
foreach ($displays as $display) {
|
||||
$result[] = ["display_label" => $display->display_label,
|
||||
"hostname" => $display->hostname,
|
||||
"ip" => $display->ip_address,
|
||||
"display_url" => $display->display_url,
|
||||
"auto_refresh_enabled" => $display->auto_refresh_enabled === "1",
|
||||
"margin_hot_fix_enabled" => $display->margin_hot_fix_enabled === "1",
|
||||
"custom_style" => $display->custom_style,
|
||||
"id" => $display->id,];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function change() {
|
||||
$displayID = $this->request->displayID;
|
||||
$field = $this->request->field;
|
||||
$value = $this->request->value;
|
||||
$value = $value === "true" ? 1 : ($value === "false" ? 0 : $value);
|
||||
$display = RaspberryDisplayModel::get($displayID);
|
||||
if ($display === null) {
|
||||
return false;
|
||||
}
|
||||
$display->$field = $value;
|
||||
$display->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function restartRaspberryPi($id) {
|
||||
$display = RaspberryDisplayModel::get($id);
|
||||
|
||||
$ssh = new SSH2($display->ip_address, $this->port);
|
||||
$ssh->login($this->username, $this->password);
|
||||
$ssh->exec('sudo reboot now');
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getConfig() {
|
||||
$hostname = $this->request->hostname;
|
||||
|
||||
$displays = RaspberryDisplayModel::getByHostname($hostname);
|
||||
|
||||
if ($displays === null) {
|
||||
die("No display found for this hostname and ip:" . $hostname . " X ");
|
||||
}
|
||||
|
||||
return array_map(function ($display) {
|
||||
return ["display_url" => $display->data->display_url,
|
||||
"auto_refresh_enabled" => $display->data->auto_refresh_enabled === "1",
|
||||
"margin_hot_fix_enabled" => $display->data->margin_hot_fix_enabled === "1",
|
||||
"id" => $display->id,];
|
||||
}, $displays);
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("RaspberryDisplay"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Raspberry Displays",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Raspberry Displays", "href" => self::getUrl("RaspberryDisplay")]
|
||||
]
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "RaspberryDisplay");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->set("additionalCSS", ["css/views/RaspberryDisplay.css"]);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,21 @@ class VoiceCallActiveController extends mfBaseController {
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
$this->layout()->setTemplate("VoiceCallActive/Index");
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("VoiceCallActive"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Active Voice Calls",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Active Voice Calls", "href" => self::getUrl("VoiceCallActive")]
|
||||
],
|
||||
"VOICE_CALL_ACTIVE_API_URL" => self::getUrl("VoiceCallActive/api"),
|
||||
];
|
||||
|
||||
$this->layout()->set("additionalCSS", ["css/views/VoiceCallActive.css"]);
|
||||
$this->layout()->set("vueViewName", "VoiceCallActive");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
@@ -54,8 +68,10 @@ class VoiceCallActiveController extends mfBaseController {
|
||||
}
|
||||
|
||||
private function getActiveCalls(): array {
|
||||
$activeCalls = $this->kolmisoftMore->getActiveCalls();
|
||||
|
||||
return [
|
||||
"rows" => array_reverse($this->kolmisoftMore->getActiveCalls())
|
||||
"rows" => is_null($activeCalls) ? [] : (is_object($activeCalls) ? [$activeCalls] : $activeCalls),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,20 @@ class VoiceCallHistoryController extends mfBaseController {
|
||||
}
|
||||
|
||||
protected function indexAction(): void {
|
||||
$this->layout()->setTemplate("VoiceCallHistory/Index");
|
||||
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
|
||||
"DASHBOARD_URL" => self::getUrl("Dashboard"),
|
||||
"MFAPPNAME" => MFAPPNAME_SLUG,
|
||||
"PAGE_TITLE" => "Voice Call History",
|
||||
"PATH" => [
|
||||
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
|
||||
["text" => "Voice Call History", "href" => self::getUrl("VoiceCallHistory")]
|
||||
],
|
||||
"VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"),
|
||||
];
|
||||
|
||||
$this->layout()->set("vueViewName", "VoiceCallHistory");
|
||||
$this->layout()->set("JSGlobals", $JSGlobals);
|
||||
$this->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
@@ -73,6 +86,11 @@ class VoiceCallHistoryController extends mfBaseController {
|
||||
$page = $json['pagination']['page'] ?? 1;
|
||||
$perPage = $json['pagination']['per_page'] ?? 10;
|
||||
|
||||
if (isset($filters['start']['from']) && isset($filters['start']['to']) && is_numeric($filters['start']['to'])) {
|
||||
$filters['start']['from'] += 7200;
|
||||
$filters['start']['to'] += 7200;
|
||||
}
|
||||
|
||||
$calls = VoiceCallHistoryModel::getVoiceCallHistory($filters, $perPage, $perPage * $page - $perPage, $order);
|
||||
$filtered_available = VoiceCallHistoryModel::countVoiceCallHistory($filters);
|
||||
$totalRows = VoiceCallHistoryModel::countVoiceCallHistory([]);
|
||||
@@ -81,7 +99,7 @@ class VoiceCallHistoryController extends mfBaseController {
|
||||
"rows" => $calls,
|
||||
"pagination" => [
|
||||
"page" => $page,
|
||||
"total_pages" => ceil($totalRows / $perPage),
|
||||
"total_pages" => ceil($filtered_available / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => intval($filtered_available),
|
||||
"total_rows" => intval($totalRows)
|
||||
|
||||
@@ -9,21 +9,29 @@ class Helper {
|
||||
* @return string The SQL condition generated based on the filter value and column name.
|
||||
* @noinspection PhpMissingParamTypeInspection
|
||||
*/
|
||||
public static function generateFilterCondition($filterValue, string $columnName): string {
|
||||
public static function generateFilterCondition($filterValue, string $columnName, bool $exactMatch = false): string {
|
||||
$sql = "";
|
||||
|
||||
if (is_array($filterValue)) {
|
||||
if (isset($filterValue['from']) && isset($filterValue['to'])) {
|
||||
$sql = " AND `$columnName` >= " . $filterValue['from'] . " AND `$columnName` <= " . $filterValue['to'];
|
||||
} else if (isset($filterValue['from'])) {
|
||||
$sql = " AND `$columnName` >= " . $filterValue['from'];
|
||||
} else if (isset($filterValue['to'])) {
|
||||
$sql = " AND `$columnName` <= " . $filterValue['to'];
|
||||
}
|
||||
} else if ($filterValue === "0" || $filterValue === "1") {
|
||||
$sql .= " AND `$columnName` = " . $filterValue;
|
||||
} else if (!empty($filterValue)) {
|
||||
if ($exactMatch) {
|
||||
$sql .= " AND `$columnName` = '" . $filterValue . "'";
|
||||
} else {
|
||||
$filterItems = explode(" ", $filterValue);
|
||||
foreach ($filterItems as $item) {
|
||||
$sql .= " AND `$columnName` LIKE '%" . $item . "%'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
3
package.json
Normal file
3
package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"dependencies": {"vue": "2.6.11"}
|
||||
}
|
||||
721
public/JSShrink.php
Normal file
721
public/JSShrink.php
Normal file
@@ -0,0 +1,721 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of the JShrink package.
|
||||
*
|
||||
* (c) Robert Hafner <tedivm@tedivm.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* JShrink
|
||||
*
|
||||
*
|
||||
* @package JShrink
|
||||
* @author Robert Hafner <tedivm@tedivm.com>
|
||||
*/
|
||||
|
||||
namespace JShrink;
|
||||
|
||||
/**
|
||||
* Minifier
|
||||
*
|
||||
* Usage - Minifier::minify($js);
|
||||
* Usage - Minifier::minify($js, $options);
|
||||
* Usage - Minifier::minify($js, array('flaggedComments' => false));
|
||||
*
|
||||
* @package JShrink
|
||||
* @author Robert Hafner <tedivm@tedivm.com>
|
||||
* @license http://www.opensource.org/licenses/bsd-license.php BSD License
|
||||
*/
|
||||
class Minifier
|
||||
{
|
||||
/**
|
||||
* The input javascript to be minified.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $input;
|
||||
|
||||
/**
|
||||
* Length of input javascript.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $len = 0;
|
||||
|
||||
/**
|
||||
* The location of the character (in the input string) that is next to be
|
||||
* processed.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $index = 0;
|
||||
|
||||
/**
|
||||
* The first of the characters currently being looked at.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $a = '';
|
||||
|
||||
/**
|
||||
* The next character being looked at (after a);
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $b = '';
|
||||
|
||||
/**
|
||||
* This character is only active when certain look ahead actions take place.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $c;
|
||||
|
||||
/**
|
||||
* This character is only active when certain look ahead actions take place.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $last_char;
|
||||
|
||||
/**
|
||||
* This character is only active when certain look ahead actions take place.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $output;
|
||||
|
||||
/**
|
||||
* Contains the options for the current minification process.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $options;
|
||||
|
||||
/**
|
||||
* These characters are used to define strings.
|
||||
*/
|
||||
protected $stringDelimiters = ['\'' => true, '"' => true, '`' => true];
|
||||
|
||||
/**
|
||||
* Contains the default options for minification. This array is merged with
|
||||
* the one passed in by the user to create the request specific set of
|
||||
* options (stored in the $options attribute).
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $defaultOptions = ['flaggedComments' => true];
|
||||
|
||||
|
||||
protected static $keywords = ["delete", "do", "for", "in", "instanceof", "return", "typeof", "yield"];
|
||||
|
||||
protected $max_keyword_len;
|
||||
|
||||
/**
|
||||
* Contains lock ids which are used to replace certain code patterns and
|
||||
* prevent them from being minified
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $locks = [];
|
||||
|
||||
/**
|
||||
* Takes a string containing javascript and removes unneeded characters in
|
||||
* order to shrink the code without altering it's functionality.
|
||||
*
|
||||
* @param string $js The raw javascript to be minified
|
||||
* @param array $options Various runtime options in an associative array
|
||||
* @throws \Exception
|
||||
* @return bool|string
|
||||
*/
|
||||
public static function minify($js, $options = [])
|
||||
{
|
||||
try {
|
||||
$jshrink = new Minifier();
|
||||
$js = $jshrink->lock($js);
|
||||
$js = ltrim($jshrink->minifyToString($js, $options));
|
||||
$js = $jshrink->unlock($js);
|
||||
unset($jshrink);
|
||||
return $js;
|
||||
} catch (\Exception $e) {
|
||||
if (isset($jshrink)) {
|
||||
// Since the breakdownScript function probably wasn't finished
|
||||
// we clean it out before discarding it.
|
||||
$jshrink->clean();
|
||||
unset($jshrink);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a javascript string and outputs only the required characters,
|
||||
* stripping out all unneeded characters.
|
||||
*
|
||||
* @param string $js The raw javascript to be minified
|
||||
* @param array $options Various runtime options in an associative array
|
||||
*/
|
||||
protected function minifyToString($js, $options)
|
||||
{
|
||||
$this->initialize($js, $options);
|
||||
$this->loop();
|
||||
$this->clean();
|
||||
return $this->output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes internal variables, normalizes new lines,
|
||||
*
|
||||
* @param string $js The raw javascript to be minified
|
||||
* @param array $options Various runtime options in an associative array
|
||||
*/
|
||||
protected function initialize($js, $options)
|
||||
{
|
||||
$this->options = array_merge(static::$defaultOptions, $options);
|
||||
$this->input = $js;
|
||||
|
||||
// We add a newline to the end of the script to make it easier to deal
|
||||
// with comments at the bottom of the script- this prevents the unclosed
|
||||
// comment error that can otherwise occur.
|
||||
$this->input .= PHP_EOL;
|
||||
|
||||
// save input length to skip calculation every time
|
||||
$this->len = strlen($this->input);
|
||||
|
||||
// Populate "a" with a new line, "b" with the first character, before
|
||||
// entering the loop
|
||||
$this->a = "\n";
|
||||
$this->b = "\n";
|
||||
$this->last_char = "\n";
|
||||
$this->output = "";
|
||||
|
||||
$this->max_keyword_len = max(array_map('strlen', static::$keywords));
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that can't stand alone preserve the newline.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $noNewLineCharacters = [
|
||||
'(' => true,
|
||||
'-' => true,
|
||||
'+' => true,
|
||||
'[' => true,
|
||||
'#' => true,
|
||||
'@' => true];
|
||||
|
||||
|
||||
protected function echo($char) {
|
||||
$this->output .= $char;
|
||||
$this->last_char = $char[-1];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The primary action occurs here. This function loops through the input string,
|
||||
* outputting anything that's relevant and discarding anything that is not.
|
||||
*/
|
||||
protected function loop()
|
||||
{
|
||||
while ($this->a !== false && !is_null($this->a) && $this->a !== '') {
|
||||
switch ($this->a) {
|
||||
// new lines
|
||||
case "\r":
|
||||
case "\n":
|
||||
// if the next line is something that can't stand alone preserve the newline
|
||||
if ($this->b !== false && isset($this->noNewLineCharacters[$this->b])) {
|
||||
$this->echo($this->a);
|
||||
$this->saveString();
|
||||
break;
|
||||
}
|
||||
|
||||
// if B is a space we skip the rest of the switch block and go down to the
|
||||
// string/regex check below, resetting $this->b with getReal
|
||||
if ($this->b === ' ') {
|
||||
break;
|
||||
}
|
||||
|
||||
// otherwise we treat the newline like a space
|
||||
|
||||
// no break
|
||||
case ' ':
|
||||
if (static::isAlphaNumeric($this->b)) {
|
||||
$this->echo($this->a);
|
||||
}
|
||||
|
||||
$this->saveString();
|
||||
break;
|
||||
|
||||
default:
|
||||
switch ($this->b) {
|
||||
case "\r":
|
||||
case "\n":
|
||||
if (strpos('}])+-"\'', $this->a) !== false) {
|
||||
$this->echo($this->a);
|
||||
$this->saveString();
|
||||
break;
|
||||
} else {
|
||||
if (static::isAlphaNumeric($this->a)) {
|
||||
$this->echo($this->a);
|
||||
$this->saveString();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
if (!static::isAlphaNumeric($this->a)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// no break
|
||||
default:
|
||||
// check for some regex that breaks stuff
|
||||
if ($this->a === '/' && ($this->b === '\'' || $this->b === '"')) {
|
||||
$this->saveRegex();
|
||||
continue 3;
|
||||
}
|
||||
|
||||
$this->echo($this->a);
|
||||
$this->saveString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// do reg check of doom
|
||||
$this->b = $this->getReal();
|
||||
|
||||
if ($this->b == '/') {
|
||||
$valid_tokens = "(,=:[!&|?\n";
|
||||
|
||||
# Find last "real" token, excluding spaces.
|
||||
$last_token = $this->a;
|
||||
if ($last_token == " ") {
|
||||
$last_token = $this->last_char;
|
||||
}
|
||||
|
||||
if (strpos($valid_tokens, $last_token) !== false) {
|
||||
// Regex can appear unquoted after these symbols
|
||||
$this->saveRegex();
|
||||
} else if ($this->endsInKeyword()) {
|
||||
// This block checks for the "return" token before the slash.
|
||||
$this->saveRegex();
|
||||
}
|
||||
}
|
||||
|
||||
// if (($this->b == '/' && strpos('(,=:[!&|?', $this->a) !== false)) {
|
||||
// $this->saveRegex();
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets attributes that do not need to be stored between requests so that
|
||||
* the next request is ready to go. Another reason for this is to make sure
|
||||
* the variables are cleared and are not taking up memory.
|
||||
*/
|
||||
protected function clean()
|
||||
{
|
||||
unset($this->input);
|
||||
$this->len = 0;
|
||||
$this->index = 0;
|
||||
$this->a = $this->b = '';
|
||||
unset($this->c);
|
||||
unset($this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next string for processing based off of the current index.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getChar()
|
||||
{
|
||||
// Check to see if we had anything in the look ahead buffer and use that.
|
||||
if (isset($this->c)) {
|
||||
$char = $this->c;
|
||||
unset($this->c);
|
||||
} else {
|
||||
// Otherwise we start pulling from the input.
|
||||
$char = $this->index < $this->len ? $this->input[$this->index] : false;
|
||||
|
||||
// If the next character doesn't exist return false.
|
||||
if (isset($char) && $char === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise increment the pointer and use this char.
|
||||
$this->index++;
|
||||
}
|
||||
|
||||
# Convert all line endings to unix standard.
|
||||
# `\r\n` converts to `\n\n` and is minified.
|
||||
if ($char == "\r") {
|
||||
$char = "\n";
|
||||
}
|
||||
|
||||
// Normalize all whitespace except for the newline character into a
|
||||
// standard space.
|
||||
if ($char !== "\n" && $char < "\x20") {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
return $char;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns the next character without moving the index forward.
|
||||
*
|
||||
*
|
||||
* @return string The next character
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected function peek()
|
||||
{
|
||||
if ($this->index >= $this->len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$char = $this->input[$this->index];
|
||||
# Convert all line endings to unix standard.
|
||||
# `\r\n` converts to `\n\n` and is minified.
|
||||
if ($char == "\r") {
|
||||
$char = "\n";
|
||||
}
|
||||
|
||||
// Normalize all whitespace except for the newline character into a
|
||||
// standard space.
|
||||
if ($char !== "\n" && $char < "\x20") {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
# Return the next character but don't push the index.
|
||||
return $char;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function gets the next "real" character. It is essentially a wrapper
|
||||
* around the getChar function that skips comments. This has significant
|
||||
* performance benefits as the skipping is done using native functions (ie,
|
||||
* c code) rather than in script php.
|
||||
*
|
||||
*
|
||||
* @return string Next 'real' character to be processed.
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected function getReal()
|
||||
{
|
||||
$startIndex = $this->index;
|
||||
$char = $this->getChar();
|
||||
|
||||
// Check to see if we're potentially in a comment
|
||||
if ($char !== '/') {
|
||||
return $char;
|
||||
}
|
||||
|
||||
$this->c = $this->getChar();
|
||||
|
||||
if ($this->c === '/') {
|
||||
$this->processOneLineComments($startIndex);
|
||||
|
||||
return $this->getReal();
|
||||
} elseif ($this->c === '*') {
|
||||
$this->processMultiLineComments($startIndex);
|
||||
|
||||
return $this->getReal();
|
||||
}
|
||||
|
||||
return $char;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed one line comments, with the exception of some very specific types of
|
||||
* conditional comments.
|
||||
*
|
||||
* @param int $startIndex The index point where "getReal" function started
|
||||
* @return void
|
||||
*/
|
||||
protected function processOneLineComments($startIndex)
|
||||
{
|
||||
$thirdCommentString = $this->index < $this->len ? $this->input[$this->index] : false;
|
||||
|
||||
// kill rest of line
|
||||
$this->getNext("\n");
|
||||
|
||||
unset($this->c);
|
||||
|
||||
if ($thirdCommentString == '@') {
|
||||
$endPoint = $this->index - $startIndex;
|
||||
$this->c = "\n" . substr($this->input, $startIndex, $endPoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips multiline comments where appropriate, and includes them where needed.
|
||||
* Conditional comments and "license" style blocks are preserved.
|
||||
*
|
||||
* @param int $startIndex The index point where "getReal" function started
|
||||
* @return void
|
||||
* @throws \RuntimeException Unclosed comments will throw an error
|
||||
*/
|
||||
protected function processMultiLineComments($startIndex)
|
||||
{
|
||||
$this->getChar(); // current C
|
||||
$thirdCommentString = $this->getChar();
|
||||
|
||||
// Detect a completely empty comment, ie `/**/`
|
||||
if ($thirdCommentString == "*") {
|
||||
$peekChar = $this->peek();
|
||||
if ($peekChar == "/") {
|
||||
$this->index++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// kill everything up to the next */ if it's there
|
||||
if ($this->getNext('*/')) {
|
||||
$this->getChar(); // get *
|
||||
$this->getChar(); // get /
|
||||
$char = $this->getChar(); // get next real character
|
||||
|
||||
// Now we reinsert conditional comments and YUI-style licensing comments
|
||||
if (($this->options['flaggedComments'] && $thirdCommentString === '!')
|
||||
|| ($thirdCommentString === '@')) {
|
||||
|
||||
// If conditional comments or flagged comments are not the first thing in the script
|
||||
// we need to echo a and fill it with a space before moving on.
|
||||
if ($startIndex > 0) {
|
||||
$this->echo($this->a);
|
||||
$this->a = " ";
|
||||
|
||||
// If the comment started on a new line we let it stay on the new line
|
||||
if ($this->input[($startIndex - 1)] === "\n") {
|
||||
$this->echo("\n");
|
||||
}
|
||||
}
|
||||
|
||||
$endPoint = ($this->index - 1) - $startIndex;
|
||||
$this->echo(substr($this->input, $startIndex, $endPoint));
|
||||
|
||||
$this->c = $char;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$char = false;
|
||||
}
|
||||
|
||||
if ($char === false) {
|
||||
throw new \RuntimeException('Unclosed multiline comment at position: ' . ($this->index - 2));
|
||||
}
|
||||
|
||||
// if we're here c is part of the comment and therefore tossed
|
||||
$this->c = $char;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the index ahead to the next instance of the supplied string. If it
|
||||
* is found the first character of the string is returned and the index is set
|
||||
* to it's position.
|
||||
*
|
||||
* @param string $string
|
||||
* @return string|false Returns the first character of the string or false.
|
||||
*/
|
||||
protected function getNext($string)
|
||||
{
|
||||
// Find the next occurrence of "string" after the current position.
|
||||
$pos = strpos($this->input, $string, $this->index);
|
||||
|
||||
// If it's not there return false.
|
||||
if ($pos === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Adjust position of index to jump ahead to the asked for string
|
||||
$this->index = $pos;
|
||||
|
||||
// Return the first character of that string.
|
||||
return $this->index < $this->len ? $this->input[$this->index] : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a javascript string is detected this function crawls for the end of
|
||||
* it and saves the whole string.
|
||||
*
|
||||
* @throws \RuntimeException Unclosed strings will throw an error
|
||||
*/
|
||||
protected function saveString()
|
||||
{
|
||||
$startpos = $this->index;
|
||||
|
||||
// saveString is always called after a gets cleared, so we push b into
|
||||
// that spot.
|
||||
$this->a = $this->b;
|
||||
|
||||
// If this isn't a string we don't need to do anything.
|
||||
if (!isset($this->stringDelimiters[$this->a])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// String type is the quote used, " or '
|
||||
$stringType = $this->a;
|
||||
|
||||
// Echo out that starting quote
|
||||
$this->echo($this->a);
|
||||
|
||||
// Loop until the string is done
|
||||
// Grab the very next character and load it into a
|
||||
while (($this->a = $this->getChar()) !== false) {
|
||||
switch ($this->a) {
|
||||
|
||||
// If the string opener (single or double quote) is used
|
||||
// output it and break out of the while loop-
|
||||
// The string is finished!
|
||||
case $stringType:
|
||||
break 2;
|
||||
|
||||
// New lines in strings without line delimiters are bad- actual
|
||||
// new lines will be represented by the string \n and not the actual
|
||||
// character, so those will be treated just fine using the switch
|
||||
// block below.
|
||||
case "\n":
|
||||
if ($stringType === '`') {
|
||||
$this->echo($this->a);
|
||||
} else {
|
||||
throw new \RuntimeException('Unclosed string at position: ' . $startpos);
|
||||
}
|
||||
break;
|
||||
|
||||
// Escaped characters get picked up here. If it's an escaped new line it's not really needed
|
||||
case '\\':
|
||||
|
||||
// a is a slash. We want to keep it, and the next character,
|
||||
// unless it's a new line. New lines as actual strings will be
|
||||
// preserved, but escaped new lines should be reduced.
|
||||
$this->b = $this->getChar();
|
||||
|
||||
// If b is a new line we discard a and b and restart the loop.
|
||||
if ($this->b === "\n") {
|
||||
break;
|
||||
}
|
||||
|
||||
// echo out the escaped character and restart the loop.
|
||||
$this->echo($this->a . $this->b);
|
||||
break;
|
||||
|
||||
|
||||
// Since we're not dealing with any special cases we simply
|
||||
// output the character and continue our loop.
|
||||
default:
|
||||
$this->echo($this->a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a regular expression is detected this function crawls for the end of
|
||||
* it and saves the whole regex.
|
||||
*
|
||||
* @throws \RuntimeException Unclosed regex will throw an error
|
||||
*/
|
||||
protected function saveRegex()
|
||||
{
|
||||
if ($this->a != " ") {
|
||||
$this->echo($this->a);
|
||||
}
|
||||
|
||||
$this->echo($this->b);
|
||||
|
||||
while (($this->a = $this->getChar()) !== false) {
|
||||
if ($this->a === '/') {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($this->a === '\\') {
|
||||
$this->echo($this->a);
|
||||
$this->a = $this->getChar();
|
||||
}
|
||||
|
||||
if ($this->a === "\n") {
|
||||
throw new \RuntimeException('Unclosed regex pattern at position: ' . $this->index);
|
||||
}
|
||||
|
||||
$this->echo($this->a);
|
||||
}
|
||||
$this->b = $this->getReal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if a character is alphanumeric.
|
||||
*
|
||||
* @param string $char Just one character
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isAlphaNumeric($char)
|
||||
{
|
||||
return preg_match('/^[\w\$\pL]$/', $char) === 1 || $char == '/';
|
||||
}
|
||||
|
||||
protected function endsInKeyword() {
|
||||
|
||||
# When this function is called A is not yet assigned to output.
|
||||
# Regular expression only needs to check final part of output for keyword.
|
||||
$testOutput = substr($this->output . $this->a, -1 * ($this->max_keyword_len + 10));
|
||||
|
||||
foreach(static::$keywords as $keyword) {
|
||||
if (preg_match('/[^\w]'.$keyword.'[ ]?$/i', $testOutput) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Replace patterns in the given string and store the replacement
|
||||
*
|
||||
* @param string $js The string to lock
|
||||
* @return bool
|
||||
*/
|
||||
protected function lock($js)
|
||||
{
|
||||
/* lock things like <code>"asd" + ++x;</code> */
|
||||
$lock = '"LOCK---' . crc32(time()) . '"';
|
||||
|
||||
$matches = [];
|
||||
preg_match('/([+-])(\s+)([+-])/S', $js, $matches);
|
||||
if (empty($matches)) {
|
||||
return $js;
|
||||
}
|
||||
|
||||
$this->locks[$lock] = $matches[2];
|
||||
|
||||
$js = preg_replace('/([+-])\s+([+-])/S', "$1{$lock}$2", $js);
|
||||
/* -- */
|
||||
|
||||
return $js;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace "locks" with the original characters
|
||||
*
|
||||
* @param string $js The string to unlock
|
||||
* @return bool
|
||||
*/
|
||||
protected function unlock($js)
|
||||
{
|
||||
if (empty($this->locks)) {
|
||||
return $js;
|
||||
}
|
||||
|
||||
foreach ($this->locks as $lock => $replacement) {
|
||||
$js = str_replace($lock, $replacement, $js);
|
||||
}
|
||||
|
||||
return $js;
|
||||
}
|
||||
}
|
||||
48
public/bundler.php
Normal file
48
public/bundler.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
include 'JSShrink.php';
|
||||
|
||||
// Set the content type to JavaScript
|
||||
header('Content-Type: application/javascript');
|
||||
header('Cache-Control: max-age=31536000, public'); // Cache for 1 year
|
||||
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 31536000) . ' GMT'); // Expires in 1 year
|
||||
|
||||
function combineAndMinifyJS($files) {
|
||||
$combinedContent = '';
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (file_exists($file)) {
|
||||
$content = file_get_contents($file);
|
||||
// Minify using JSShrink
|
||||
$content = \JShrink\Minifier::minify($content);
|
||||
$combinedContent .= $content . "\n";
|
||||
} else {
|
||||
header("HTTP/1.1 404 Not Found");
|
||||
echo "File not found: $file";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
return $combinedContent;
|
||||
}
|
||||
|
||||
$jsFiles = [
|
||||
"plugins/axios/axios.min.js",
|
||||
"plugins/moment/moment.min.js",
|
||||
"plugins/daterangepicker/daterangepicker.js",
|
||||
"plugins/vue/" . (isset($_GET['VUE_DEBUG']) || $_SERVER['HTTP_HOST'] === "localhost" ? "vue.js" : "vue.min.js"),
|
||||
"plugins/vue/tt-components/tt-table.js",
|
||||
"plugins/vue/tt-components/tt-page-title.js",
|
||||
"plugins/vue/tt-components/tt-loader.js",
|
||||
"plugins/vue/tt-components/tt-select.js",
|
||||
"plugins/vue/tt-components/tt-datepicker.js",
|
||||
"plugins/vue/tt-components/tt-input.js",
|
||||
"plugins/vue/tt-components/tt-autocomplete.js",
|
||||
"plugins/vue/tt-components/tt-icon-select.js",
|
||||
"plugins/vue/tt-components/tt-number-range.js",
|
||||
];
|
||||
|
||||
|
||||
// Output the combined and minified JavaScript
|
||||
$minified = combineAndMinifyJS($jsFiles);
|
||||
echo $minified;
|
||||
?>
|
||||
13
public/css/views/VoiceCallActive.css
Normal file
13
public/css/views/VoiceCallActive.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.voice-green {
|
||||
background-color: #6CAE75 !important
|
||||
}
|
||||
.voice-yellow {
|
||||
background-color: #E8E288 !important
|
||||
}
|
||||
.voice-red {
|
||||
background-color: #FF8360 !important
|
||||
}
|
||||
|
||||
.table-sm td {
|
||||
padding: 4px !important;
|
||||
}
|
||||
225
public/js/pages/Domain/Domain.js
Normal file
225
public/js/pages/Domain/Domain.js
Normal file
@@ -0,0 +1,225 @@
|
||||
Vue.component('Domain', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
|
||||
<!-- start page title -->
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDomains'" :config="domainsTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="reloadDomains">
|
||||
<template v-if="reloadDomainsLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Reload Domains
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model="checkDomainInput" placeholder="Neue Domain überprüfen">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" @click="checkDomainAvailability">
|
||||
<template v-if="checkDomainLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Slot to show DNS records button -->
|
||||
<template v-slot:inwxroid="{ row }">
|
||||
<button type="button" class="btn btn-primary" @click="showDnsRecordsModal(row.domain)"
|
||||
:class="dnsRecordsModalLoading === row.domain ? 'disabled' : ''">
|
||||
<template v-if="dnsRecordsModalLoading === row.domain">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<span v-else>DNS</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Registrant Admin Tech Billing from domainContacts -->
|
||||
<template v-slot:registrant="{ row }">
|
||||
{{ domainContacts[row.registrant] ? domainContacts[row.registrant]["name"] : '' }}
|
||||
</template>
|
||||
<template v-slot:admin="{ row }">{{ domainContacts[row.admin] ? domainContacts[row.admin]["name"] : ''}}
|
||||
</template>
|
||||
<template v-slot:tech="{ row }">{{ domainContacts[row.tech] ? domainContacts[row.tech]["name"] : ''}}
|
||||
</template>
|
||||
<template v-slot:billing="{ row }">{{ domainContacts[row.billing] ? domainContacts[row.billing]["name"] : ''}}
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
<!-- Bootstrap Modal to query and show all DNS records for a domain -->
|
||||
<div class="modal show d-block" tabindex="-1" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
|
||||
ref="dnsRecordsModal" @click="dnsRecordsModal.domain = null" @keydown.esc="dnsRecordsModal.domain = null"
|
||||
v-if="dnsRecordsModal.domain">
|
||||
<div class="modal-dialog" role="document" style="max-width: fit-content" @click.stop>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">DNS Records for {{ dnsRecordsModal.domain ?? '' }}</h5>
|
||||
<button type="button" class="close" @click="dnsRecordsModal.domain = null">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="tt-table table-striped table-bordered table-hover table-sm table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Record Class</th>
|
||||
<th>Record Type</th>
|
||||
<th>Record Host</th>
|
||||
<th>Record Value</th>
|
||||
<th>Record TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in dnsRecordsModal.records">
|
||||
<td>{{ record.class }}</td>
|
||||
<td>{{ record.type }} {{ record.pri ? '(' + record.pri + ')' : '' }}</td>
|
||||
<td>{{ record.host }}</td>
|
||||
<td>{{ record.value }}</td>
|
||||
<td>{{ record.ttl }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="dnsRecordsModal.domain = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
domainContacts: {},
|
||||
reloadDomainsLoading: false,
|
||||
dnsRecordsModalLoading: null,
|
||||
dnsRecordsModal: {
|
||||
domain: null, records: []
|
||||
},
|
||||
checkDomainInput: '',
|
||||
checkDomainResult: null,
|
||||
checkDomainLoading: false
|
||||
}
|
||||
}, created() {
|
||||
this.fetchDomainContacts().then()
|
||||
}, computed: {
|
||||
domainsTableConfig() {
|
||||
const base = {
|
||||
headers: [{text: "DNS", key: "inwxRoId", filter: false, sortable: false}, {
|
||||
text: "Domain",
|
||||
key: "domain"
|
||||
}, {
|
||||
text: "Plesk",
|
||||
key: "pleskId",
|
||||
filter: 'iconSelect',
|
||||
filterOptions: [{value: 1, text: 'Yes', icon: 'fas fa-check text-success'}, {
|
||||
value: 0,
|
||||
text: 'No',
|
||||
icon: 'fas fa-times text-danger'
|
||||
}],
|
||||
sortable: false
|
||||
}, {text: "Created Date", key: "crDate", filter: "date"}, {
|
||||
text: "Expiration Date",
|
||||
key: "exDate",
|
||||
filter: "date"
|
||||
}, {text: "Renewal Date", key: "reDate", filter: "date"}, {
|
||||
text: "Updated Date",
|
||||
key: "upDate",
|
||||
filter: "date"
|
||||
}, {
|
||||
text: "Transfer Lock",
|
||||
key: "transferLock",
|
||||
filter: 'iconSelect',
|
||||
filterOptions: [{value: 1, text: 'Locked', icon: 'fas fa-lock text-danger'}, {
|
||||
value: 0,
|
||||
text: 'Unlocked',
|
||||
icon: 'fas fa-unlock text-success'
|
||||
}]
|
||||
}, {text: "Authorization Code", key: "authCode", sortable: false}, {
|
||||
text: "Registrant ID",
|
||||
key: "registrant",
|
||||
sortable: false
|
||||
}, {text: "Admin ID", key: "admin", sortable: false}, {
|
||||
text: "Tech ID",
|
||||
key: "tech",
|
||||
sortable: false
|
||||
}, {text: "Billing ID", key: "billing", sortable: false}, {text: "Name Servers", key: "ns"}],
|
||||
tableHeader: 'Domains',
|
||||
key: 'Domain'
|
||||
}
|
||||
|
||||
const domainContactsSorted = Object.entries(this.domainContacts).sort(([, a], [, b]) => a.name.localeCompare(b.name))
|
||||
const domainContactsFilterOptions = domainContactsSorted.map(([, contact]) => {
|
||||
return {text: contact.name, value: contact.inwxRoId}
|
||||
})
|
||||
|
||||
// for registrant admin tech billing set filter to select with domainContacts if domainContacts is not empty
|
||||
if (Object.keys(this.domainContacts).length > 0) {
|
||||
base.headers = base.headers.map(header => {
|
||||
if (['registrant', 'admin', 'tech', 'billing'].includes(header.key)) {
|
||||
header.filter = 'select'
|
||||
header.filterOptions = domainContactsFilterOptions
|
||||
}
|
||||
return header
|
||||
})
|
||||
}
|
||||
return base
|
||||
}
|
||||
}, methods: {
|
||||
async showDnsRecordsModal(domain) {
|
||||
this.dnsRecordsModalLoading = domain
|
||||
this.dnsRecordsModal = {
|
||||
domain: null, records: []
|
||||
}
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDnsRecords&domain=' + domain)
|
||||
this.dnsRecordsModal.domain = domain
|
||||
this.dnsRecordsModal.records = response.data.map(record => {
|
||||
if (typeof record.entries === 'object') {
|
||||
record.value = record.entries[0]
|
||||
} else {
|
||||
record.value = record.target || record.txt || record.ip
|
||||
}
|
||||
if (record.type === 'SOA') {
|
||||
record.value = record.mname + ' ' + record.rname + ' ' + record.serial + ' ' + record.refresh + ' ' + record.retry + ' ' + record.expire
|
||||
|
||||
}
|
||||
return record
|
||||
})
|
||||
this.dnsRecordsModalLoading = null
|
||||
this.$nextTick(() => {
|
||||
this.$refs.dnsRecordsModal.focus()
|
||||
})
|
||||
}, async fetchDomainContacts() {
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=getDomainContacts')
|
||||
this.domainContacts = response.data
|
||||
}, async reloadDomains() {
|
||||
this.reloadDomainsLoading = true
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=importAllDomains')
|
||||
window.notify('success', response.data["importMessages"].join('<br>'))
|
||||
await Promise.all([this.fetchDomainContacts(), this.$refs.table.fetchData(this.$refs.table.pagination.page)])
|
||||
this.reloadDomainsLoading = false
|
||||
}, //TODO: make this cleaner
|
||||
async checkDomainAvailability() {
|
||||
this.checkDomainLoading = true
|
||||
const response = await axios.get(window['TT_CONFIG']['DOMAIN_API_URL'] + '?do=checkDomain&domain=' + this.checkDomainInput)
|
||||
const priceInformation = response.data.price.domain[this.checkDomainInput]
|
||||
window.notify(response.data.status === 'free' ? 'success' : 'error', `Domain ist ${response.data.status === 'free' ? 'verfügbar. Registrieren um' : 'nicht frei. Transfer um'} ${priceInformation.price}${priceInformation.currency}/${priceInformation.period === '1Y' ? 'Jahr' : priceInformation.period}`)
|
||||
this.checkDomainLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
185
public/js/pages/HistoricTicket/HistoricTicket.js
Normal file
185
public/js/pages/HistoricTicket/HistoricTicket.js
Normal file
@@ -0,0 +1,185 @@
|
||||
Vue.component('HistoricTicket', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['HISTORIC_TICKET_API_URL'] + '?do=getHistoricTickets'"
|
||||
:config="historicTicketTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<!-- add input for global search with label and bootstrap class-->
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" placeholder="Globale Suche" v-model="globalSearch"
|
||||
@keydown.enter="doGlobalSearch">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="button" @click="doGlobalSearch">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:first_name="{ row }">
|
||||
{{ row.first_name }} {{ row.last_name }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ctime="{ row }">
|
||||
{{ new Date(row.ctime * 1000).toLocaleString() }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ticket_number="{ row }">
|
||||
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
|
||||
<!-- Bootstrap Modal to show global search results -->
|
||||
<div class="modal show d-block" tabindex="0" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
|
||||
@click="globalSearchModal = false" @keydown.esc="globalSearchModal = false" ref="globalSearchModal"
|
||||
v-if="globalSearchModal">
|
||||
<div class="modal-dialog" role="document" @click.stop
|
||||
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Suchergebnisse</h5>
|
||||
<button type="button" class="close" @click="globalSearchModal = false">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<tt-table
|
||||
:fetch-url="\`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=findHistoricTicket&query=\${globalSearch}\`"
|
||||
:config="globalSearchModalTableConfig"
|
||||
small ref="table">
|
||||
|
||||
<template v-slot:ctime="{ row }">
|
||||
{{ window.moment(row.ctime * 1000).format('DD.MM.YYYY HH:mm') }}
|
||||
</template>
|
||||
|
||||
<template v-slot:ticket_number="{ row }">
|
||||
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
|
||||
</template>
|
||||
</tt-table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="globalSearchModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap Modal to show ticket messages -->
|
||||
<div class="modal show d-block" tabindex="0" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
|
||||
@click="selectedTicketNumber = null" @keydown.esc="selectedTicketNumber = null" ref="selectedTicketModal"
|
||||
v-if="selectedTicketNumber">
|
||||
<div class="modal-dialog" role="document" @click.stop
|
||||
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Ticket {{ selectedTicketNumber }}</h5>
|
||||
<button type="button" class="close" @click="selectedTicketNumber = null">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="selectedTicketData">
|
||||
<h5>{{ selectedTicketData.ticket.subject }}</h5>
|
||||
<p>{{ selectedTicketData.ticket.message }}</p>
|
||||
<div v-for="message in selectedTicketData.messages">
|
||||
<hr>
|
||||
<h6>{{ new Date(message.ctime * 1000).toLocaleString()}}</h6>
|
||||
<p style="word-break: break-all;" v-html="message.content?.replaceAll('\\n', '<br>')"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="spinner-border text-primary" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`, data() {
|
||||
return {
|
||||
window: window,
|
||||
selectedTicketNumber: null,
|
||||
selectedTicketData: null,
|
||||
globalSearch: '',
|
||||
globalSearchModal: false,
|
||||
globalSearchModalTableConfig: {
|
||||
headers: [{text: 'Ticket Number', key: 'ticket_number', filter: false, sortable: false},
|
||||
{text: 'Erstellt', key: 'ctime', filter: false, sortable: false},
|
||||
{text: 'Subject', key: 'ticket_subject', filter: false, sortable: false},
|
||||
{text: 'Message', key: 'ticket_message', filter: false, sortable: false},],
|
||||
tableHeader: 'Suchergebnisse',
|
||||
key: 'HistoricTicketGlobalSearch',
|
||||
},
|
||||
historicTicketTableConfig: {
|
||||
headers: [{text: 'Ticket Number', key: 'ticket_number', filter: 'search'},
|
||||
{text: 'Erstellt', key: 'ctime', filter: false},
|
||||
{text: 'Subject', key: 'subject', filter: 'search', sortable: false},
|
||||
{
|
||||
text: 'Type',
|
||||
key: 'type',
|
||||
filter: 'select',
|
||||
filterOptions: [{value: 'BACKOFFICE', text: 'BACKOFFICE'},
|
||||
{value: 'KUNDENANFRAGEN', text: 'KUNDENANFRAGEN'},
|
||||
{value: 'STÖRUNGEN', text: 'STÖRUNGEN'},
|
||||
{value: 'ALLGEMEINES', text: 'ALLGEMEINES'},
|
||||
{value: 'TERMIN VEREINBART', text: 'TERMIN VEREINBART'},
|
||||
{value: 'VERRECHNEN AB DATUM', text: 'VERRECHNEN AB DATUM'},
|
||||
{value: 'ONLINE-TICKETS', text: 'ONLINE-TICKETS'},
|
||||
{value: 'KÜNDIGUNG', text: 'KÜNDIGUNG'},
|
||||
{value: 'BESTELLUNGEN', text: 'BESTELLUNGEN'},
|
||||
{value: 'PORTIERUNG', text: 'PORTIERUNG'},
|
||||
{value: 'KABEL-TV', text: 'KABEL-TV'},
|
||||
{value: 'TIEFBAU', text: 'TIEFBAU'},
|
||||
{value: 'ENERGIE STEIERMARK', text: 'ENERGIE STEIERMARK'},
|
||||
{value: 'INTERN', text: 'INTERN'},
|
||||
{value: 'FELIX', text: 'FELIX'},
|
||||
{value: '0', text: '0'},
|
||||
{value: 'JETTEN', text: 'JETTEN'},]
|
||||
},
|
||||
{
|
||||
text: 'Status',
|
||||
key: 'status',
|
||||
filter: 'select',
|
||||
filterOptions: [{value: 'Geschlossen', text: 'Geschlossen'},
|
||||
{value: 'In Evidenz', text: 'In Evidenz'},
|
||||
{value: 'In Bearbeitung', text: 'In Bearbeitung'},
|
||||
{value: 'Business In Bearbeitung', text: 'Business In Bearbeitung'},
|
||||
{value: 'Business Angebot gelegt', text: 'Business Angebot gelegt'},]
|
||||
},
|
||||
{text: 'Name', key: 'first_name', filter: 'search', sortable: false},
|
||||
{text: 'Email', key: 'email', filter: 'search', sortable: false},
|
||||
{text: 'Phone', key: 'phone', filter: 'search', sortable: false},],
|
||||
defaultPageSize: 25,
|
||||
tableHeader: 'Historische Tickets',
|
||||
key: 'HistoricTicket',
|
||||
}
|
||||
}
|
||||
}, methods: {
|
||||
async doGlobalSearch() {
|
||||
if (this.globalSearch.length > 0) {
|
||||
this.globalSearchModal = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.globalSearchModal.focus();
|
||||
});
|
||||
}
|
||||
}, async clickTicketNumber(ticketNumber) {
|
||||
this.globalSearchModal = false;
|
||||
this.selectedTicketData = null;
|
||||
|
||||
this.selectedTicketNumber = ticketNumber;
|
||||
const response = await axios.post(`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=getHistoricTicketMessages`,
|
||||
{ticketNumber});
|
||||
this.selectedTicketData = response.data;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.selectedTicketModal.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
178
public/js/pages/IpNetwork/IpNetwork.js
Normal file
178
public/js/pages/IpNetwork/IpNetwork.js
Normal file
@@ -0,0 +1,178 @@
|
||||
Vue.component('IpNetwork', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']"
|
||||
:path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['IPNETWORK_API_URL'] + '?do=get'"
|
||||
:config="IpNetworkTableConfig"
|
||||
@row-click="(row) => row.cidr !== '32' && switchCurrentNetwork(row.id)"
|
||||
@reset-table="switchCurrentNetwork"
|
||||
small ssr disable-initial-fetch ref="table">
|
||||
|
||||
<template v-slot:top-buttons>
|
||||
|
||||
<button type="button" class="btn btn-primary"
|
||||
@click="switchCurrentNetwork(currentNetworkData.parent_network_id)"
|
||||
:disabled="!currentNetworkData">
|
||||
<i class="fas fa-sync-alt"></i>Go Back
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" @click="addModal = true">
|
||||
<i class="fas fa-sync-alt"></i>Add new Network Space
|
||||
</button>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- add $slots.expandedRow to the table component and display discription -->
|
||||
<template v-slot:expandedRow="{row}">
|
||||
<span style="white-space: pre;" v-if="row.description" v-text="row.description"></span>
|
||||
<span v-else>No description</span>
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
<!-- add modal -->
|
||||
<div class="modal show d-block" tabindex="-1" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
|
||||
ref="addModal" @click="addModal = false" @keydown.esc="addModal = false" v-if="addModal === true">
|
||||
<div class="modal-dialog" role="document" @click.stop>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit DNS Record</h5>
|
||||
<button type="button" class="close" @click="addModal = false">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="wrapper" style="display:grid; grid-template-columns: 3fr 1fr 2fr; grid-gap: 12px">
|
||||
<div class="form-group">
|
||||
<label for="network_address">Network Address</label>
|
||||
<input type="text" class="form-control" id="network_address"
|
||||
v-model="addModalData.network_address">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cidr">CIDR</label>
|
||||
<input type="text" class="form-control" id="cidr" v-model="addModalData.cidr">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select class="form-control" id="status" v-model="addModalData.status">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="reserved">Reserved</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: span 2">
|
||||
<label for="name_location">Name</label>
|
||||
<input type="text" class="form-control" id="name_location"
|
||||
v-model="addModalData.name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name_location">Location</label>
|
||||
<input type="text" class="form-control" id="name_location"
|
||||
v-model="addModalData.location">
|
||||
</div>
|
||||
<div class="form-group" style="grid-column: span 3">
|
||||
<label for="description">Description</label>
|
||||
<input type="text" class="form-control" id="description"
|
||||
v-model="addModalData.description">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @click="addSubmit">Save</button>
|
||||
<button class="btn btn-secondary" @click="addModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
apiUrl: window['TT_CONFIG']['IPNETWORK_API_URL'],
|
||||
IpNetworkTableConfig: {
|
||||
defaultPageSize: 50,
|
||||
customRowClass: function (row) {
|
||||
return row.cidr !== '32' ? 'tt-pointer' : '';
|
||||
},
|
||||
expandCondition: function (row) {
|
||||
return !!row.description;
|
||||
},
|
||||
headers: [
|
||||
{text: 'Network Address', key: 'network_address_str'},
|
||||
{text: 'Name', key: 'name'},
|
||||
{
|
||||
text: 'Status', key: 'status', filter: 'iconSelect',
|
||||
filterOptions: [{value: 'active', text: 'Active', icon: 'fas fa-check text-success'},
|
||||
{value: 'inactive', text: 'Inactive', icon: 'fas fa-times text-danger'},
|
||||
{value: 'reserved', text: 'Reserved', icon: 'fas fa-lock text-warning'}]
|
||||
},
|
||||
{text: 'Children', key: 'children', filter: 'numberRange'},
|
||||
],
|
||||
tableHeader: 'IPAM',
|
||||
key: 'IpNetwork'
|
||||
},
|
||||
currentNetworkData: null,
|
||||
addModal: false,
|
||||
addModalData: {
|
||||
network_address: '',
|
||||
cidr: '',
|
||||
parent_network_id: '',
|
||||
status: 'active',
|
||||
name: '',
|
||||
description: '',
|
||||
location: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
function popstateFunction() {
|
||||
const parentNetworkId = new URLSearchParams(window.location.search).get('parent_network_id');
|
||||
this.switchCurrentNetwork(parentNetworkId).then();
|
||||
}
|
||||
|
||||
window.onpopstate = popstateFunction.bind(this);
|
||||
window.onpopstate.call(this)
|
||||
|
||||
},
|
||||
methods: {
|
||||
async switchCurrentNetwork(networkId = null) {
|
||||
if (!networkId) {
|
||||
this.$refs.table.$set(this.$refs.table.filters, 'parent_network_id', undefined);
|
||||
this.currentNetworkData = null;
|
||||
this.IpNetworkTableConfig.tableHeader = 'IPAM';
|
||||
this.$refs.table.disableDebounce = true;
|
||||
window.history.pushState({}, '', `?`);
|
||||
} else {
|
||||
this.$refs.table.disableDebounce = true;
|
||||
this.$refs.table.$set(this.$refs.table.filters, 'parent_network_id', networkId);
|
||||
window.history.pushState({}, '', `?parent_network_id=${networkId}`);
|
||||
|
||||
const response = await axios.post(`${this.apiUrl}?do=getById`, {id: networkId});
|
||||
this.currentNetworkData = response.data.network;
|
||||
this.IpNetworkTableConfig.tableHeader = `IPAM - ${this.currentNetworkData.network_address_str}/${this.currentNetworkData.cidr} - ${this.currentNetworkData.name}`;
|
||||
}
|
||||
await this.$refs.table.fetchData();
|
||||
},
|
||||
async addSubmit() {
|
||||
const response = await axios.post(`${this.apiUrl}?do=create`,
|
||||
{
|
||||
...this.addModalData,
|
||||
parent_network_id: this.currentNetworkData ? this.currentNetworkData.id : null
|
||||
});
|
||||
if (response.data.status === 'success') {
|
||||
this.addModal = false;
|
||||
window.notify('success', 'Network space created successfully');
|
||||
await this.$refs.table.fetchData();
|
||||
} else {
|
||||
window.notify('error', response.data.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
130
public/js/pages/RaspberryDisplay/RaspberryDisplay.js
Normal file
130
public/js/pages/RaspberryDisplay/RaspberryDisplay.js
Normal file
@@ -0,0 +1,130 @@
|
||||
Vue.filter('cleanupURL', function (value) {
|
||||
value = value.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "").split('/')[0];
|
||||
return value;
|
||||
})
|
||||
|
||||
Vue.component('RaspberryDisplay', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<div class="card">
|
||||
<tt-loader v-if="loading"></tt-loader>
|
||||
|
||||
<div class="p-2">
|
||||
<h3>8322 Studenzen NOC Displays</h3>
|
||||
|
||||
<div class="display-grid">
|
||||
<div v-for="display in displays" :key="display.id"
|
||||
:class="['display', display['display_label'].includes('-B-') ? 'big-42-inch' : 'small-27-inch']"
|
||||
:style="display['custom_style']" style="">
|
||||
<div
|
||||
style="display: grid; grid-template-columns: max-content auto max-content; justify-items: center;width:100%; padding: 0 2px">
|
||||
<div>
|
||||
<!-- FONT AWESOME ONLINE GREEN CIRCLE -->
|
||||
<i class="fas fa-circle" data-toggle="tooltip" title="ONLINE" style="color: green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div @click.prevent="enableDisplayURLEditMode(display.id)" style="cursor: pointer">
|
||||
<span v-if="displaysURLEditMode !== display.id">{{ display['display_url'] | cleanupURL }}</span>
|
||||
<input v-else-if="displaysURLEditMode === display.id"
|
||||
v-model="display['display_url']"
|
||||
@keyup.enter="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
@blur="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
ref="displayURLEditInput"
|
||||
class="form-control"
|
||||
type="text">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="cursor: pointer">
|
||||
<!-- FONT AWESOME REBOOT ICON -->
|
||||
<i class="fas fa-red fa-sync-alt" data-toggle="tooltip" title="Reboot this Raspberry"
|
||||
@click="rebootRaspberry(display.id)"
|
||||
style="color: green"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Checkbox for Auto Refresh Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="\`Auto refresh is \${display['auto_refresh_enabled'] ? 'enabled' : 'disabled'}.\`">
|
||||
<input type="checkbox" :id="'auto_refresh_enabled_checkbox_' + display.id"
|
||||
v-model="display['auto_refresh_enabled']"
|
||||
@change="submitChanges(display.id, 'auto_refresh_enabled', display['auto_refresh_enabled'])">
|
||||
<label :for="'auto_refresh_enabled_checkbox_' + display.id">ARF</label>
|
||||
</div>
|
||||
|
||||
<!-- This will only display if both are true, consider adjusting logic as needed -->
|
||||
<span style="margin: 0 4px"> | </span>
|
||||
|
||||
<!-- Checkbox for Margin Hotfix Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="\`Margin Hotfix is \${display['margin_hot_fix_enabled'] ? 'enabled' : 'disabled'}.\`">
|
||||
|
||||
<input type="checkbox" :id="'margin_hot_fix_enabled_checkbox_' + display.id"
|
||||
v-model="display['margin_hot_fix_enabled']"
|
||||
@change="submitChanges(display.id, 'margin_hot_fix_enabled', display['margin_hot_fix_enabled'])">
|
||||
<label :for="'margin_hot_fix_enabled_checkbox_' + display.id">MHF</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-text="display['display_label']"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false, displaysURLEditMode: null, displays: null, window: window
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchDisplays().then()
|
||||
}, methods: {
|
||||
async rebootRaspberry(displayID) {
|
||||
this.loading = true;
|
||||
await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=reboot`, {
|
||||
params: {
|
||||
displayID: displayID
|
||||
}
|
||||
});
|
||||
this.loading = false;
|
||||
}, async fetchDisplays() {
|
||||
this.loading = true;
|
||||
const response = await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=getDisplays`);
|
||||
this.displays = response.data.result;
|
||||
this.loading = false;
|
||||
Vue.nextTick(() => {
|
||||
$('[data-toggle="tooltip"]').tooltip('dispose');
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
}, enableDisplayURLEditMode(displayID) {
|
||||
this.displaysURLEditMode = displayID;
|
||||
const _this = this;
|
||||
// wait for the DOM to update
|
||||
Vue.nextTick(() => {
|
||||
_this.$refs['displayURLEditInput'][0].focus();
|
||||
});
|
||||
}, disableDisplayURLEditMode(displayID, displayURL) {
|
||||
this.displaysURLEditMode = null;
|
||||
this.submitChanges(displayID, 'display_url', displayURL);
|
||||
}, async submitChanges(displayID, field, value) {
|
||||
this.loading = true;
|
||||
await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=change`, {
|
||||
params: {
|
||||
displayID: displayID, field: field, value: value,
|
||||
}
|
||||
});
|
||||
await this.fetchDisplays();
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
})
|
||||
112
public/js/pages/VoiceCallActive/VoiceCallActive.js
Normal file
112
public/js/pages/VoiceCallActive/VoiceCallActive.js
Normal file
@@ -0,0 +1,112 @@
|
||||
Vue.component('VoiceCallActive', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['VOICE_CALL_ACTIVE_API_URL'] + '?do=getActiveCalls'"
|
||||
:config="VoiceCallActiveTableConfig"
|
||||
small ref="table">
|
||||
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="refresh" data-toggle="tooltip" data-placement="bottom"
|
||||
title="Refreshing too often will run into API-Rate limits and will cause errors.">
|
||||
<template v-if="refreshLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<div class="d-flex">
|
||||
<label style="margin-bottom: 0 !important;">
|
||||
<input type="checkbox" id="autoRefresh" data-toggle="toggle" data-size="lg">
|
||||
<span class="ml-2">Auto Refresh (5sec)</span>
|
||||
</label>
|
||||
<span style="width: 50px"></span>
|
||||
<div class="voice-yellow p-2">Ringing</div>
|
||||
<div class="voice-red p-2">Outgoing</div>
|
||||
<div class="voice-green p-2">Ingoing</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:answer_time="{ row }">
|
||||
{{ !isNaN(new Date(row.answer_time)) ? window.moment(row.answer_time, 'YYYY-MM-DD HH:mm:ss Z').format('DD.MM.YYYY HH:mm:ss') : 'Call is not running' }}
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<i v-if="!row.dst_device_extension && row.status !== 'Ringing'" class="fas fa-phone-arrow-up-right"></i>
|
||||
<i v-else-if="!row.dst_device_extension && row.status === 'Ringing'"
|
||||
class="fas fa-phone-arrow-up-right fa-shake"></i>
|
||||
<i v-else-if="row.status === 'Ringing'" class="fas fa-phone-arrow-down-left fa-shake"></i>
|
||||
<i v-else class="fas fa-phone-arrow-down-left"></i>
|
||||
{{ row.status }}
|
||||
</template>
|
||||
|
||||
</tt-table>
|
||||
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
VoiceCallActiveTableConfig: {
|
||||
customRowClass: function (row) {
|
||||
if (row.status.toLowerCase() === 'ringing') {
|
||||
return 'voice-yellow';
|
||||
}
|
||||
if (!row.dst_device_extension) {
|
||||
return 'voice-red';
|
||||
}
|
||||
if (row.status.toLowerCase() === 'answered') {
|
||||
return 'voice-green';
|
||||
}
|
||||
},
|
||||
headers: [
|
||||
{text: 'Call ID', key: 'id', filter: false, sortable: false},
|
||||
{text: 'Status', key: 'status', filter: false, sortable: false},
|
||||
{text: 'Answer Time', key: 'answer_time', filter: false, sortable: false},
|
||||
{text: 'Duration', key: 'duration', filter: false, sortable: false},
|
||||
{text: 'Source', key: 'src', filter: false, sortable: false},
|
||||
{text: 'Device Type', key: 'device_type', filter: false, sortable: false},
|
||||
{text: 'Destination', key: 'localized_dst', filter: false, sortable: false},
|
||||
{text: 'Destination User', key: 'dst_user', filter: false, sortable: false},
|
||||
{text: 'Destination Device Extension', key: 'dst_device_extension', filter: false, sortable: false},
|
||||
],
|
||||
tableHeader: 'Active Voice Calls',
|
||||
key: 'VoiceCallActive',
|
||||
},
|
||||
refreshLoading: false,
|
||||
autoRefresh: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//TODO: create vue tooltip component
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
const _this = this;
|
||||
$('#autoRefresh').change(function () {
|
||||
console.log(this.checked);
|
||||
if (this.checked) {
|
||||
_this.autoRefresh = setInterval(function () {
|
||||
_this.refresh();
|
||||
}, 5000);
|
||||
} else {
|
||||
clearInterval(_this.autoRefresh);
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
this.refreshLoading = true;
|
||||
this.$refs.table.loading = true;
|
||||
await this.$refs.table.fetchData();
|
||||
$('.tooltip').tooltip('hide');
|
||||
this.$refs.table.loading = false;
|
||||
this.refreshLoading = false;
|
||||
},
|
||||
}
|
||||
|
||||
})
|
||||
56
public/js/pages/VoiceCallHistory/VoiceCallHistory.js
Normal file
56
public/js/pages/VoiceCallHistory/VoiceCallHistory.js
Normal file
@@ -0,0 +1,56 @@
|
||||
Vue.component('VoiceCallHistory', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
|
||||
|
||||
<tt-table :fetch-url="window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=getCalls'"
|
||||
:config="VoiceCallHistoryTableConfig"
|
||||
small ssr ref="table">
|
||||
<template v-slot:top-buttons>
|
||||
<button type="button" class="btn btn-primary" @click="importCallsFromToday">
|
||||
<template v-if="importCallsFromTodayLoading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Re-Import Calls from Today
|
||||
</template>
|
||||
</button>
|
||||
|
||||
</template>
|
||||
</tt-table>
|
||||
</div>
|
||||
`, data() {
|
||||
return {
|
||||
window: window,
|
||||
VoiceCallHistoryTableConfig: {
|
||||
headers: [{text: "Call-ID", key: "uid"},
|
||||
{text: "Voice Account", key: "voice_account"},
|
||||
{text: "Time Range", key: "start", filter: "date"},
|
||||
{text: "Source", key: "source"},
|
||||
{text: "Destination", key: "destination"},
|
||||
{
|
||||
text: "Billable",
|
||||
key: "billable",
|
||||
filter: "iconSelect",
|
||||
filterOptions: [{value: 1, text: 'Yes', icon: 'fas fa-check text-success'},
|
||||
{value: 0, text: 'No', icon: 'fas fa-times text-danger'}]
|
||||
},
|
||||
{text: "Duration", key: "duration", filter: "numberRange"},],
|
||||
tableHeader: 'Voice Call History',
|
||||
key: 'VoiceCallHistory',
|
||||
},
|
||||
importCallsFromTodayLoading: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async importCallsFromToday() {
|
||||
this.importCallsFromTodayLoading = true;
|
||||
const response = await axios.get(window['TT_CONFIG']['VOICE_CALL_HISTORY_API_URL'] + '?do=importCallsFromToday');
|
||||
window.notify(response.data.status === 'success' ? 'success' : 'error', response.data.message);
|
||||
await this.$refs.table.fetchData();
|
||||
this.importCallsFromTodayLoading = false;
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
// noinspection JSJQueryEfficiency
|
||||
|
||||
Vue.filter('cleanupURL', function (value) {
|
||||
value = value.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "").split('/')[0];
|
||||
return value;
|
||||
})
|
||||
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mounted() {
|
||||
this.fetchDisplays()
|
||||
},
|
||||
methods: {
|
||||
async rebootRaspberry(displayID) {
|
||||
this.loading = true;
|
||||
await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=reboot`, {
|
||||
params: {
|
||||
displayID: displayID
|
||||
}
|
||||
});
|
||||
this.loading = false;
|
||||
},
|
||||
async fetchDisplays() {
|
||||
this.loading = true;
|
||||
const response = await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=getDisplays`);
|
||||
this.displays = response.data.result;
|
||||
this.loading = false;
|
||||
Vue.nextTick(() => {
|
||||
$('[data-toggle="tooltip"]').tooltip('dispose');
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
},
|
||||
enableDisplayURLEditMode(displayID) {
|
||||
this.displaysURLEditMode = displayID;
|
||||
const _this = this;
|
||||
// wait for the DOM to update
|
||||
Vue.nextTick(() => {
|
||||
_this.$refs['displayURLEditInput'][0].focus();
|
||||
});
|
||||
},
|
||||
disableDisplayURLEditMode(displayID, displayURL) {
|
||||
this.displaysURLEditMode = null;
|
||||
this.submitChanges(displayID, 'display_url', displayURL);
|
||||
},
|
||||
async submitChanges(displayID, field, value) {
|
||||
this.loading = true;
|
||||
await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=change`, {
|
||||
params: {
|
||||
displayID: displayID,
|
||||
field: field,
|
||||
value: value,
|
||||
}
|
||||
});
|
||||
await this.fetchDisplays();
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
data: {
|
||||
loading: false,
|
||||
displaysURLEditMode: null,
|
||||
displays: null,
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,11 @@
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
|
||||
.tt-table-card {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tt-table-card .page-link {
|
||||
padding: 5px .75rem !important;
|
||||
}
|
||||
@@ -27,3 +32,44 @@
|
||||
.tt-table {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tt-table-pagination-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
padding-bottom: 8px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
.tt-table-pagination-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-columns: auto auto;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 4px;
|
||||
justify-content: end;
|
||||
}
|
||||
.tt-table-pagination {
|
||||
margin: 0;
|
||||
}
|
||||
.tt-table-page-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt-table-page-item.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tt-table-page-item.active {
|
||||
font-weight: bold;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.tt-table-select {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
.tt-table-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.tt-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -66,6 +66,7 @@ Vue.component('tt-date-picker', {
|
||||
});
|
||||
|
||||
function checkIfAppliedElseClear() {
|
||||
if (this.value && this.value.from && this.value.to) return;
|
||||
$(_this.$refs.input).val('');
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ Vue.component('tt-date-picker', {
|
||||
}
|
||||
|
||||
$(this.$refs.input).on('cancel.daterangepicker', clearIfCancelled);
|
||||
$(this.$refs.input).on('hide.daterangepicker', checkIfAppliedElseClear);
|
||||
$(this.$refs.input).on('hide.daterangepicker', checkIfAppliedElseClear.bind(this));
|
||||
|
||||
// if value from or to is undefined then clear the input field
|
||||
if (!this.value || this.value.from === null || this.value.to === null) {
|
||||
@@ -84,11 +85,15 @@ Vue.component('tt-date-picker', {
|
||||
|
||||
},
|
||||
watch: {
|
||||
value: function (newVal, oldVal) {
|
||||
if (this.isInitialized) {
|
||||
value: function (newVal) {
|
||||
if (!this.isInitialized) return;
|
||||
|
||||
const datePicker = $(this.$refs.input).data('daterangepicker');
|
||||
if (!newVal || newVal.from === null || newVal.to === null) {
|
||||
$(this.$refs.input).val('');
|
||||
}
|
||||
} else {
|
||||
datePicker.setStartDate(this.moment.unix(newVal.from));
|
||||
datePicker.setEndDate(this.moment.unix(newVal.to));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,11 @@ Vue.component('tt-icon-select', {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
this.observer.disconnect();
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.selectedOption = this.options.find(option => option.value.toString() === val) || null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectOption(option) {
|
||||
this.selectedOption = option;
|
||||
@@ -38,16 +43,16 @@ Vue.component('tt-icon-select', {
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="form-group tt-select" style="user-select: none;margin-bottom: 0">
|
||||
<div class="form-group tt-select" style="user-select: none;margin-bottom: 0; margin-top: 6px">
|
||||
<div class="dropdown" :class="{'show': isOpen}">
|
||||
<i v-if="selectedOption !== null" :class="selectedOption.icon" style="font-size: 24px; cursor: pointer" ref="selectedIcon"></i>
|
||||
<i v-if="selectedOption !== null" :class="selectedOption.icon" style="font-size: 18px; cursor: pointer" ref="selectedIcon"></i>
|
||||
<span v-else style="cursor: pointer" ref="selectedIcon">Alle<i class="fas fa-caret-down"></i></span>
|
||||
<div style="display: grid; justify-items: center;" ref="select">
|
||||
<div class="dropdown-menu" :class="{'show': isOpen}" style="min-width: unset !important;">
|
||||
<a class="dropdown-item text-center" href="#" @click.prevent="selectOption(null)">Alle</a>
|
||||
<a v-for="option in options" class="dropdown-item text-center" href="#"
|
||||
@click.prevent="selectOption(option)">
|
||||
<i :class="option.icon" style="font-size: 24px"></i>
|
||||
<i :class="option.icon" style="font-size: 18px" :title="option.text"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,11 @@ Vue.component('tt-input', {
|
||||
inputValue: this.value,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.inputValue = val;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="form-group">
|
||||
<slot name="prepend"></slot>
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
Vue.component('tt-number-range', {
|
||||
props: {
|
||||
valueFrom: {type: Number, default: 0},
|
||||
valueTo: {type: Number, default: 0},
|
||||
returnText: {type: Boolean, default: false}
|
||||
returnText: {type: Boolean, default: false},
|
||||
value: [String, Object],
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputValueFrom: this.valueFrom || '', inputValueTo: this.valueTo || '',
|
||||
inputValueFrom: this.value?.from || '',
|
||||
inputValueTo: this.value?.to || '',
|
||||
};
|
||||
}, watch: {
|
||||
valueFrom(newValue) {
|
||||
this.inputValueFrom = newValue;
|
||||
}, valueTo(newValue) {
|
||||
this.inputValueTo = newValue;
|
||||
value(val) {
|
||||
if (this.returnText !== true) {
|
||||
this.inputValueFrom = val.from;
|
||||
this.inputValueTo = val.to;
|
||||
} else {
|
||||
if (val.includes('<')) {
|
||||
this.inputValueFrom = '';
|
||||
this.inputValueTo = val.replace('<', '');
|
||||
} else if (val.includes('>')) {
|
||||
this.inputValueFrom = val.replace('>', '');
|
||||
this.inputValueTo = '';
|
||||
} else {
|
||||
this.inputValueFrom = val.split('-')[0];
|
||||
this.inputValueTo = val.split('-')[1];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, methods: {
|
||||
updateValue() {
|
||||
if (this.returnText !== true) {
|
||||
this.$emit('input', {target: {value: {from: this.inputValueFrom, to: this.inputValueTo}}});
|
||||
this.$emit('input', {from: this.inputValueFrom || undefined, to: this.inputValueTo || undefined});
|
||||
} else if (this.returnText === true) {
|
||||
if (this.inputValueFrom === '' && this.inputValueTo === '') {
|
||||
if (!this.inputValueFrom && !this.inputValueTo) {
|
||||
this.$emit('input', '');
|
||||
} else if (this.inputValueFrom === '') {
|
||||
} else if (!this.inputValueFrom) {
|
||||
this.$emit('input', '<' + this.inputValueTo);
|
||||
} else if (this.inputValueTo === '') {
|
||||
} else if (!this.inputValueTo) {
|
||||
this.$emit('input', '>' + this.inputValueFrom);
|
||||
} else {
|
||||
this.$emit('input', this.inputValueFrom + '-' + this.inputValueTo);
|
||||
@@ -31,13 +44,14 @@ Vue.component('tt-number-range', {
|
||||
}
|
||||
}
|
||||
}, template: `
|
||||
<div style="display:grid;grid-template-columns: 1fr 1fr;grid-gap: 4px;">
|
||||
<div style="display:grid;grid-template-columns: 75px 25px 75px;grid-gap: 4px;justify-content: center">
|
||||
<slot name="prepend"></slot>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
v-model.number="inputValueFrom"
|
||||
@input="updateValue"
|
||||
>
|
||||
<div style="align-self: center;padding: 0 8px"><i class="fa-solid fa-sort" style="transform: rotate(90deg);"></i></div>
|
||||
<input type="number"
|
||||
class="form-control form-control-sm"
|
||||
v-model.number="inputValueTo"
|
||||
|
||||
@@ -2,7 +2,7 @@ Vue.component('tt-select', {
|
||||
props: ['options', 'label', 'required', 'value', 'suffix'],
|
||||
data() {
|
||||
return {
|
||||
selectedOption: '',
|
||||
selectedOption: undefined,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -17,7 +17,7 @@ Vue.component('tt-select', {
|
||||
<div class="form-group">
|
||||
<label v-if="label" :for="label">{{ label }}</label>
|
||||
<select class="form-control form-control-sm" :required="required" v-model="selectedOption"
|
||||
@change="$emit('input', $event.target.value)">
|
||||
@change="$emit('input', $event.target.value ? $event.target.value : undefined)">
|
||||
<template v-for="option of options">
|
||||
<option v-if="['string','number'].includes(typeof option)" :value="option">{{ option }}
|
||||
<template v-if="suffix"> {{ suffix }}</template>
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//TODO: tt-autocomplete , tt-select aswell as tt-input should be used for filtering
|
||||
//TODO: Add sorting functionality
|
||||
//TODO: Add export to excel and pdf functionality
|
||||
//TODO: Add Date-Range filter
|
||||
//TODO: Add Exact Date filter
|
||||
//TODO: Add new prop serverSide to disable pagination and filtering on the client side
|
||||
//TODO: Add filtering function if serverSide is disabled
|
||||
//TODO: Add JSDoc for various functions and props
|
||||
//TODO: Fixed Table Header
|
||||
|
||||
/**
|
||||
* @typedef {Object} ttTableColumnConfig
|
||||
* @property {string} text - The display text of the column.
|
||||
@@ -17,30 +7,112 @@
|
||||
* @property {string} class - The CSS class(es) applied to the column.
|
||||
*/
|
||||
|
||||
Vue.component('tt-table-pagination', {
|
||||
props: {
|
||||
pagination: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({page: 1, per_page: 10, total_rows: 0, filtered_available: 0, total_pages: 1})
|
||||
},
|
||||
reverse: {type: Boolean, default: false}
|
||||
},
|
||||
computed: {
|
||||
pagesToDisplay() {
|
||||
const range = 2;
|
||||
const start = Math.max(this.pagination.page - range, 1);
|
||||
const end = Math.min(this.pagination.page + range, this.pagination.total_pages);
|
||||
let pages = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages.length === 0 ? [1] : pages;
|
||||
},
|
||||
pageInfoText() {
|
||||
const start = Math.min(this.pagination.page * this.pagination.per_page - this.pagination.per_page + 1, this.pagination.total_rows);
|
||||
const end = Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total_rows);
|
||||
const total = this.pagination.total_rows === this.pagination.filtered_available
|
||||
? this.pagination.total_rows
|
||||
: `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
|
||||
return `${start} bis ${end} von ${total}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchRows(page) {
|
||||
this.$emit('fetch-rows', page);
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="tt-table-pagination-container">
|
||||
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
|
||||
<span class="tt-table-text-center" v-text="pageInfoText"
|
||||
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 1 }"></span>
|
||||
|
||||
|
||||
<ul class="pagination tt-table-pagination" :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
|
||||
<li class="page-item tt-table-page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">First</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item" v-for="pageNumber in pagesToDisplay"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }">
|
||||
<a class="page-link"
|
||||
v-bind:class="{ 'active': pageNumber === pagination.page, 'disabled': pageNumber === pagination.page }"
|
||||
href="#"
|
||||
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
|
||||
</li>
|
||||
<li class="page-item tt-table-page-item"
|
||||
v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<span class="tt-table-text-center" :style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 2 }">Einträge pro Seite</span>
|
||||
|
||||
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)"
|
||||
class="form-control form-control-sm tt-table-select"
|
||||
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 2 }">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
|
||||
Vue.component('tt-table', {
|
||||
template: `
|
||||
<div class="card tt-table-card">
|
||||
<div class="card tt-table-card" v-if="columns && pagination">
|
||||
<div class="card-body">
|
||||
<!-- Top Buttons -->
|
||||
<div
|
||||
style="display:grid; grid-template-columns: auto auto auto auto auto; grid-gap: 8px; padding-bottom: 8px">
|
||||
<slot name="top-buttons"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Page navigation">
|
||||
<div
|
||||
style="display:grid; grid-template-columns: 1fr 1fr;padding-bottom: 8px;align-items:center; justify-content: space-between">
|
||||
<!-- if excelExport is true, show the export button fontawesome icon excel -->
|
||||
<div style="display:flex;align-items: center;">
|
||||
|
||||
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen" @click="filters = {}; window.notify('success','Filter zurückgesetzt')" class="fa-solid fa-trash-undo" style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i>
|
||||
|
||||
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen"
|
||||
@click="resetTable" class="fa-solid fa-trash-undo"
|
||||
style="font-size: 24px;margin-right: 8px;cursor: pointer; color: var(--orange)"></i>
|
||||
<h4 style="margin: 0">{{ config.tableHeader }}</h4>
|
||||
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel" style="font-size: 24px;margin-left: 8px;cursor: pointer; color: var(--success)"></i>
|
||||
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel"
|
||||
style="font-size: 24px;margin-left: 8px;cursor: pointer; color: var(--success)"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="pagination && pagination.total_rows > 0"
|
||||
<div v-if="pagination && typeof pagination.total_rows === 'number'"
|
||||
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
|
||||
<ul class="pagination" style="margin: 0">
|
||||
<li class="page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
@@ -63,8 +135,8 @@ Vue.component('tt-table', {
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-center"
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.filtered_available)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.filtered_available) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
@@ -74,7 +146,6 @@ Vue.component('tt-table', {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Table -->
|
||||
<table
|
||||
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]">
|
||||
@@ -82,96 +153,69 @@ Vue.component('tt-table', {
|
||||
<tr>
|
||||
<th scope="col" v-for="column in columns"
|
||||
:style="'vertical-align: top; text-align: center;' + (column.filter === 'dateRange' ? 'min-width: 260px;' : '')">
|
||||
|
||||
<div style="text-align:center; white-space: nowrap;word-break: keep-all;" :style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
|
||||
<div style="text-align:center; white-space: nowrap;word-break: keep-all;"
|
||||
:style="{ 'cursor': column.sortable ? 'pointer' : 'default' }"
|
||||
@click="column.sortable ? setOrder(column.key) : undefined">
|
||||
{{ column.text }}
|
||||
<i
|
||||
v-if="column.sortable"
|
||||
:class="getSortIconClass(column.key)"></i>
|
||||
</div>
|
||||
|
||||
<tt-input v-if="column.filter === 'search'" sm v-model="filters[column.key]"></tt-input>
|
||||
<tt-icon-select v-else-if="column.filter === 'iconSelect'" :options="column.filterOptions"
|
||||
v-model="filters[column.key]"></tt-icon-select>
|
||||
<tt-number-range v-else-if="column.filter === 'numberRange'" returnText
|
||||
<tt-number-range v-else-if="column.filter === 'numberRange'" :returnText="!ssr"
|
||||
v-model="filters[column.key]"></tt-number-range>
|
||||
<tt-select v-else-if="column.filter === 'select'"
|
||||
:options="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
|
||||
v-model="filters[column.key]"></tt-select>
|
||||
<tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"></tt-date-picker>
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr v-if="pagination?.total_rows === 0" style="height: 150px">
|
||||
<tr v-if="pagination?.filtered_available === 0" style="height: 150px">
|
||||
<td :colspan="Object.keys(columns).length" :rowspan="5" class="text-center">Keine Ergebnisse!</td>
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="(pagination === null && ssr === true) || rows === null"
|
||||
style="height: 150px">
|
||||
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
|
||||
</tr>
|
||||
|
||||
<tr v-for="row in (ssr === false ? computedRows : rows)"
|
||||
:class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''">
|
||||
<template v-for="(row) in (ssr === false ? computedRows : rows)">
|
||||
<tr :class="typeof config.customRowClass === 'function' ? config.customRowClass(row) : ''"
|
||||
@click="$emit('row-click', row)"
|
||||
>
|
||||
<template v-for="(column, key) in columns">
|
||||
<td :class="{ 'text-center': column.filter === 'iconSelect', [columns[key].class]: true }">
|
||||
<!-- If td is first of row then check isExpanded and display fas.fa-chevron-right or fas.fa-chevron-down with cursor pointer -->
|
||||
<i v-if="key === Object.keys(columns)[0] && $scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row))"
|
||||
@click.stop="toggleExpand(row.id)"
|
||||
:class="isExpanded(row.id) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"
|
||||
style="cursor: pointer;font-size: 14px;padding-right: 8px;user-select: none"></i>
|
||||
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
|
||||
|
||||
<span v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
|
||||
<i v-else-if="column.filter === 'iconSelect'" :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<span v-else v-html="row[key] === null || typeof row[key] === 'undefined' ? null : row[key]?.toString()?.replace('\\n', '<br>')"></span>
|
||||
|
||||
<span
|
||||
v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
|
||||
<i v-else-if="column.filter === 'iconSelect'"
|
||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<span v-else
|
||||
v-html="row[key] === null || typeof row[key] === 'undefined' ? null : row[key]?.toString()?.replace('\\n', '<br>')"></span>
|
||||
</slot>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="isExpanded(row.id) && $scopedSlots.expandedRow">
|
||||
<td :colspan="Object.keys(columns).length">
|
||||
<slot name="expandedRow" :row="row"></slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Page navigation">
|
||||
<div style="display:grid; grid-template-columns: 1fr;padding-bottom: 8px;align-items:center; justify-content: end">
|
||||
|
||||
<div v-if="pagination && pagination.total_rows > 0"
|
||||
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
|
||||
|
||||
<span class="text-center"
|
||||
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
|
||||
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + (pagination.total_rows === pagination.filtered_available ? pagination.total_rows : pagination.filtered_available + ' ('+pagination.total_rows+')')"></span>
|
||||
|
||||
|
||||
<ul class="pagination" style="margin: 0">
|
||||
<li class="page-item" v-bind:class="{ disabled: pagination.page === 1 }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">First</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item" v-for="pageNumber in pagesToDisplay"
|
||||
v-bind:class="{ 'active disabled': pageNumber === pagination.page }">
|
||||
<a class="page-link" v-bind:class="{ 'active disabled': pageNumber === pagination.page }" href="#"
|
||||
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
|
||||
</li>
|
||||
<li class="page-item" v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
|
||||
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
|
||||
aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
<span class="sr-only">Last</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="text-center">Einträge pro Seite</span>
|
||||
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<tt-table-pagination :pagination="pagination" @fetch-rows="fetchRows"
|
||||
v-if="pagination"></tt-table-pagination>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +228,8 @@ Vue.component('tt-table', {
|
||||
small: {type: Boolean, default: true},
|
||||
excelExport: {type: Boolean, default: false},
|
||||
config: {type: Object, default: () => ({}), required: true},
|
||||
ssr: {type: Boolean, default: false}
|
||||
ssr: {type: Boolean, default: false},
|
||||
disableInitialFetch: {type: Boolean, default: false}
|
||||
}, data() {
|
||||
return {
|
||||
window: window,
|
||||
@@ -193,14 +238,17 @@ Vue.component('tt-table', {
|
||||
loading: false,
|
||||
rows: null,
|
||||
rawRows: null,
|
||||
pagination: null,
|
||||
pagination: {},
|
||||
filters: {},
|
||||
debounceTimeout: null,
|
||||
disableDebounce: false,
|
||||
latestFetchTimestamp: null,
|
||||
order: {
|
||||
key: null,
|
||||
order: 'asc' // default sort order
|
||||
}
|
||||
},
|
||||
expandedRows: {},
|
||||
isInitialised: false
|
||||
};
|
||||
},
|
||||
|
||||
@@ -235,7 +283,7 @@ Vue.component('tt-table', {
|
||||
this.pagination = {
|
||||
page: page++,
|
||||
per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10,
|
||||
total_rows: this.rawRows.length,
|
||||
total_rows: this.rawRows.length || 0,
|
||||
total_pages: this.rawRows.length / this.pagination?.per_page,
|
||||
filtered_available: this.rawRows.length
|
||||
};
|
||||
@@ -261,6 +309,7 @@ Vue.component('tt-table', {
|
||||
this.pagination = response.data.pagination;
|
||||
}
|
||||
this.loading = false;
|
||||
this.isInitialised = true;
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
@@ -271,6 +320,12 @@ Vue.component('tt-table', {
|
||||
* @param {boolean} debounce Whether to debounce the fetch operation. Defaults to false.
|
||||
*/
|
||||
async fetchRows(page = 1, debounce = false) {
|
||||
|
||||
if (this.disableDebounce === true) {
|
||||
debounce = false;
|
||||
this.disableDebounce = false;
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
if (debounce) {
|
||||
this.debounce(this.fetchData.bind(this), 300)(page);
|
||||
@@ -278,21 +333,39 @@ Vue.component('tt-table', {
|
||||
await this.fetchData(page); // Directly call fetchData without debounce
|
||||
}
|
||||
},
|
||||
applyFilter(event, key) {
|
||||
this.$set(this.filters, key, event.target.value); // Ensure reactivity
|
||||
},
|
||||
saveSettingsToLocalStorage() {
|
||||
if (this.isInitialised === false) return;
|
||||
|
||||
const filters = Object.entries(this.filters).reduce((acc, [key, value]) => {
|
||||
if (!value) {
|
||||
return acc; // Skip empty strings
|
||||
}
|
||||
value = JSON.parse(JSON.stringify(value)); // Deep copy to avoid Vue reactivity
|
||||
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
|
||||
return acc; // Skip empty objects
|
||||
}
|
||||
acc[key] = value; // Add non-empty properties to accumulator
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
localStorage.setItem(`tt-table-${this.config.key}`, JSON.stringify({
|
||||
filters: this.filters,
|
||||
pagination: this.pagination,
|
||||
// filter filters with empty values or empty objects
|
||||
filters,
|
||||
paginationPerPage: this.pagination.per_page,
|
||||
order: this.order.key ? this.order : undefined,
|
||||
expandedRows: this.expandedRows
|
||||
}));
|
||||
},
|
||||
parseSettingsFromLocalStorage() {
|
||||
const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`));
|
||||
const settings = JSON.parse(localStorage.getItem(`tt-table-${this.config.key}`) || '{}');
|
||||
if (settings) {
|
||||
this.filters = settings.filters;
|
||||
this.pagination = settings.pagination;
|
||||
this.disableDebounce = true;
|
||||
this.filters = settings.filters || {};
|
||||
this.pagination.per_page = parseInt(settings.paginationPerPage) || this.config.defaultPageSize || 10;
|
||||
this.order = settings.order || {key: null, order: 'asc'};
|
||||
this.expandedRows = settings.expandedRows || {};
|
||||
}
|
||||
return !!settings;
|
||||
},
|
||||
setOrder(key) {
|
||||
if (this.order.key === key) {
|
||||
@@ -316,7 +389,18 @@ Vue.component('tt-table', {
|
||||
}
|
||||
return 'fa fa-sort'; // default icon when not sorted
|
||||
},
|
||||
exportToExcel() {
|
||||
async exportToExcel() {
|
||||
|
||||
// create script and await downloading: /plugins/xlsx/xlsx.min.js
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/plugins/xlsx/xlsx.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
})
|
||||
|
||||
|
||||
const wb = this.XLSX.utils.book_new();
|
||||
|
||||
let data = typeof this.config.customExcelProcessor === 'function' ? this.config.customExcelProcessor(this.rawRows) : JSON.parse(JSON.stringify(this.rawRows));
|
||||
@@ -348,21 +432,56 @@ Vue.component('tt-table', {
|
||||
|
||||
this.XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
this.XLSX.writeFile(wb, 'export.xlsx');
|
||||
},
|
||||
resetTable() {
|
||||
this.$emit('reset-table');
|
||||
this.filters = {};
|
||||
this.order = {key: null, order: 'asc'};
|
||||
this.expandedRows = {};
|
||||
this.disableDebounce = true;
|
||||
window.notify('success', 'Filter zurückgesetzt');
|
||||
},
|
||||
toggleExpand(index) {
|
||||
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true);
|
||||
},
|
||||
isExpanded(index) {
|
||||
return !!this.expandedRows[index];
|
||||
}
|
||||
}, watch: {
|
||||
filters: {
|
||||
handler: function () {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
if (this.ssr) {
|
||||
this.fetchRows(this.pagination?.page || 1, true).then();
|
||||
}
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
pagination: {
|
||||
handler: function () {
|
||||
'pagination.per_page': {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
if (newVal === oldVal) return
|
||||
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
deep: true
|
||||
order: {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
if (this.ssr) {
|
||||
this.fetchRows(this.pagination?.page || 1, true).then();
|
||||
}
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
},
|
||||
expandedRows: {
|
||||
handler: function (newVal, oldVal) {
|
||||
if (!this.isInitialised) return;
|
||||
|
||||
this.saveSettingsToLocalStorage();
|
||||
}, deep: true
|
||||
}
|
||||
}, computed: {
|
||||
/**
|
||||
@@ -406,7 +525,7 @@ Vue.component('tt-table', {
|
||||
pagesArray.push(i);
|
||||
}
|
||||
|
||||
return pagesArray;
|
||||
return pagesArray.length === 0 ? [1] : pagesArray;
|
||||
},
|
||||
computedRows() {
|
||||
if (!this.rawRows || this.ssr === true) return null;
|
||||
@@ -430,7 +549,6 @@ Vue.component('tt-table', {
|
||||
const data = this.rawRows;
|
||||
const output = [];
|
||||
const filters = this.filters;
|
||||
console.log(filters)
|
||||
const filtersLength = Object.keys(filters).length;
|
||||
const headers = this.columns;
|
||||
const dataLength = data.length;
|
||||
@@ -539,19 +657,19 @@ Vue.component('tt-table', {
|
||||
}
|
||||
|
||||
},
|
||||
beforeMount() {
|
||||
this.parseSettingsFromLocalStorage();
|
||||
},
|
||||
mounted() {
|
||||
async created() {
|
||||
if (this.config.hasOwnProperty('defaultPageSize') && this.config.defaultPageSize) {
|
||||
this.pagination = {page: 1, per_page: this.config.defaultPageSize, total_rows: null, total_pages: 1};
|
||||
}
|
||||
|
||||
// if ssr is true then register watcher for order
|
||||
if (this.ssr) {
|
||||
this.$watch('order', () => {
|
||||
this.fetchRows(this.pagination.page, true).then();
|
||||
}, {deep: true});
|
||||
|
||||
|
||||
this.parseSettingsFromLocalStorage()
|
||||
|
||||
if (!this.disableInitialFetch) {
|
||||
this.disableDebounce = true;
|
||||
await this.fetchRows()
|
||||
this.isInitialised = true;
|
||||
}
|
||||
|
||||
// if sticky is true then add style element to style thead sticky
|
||||
@@ -560,7 +678,5 @@ Vue.component('tt-table', {
|
||||
style.innerHTML = `table thead th { position: sticky; top: 0; z-index: 1; background-color: white; }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
this.fetchRows().then();
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user