4 Commits

Author SHA1 Message Date
CPAMAP b7a952ee81 Bump version to 9.6.0 2026-04-13 13:11:10 +02:00
CPAMAP 523477ffba Add firma flag to availability and show office/home label
Server query gets the last stamp's BU_TERM in the same OUTER APPLY
as LAST_STAMP, exposing FIRMA (1 = Zeiterfassung/Büro). Three OUTER
APPLYs reduced to two by combining MAX(BU_BU) with TOP 1 ORDER BY.

Client model gains a firma boolean and the people-tile indicator
appends "· Büro" or "· Home" next to the timestamp, but only when
the user is logged in.
2026-04-13 12:55:51 +02:00
CPAMAP 74992a020f Add lastStamp to availability and show it on people tiles
Server query extended with MAX(BU_BU) per user as LAST_STAMP,
exposed via the Availability model. Client formats it as
DD.MM. HH:mm next to the loggedIn dot, replacing the mocked time.
2026-04-13 12:29:43 +02:00
CPAMAP 235b7cba18 Add availability indicators on people tiles and share fetch service
New Availability module decorates each people-tile with a vertical
  status bar on the avatar and a dot+timestamp under the status text,
  showing ZeitConsens loggedIn state per extension.

  Status and Availability now share a single AvailabilityService that
  polls /availability every 30s, halving the API load. Avatars are
  enlarged to 57px and vertically centered to fit the new layout.
2026-04-13 12:14:18 +02:00
12 changed files with 759 additions and 267 deletions
+470 -217
View File
@@ -122,6 +122,98 @@ module.exports = function (i) {
return i[1]; return i[1];
}; };
/***/ },
/***/ "./node_modules/css-loader/dist/cjs.js!./src/availability.css"
(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ A: () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./node_modules/css-loader/dist/runtime/noSourceMaps.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./node_modules/css-loader/dist/runtime/api.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__);
// Imports
var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default()));
// Module
___CSS_LOADER_EXPORT___.push([module.id, `.tapi-availability {
grid-column: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
font-size: 9px;
opacity: 0.7;
line-height: 1;
white-space: nowrap;
overflow: hidden;
margin-top: -4px;
margin-bottom: 6px;
}
.tapi-availability small {
font-size: inherit;
}
.tapi-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.tapi-dot-on {
background-color: #5cb85c;
}
.tapi-dot-off {
background-color: #d9534f;
}
.tapi-avatar-square {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 5px;
z-index: 10;
}
people-tile .avatar-size-m,
people-tile .avatar-size-m .avatar-container,
people-tile .avatar-size-m .avatar-content {
width: 57px !important;
height: 57px !important;
line-height: 57px !important;
margin-top: 10px;
}
people-tile {
grid-template-rows: 29px 23px;
}
people-tile app-avatar {
grid-row: 1 / span 2 !important;
align-self: center !important;
}
.tapi-square-on {
background-color: #5cb85c;
}
.tapi-square-off {
background-color: #d9534f;
}
`, ""]);
// Exports
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
/***/ }, /***/ },
/***/ "./node_modules/css-loader/dist/cjs.js!./src/presence.css" /***/ "./node_modules/css-loader/dist/cjs.js!./src/presence.css"
@@ -658,6 +750,344 @@ var __webpack_exports__ = {};
(() => { (() => {
"use strict"; "use strict";
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js
var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js");
var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js
var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js");
var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js
var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js");
var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js
var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js");
var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js
var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js");
var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform);
// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/availability.css
var availability = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/availability.css");
;// ./src/availability.css
var options = {};
options.styleTagTransform = (styleTagTransform_default());
options.setAttributes = (setAttributesWithoutAttributes_default());
options.insert = insertBySelector_default().bind(null, "head");
options.domAPI = (styleDomAPI_default());
options.insertStyleElement = (insertStyleElement_default());
var update = injectStylesIntoStyleTag_default()(availability/* default */.A, options);
/* harmony default export */ const src_availability = (availability/* default */.A && availability/* default */.A.locals ? availability/* default */.A.locals : undefined);
;// ./src/availability.ts
class Availability {
_service;
_availabilities = [];
constructor(service) {
this._service = service;
}
start() {
this._service.subscribe((avs) => {
this._availabilities = avs;
this.updateAllIndicators();
});
waitForKeyElements('people-tile', (element) => { this.decorateTile(element); }, false);
}
decorateTile(tile) {
if (tile.querySelector('.tapi-availability')) {
return;
}
var extEl = tile.querySelector('span[data-id="extPhone"]');
if (!extEl) {
return;
}
var extension = extEl.textContent.trim();
var indicator = document.createElement('div');
indicator.classList.add('tapi-availability');
indicator.dataset.tapiExtension = extension;
tile.appendChild(indicator);
this.updateIndicator(indicator);
var avatarContainer = tile.querySelector('app-avatar .avatar-container');
if (avatarContainer) {
var square = document.createElement('i');
square.classList.add('tapi-avatar-square');
square.dataset.tapiExtension = extension;
avatarContainer.appendChild(square);
this.updateSquare(square);
}
}
updateAllIndicators() {
var indicators = document.getElementsByClassName('tapi-availability');
for (var i of indicators) {
this.updateIndicator(i);
}
var squares = document.getElementsByClassName('tapi-avatar-square');
for (var s of squares) {
this.updateSquare(s);
}
}
updateIndicator(indicator) {
var extension = indicator.dataset.tapiExtension;
var entry = this._availabilities.find(a => a.extension === extension);
if (!entry) {
indicator.innerHTML = '';
return;
}
var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off';
var time = '';
if (entry.lastStamp) {
var d = new Date(entry.lastStamp);
var pad = (n) => n.toString().padStart(2, '0');
time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
var location = '';
if (entry.loggedIn) {
location = entry.firma ? ' · Büro' : ' · Home';
}
indicator.innerHTML = '<span class="tapi-dot ' + dotClass + '"></span><small>' + time + location + '</small>';
}
updateSquare(square) {
var extension = square.dataset.tapiExtension;
var entry = this._availabilities.find(a => a.extension === extension);
square.classList.remove('tapi-square-on', 'tapi-square-off');
if (!entry) {
square.style.display = 'none';
return;
}
square.style.display = '';
square.classList.add(entry.loggedIn ? 'tapi-square-on' : 'tapi-square-off');
}
}
;// ./src/config.ts
class _Config {
tapi_server_url = 'https://3cxtapi.cp-austria.at';
}
const Config = new _Config();
;// ./node_modules/@trim21/gm-fetch/dist/index.mjs
function parseRawHeaders(h) {
const s = h.trim();
if (!s) {
return new Headers();
}
const array = s.split("\r\n").map((value) => {
let s = value.split(":");
return [s[0].trim(), s[1].trim()];
});
return new Headers(array);
}
function parseGMResponse(req, res) {
// workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob"
const headers = parseRawHeaders(res.responseHeaders);
const body = typeof res.response === "string"
? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" })
: res.response;
return new ResImpl(body, {
statusCode: res.status,
statusText: res.statusText,
headers,
finalUrl: res.finalUrl,
redirected: res.finalUrl === req.url,
});
}
class ResImpl {
constructor(body, init) {
this.rawBody = body;
this.init = init;
this.body = body.stream();
const { headers, statusCode, statusText, finalUrl, redirected } = init;
this.headers = headers;
this.status = statusCode;
this.statusText = statusText;
this.url = finalUrl;
this.type = "basic";
this.redirected = redirected;
this._bodyUsed = false;
}
get bodyUsed() {
return this._bodyUsed;
}
get ok() {
return this.status < 300;
}
arrayBuffer() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.arrayBuffer();
}
blob() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read");
}
this._bodyUsed = true;
// `slice` will use empty string as default value, so need to pass all arguments.
return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type));
}
clone() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read");
}
return new ResImpl(this.rawBody, this.init);
}
formData() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text().then(decode);
}
async json() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'json' on 'Response': body stream already read");
}
this._bodyUsed = true;
return JSON.parse(await this.rawBody.text());
}
text() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'text' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text();
}
async bytes() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read");
}
this._bodyUsed = true;
return new Uint8Array(await this.rawBody.arrayBuffer());
}
}
function decode(body) {
const form = new FormData();
body
.trim()
.split("&")
.forEach(function (bytes) {
if (bytes) {
const split = bytes.split("=");
const name = split.shift()?.replace(/\+/g, " ");
const value = split.join("=").replace(/\+/g, " ");
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form;
}
async function GM_fetch(input, init) {
const request = new Request(input, init);
let data;
if (init?.body) {
data = await request.text();
}
return await XHR(request, init, data);
}
function XHR(request, init, data) {
return new Promise((resolve, reject) => {
if (request.signal && request.signal.aborted) {
return reject(new DOMException("Aborted", "AbortError"));
}
GM.xmlHttpRequest({
url: request.url,
method: gmXHRMethod(request.method.toUpperCase()),
headers: Object.fromEntries(new Headers(init?.headers).entries()),
data: data,
responseType: "blob",
onload(res) {
try {
resolve(parseGMResponse(request, res));
}
catch (e) {
reject(e);
}
},
onabort() {
reject(new DOMException("Aborted", "AbortError"));
},
ontimeout() {
reject(new TypeError("Network request failed, timeout"));
},
onerror(err) {
reject(new TypeError("Failed to fetch: " + err.finalUrl));
},
});
});
}
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"];
// a ts type helper to narrow type
function includes(array, element) {
return array.includes(element);
}
function gmXHRMethod(method) {
if (includes(httpMethods, method)) {
return method;
}
throw new Error(`unsupported http method ${method}`);
}
//# sourceMappingURL=index.mjs.map
;// ./src/availability-service.ts
class AvailabilityService {
_availabilities = [];
_listeners = [];
_started = false;
start() {
if (this._started) {
return;
}
this._started = true;
this.fetch();
setInterval(() => this.fetch(), 30000);
}
subscribe(listener) {
this._listeners.push(listener);
if (this._availabilities.length > 0) {
listener(this._availabilities);
}
}
get availabilities() {
return this._availabilities;
}
async fetch() {
try {
var response = await GM_fetch(Config.tapi_server_url + '/availability');
if (response.status === 200) {
this._availabilities = await response.json();
this._listeners.forEach(l => l(this._availabilities));
}
}
catch (error) {
console.log(error);
}
}
}
;// ./node_modules/chrono-node/dist/esm/types.js ;// ./node_modules/chrono-node/dist/esm/types.js
var Meridiem; var Meridiem;
(function (Meridiem) { (function (Meridiem) {
@@ -4248,178 +4678,6 @@ function fireChangeEvents(element) {
console.debug('change event dispatched for element: ' + element.id); console.debug('change event dispatched for element: ' + element.id);
} }
;// ./node_modules/@trim21/gm-fetch/dist/index.mjs
function parseRawHeaders(h) {
const s = h.trim();
if (!s) {
return new Headers();
}
const array = s.split("\r\n").map((value) => {
let s = value.split(":");
return [s[0].trim(), s[1].trim()];
});
return new Headers(array);
}
function parseGMResponse(req, res) {
// workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob"
const headers = parseRawHeaders(res.responseHeaders);
const body = typeof res.response === "string"
? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" })
: res.response;
return new ResImpl(body, {
statusCode: res.status,
statusText: res.statusText,
headers,
finalUrl: res.finalUrl,
redirected: res.finalUrl === req.url,
});
}
class ResImpl {
constructor(body, init) {
this.rawBody = body;
this.init = init;
this.body = body.stream();
const { headers, statusCode, statusText, finalUrl, redirected } = init;
this.headers = headers;
this.status = statusCode;
this.statusText = statusText;
this.url = finalUrl;
this.type = "basic";
this.redirected = redirected;
this._bodyUsed = false;
}
get bodyUsed() {
return this._bodyUsed;
}
get ok() {
return this.status < 300;
}
arrayBuffer() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.arrayBuffer();
}
blob() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read");
}
this._bodyUsed = true;
// `slice` will use empty string as default value, so need to pass all arguments.
return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type));
}
clone() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read");
}
return new ResImpl(this.rawBody, this.init);
}
formData() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text().then(decode);
}
async json() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'json' on 'Response': body stream already read");
}
this._bodyUsed = true;
return JSON.parse(await this.rawBody.text());
}
text() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'text' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text();
}
async bytes() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read");
}
this._bodyUsed = true;
return new Uint8Array(await this.rawBody.arrayBuffer());
}
}
function decode(body) {
const form = new FormData();
body
.trim()
.split("&")
.forEach(function (bytes) {
if (bytes) {
const split = bytes.split("=");
const name = split.shift()?.replace(/\+/g, " ");
const value = split.join("=").replace(/\+/g, " ");
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form;
}
async function GM_fetch(input, init) {
const request = new Request(input, init);
let data;
if (init?.body) {
data = await request.text();
}
return await XHR(request, init, data);
}
function XHR(request, init, data) {
return new Promise((resolve, reject) => {
if (request.signal && request.signal.aborted) {
return reject(new DOMException("Aborted", "AbortError"));
}
GM.xmlHttpRequest({
url: request.url,
method: gmXHRMethod(request.method.toUpperCase()),
headers: Object.fromEntries(new Headers(init?.headers).entries()),
data: data,
responseType: "blob",
onload(res) {
try {
resolve(parseGMResponse(request, res));
}
catch (e) {
reject(e);
}
},
onabort() {
reject(new DOMException("Aborted", "AbortError"));
},
ontimeout() {
reject(new TypeError("Network request failed, timeout"));
},
onerror(err) {
reject(new TypeError("Failed to fetch: " + err.finalUrl));
},
});
});
}
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"];
// a ts type helper to narrow type
function includes(array, element) {
return array.includes(element);
}
function gmXHRMethod(method) {
if (includes(httpMethods, method)) {
return method;
}
throw new Error(`unsupported http method ${method}`);
}
//# sourceMappingURL=index.mjs.map
;// ./src/config.ts
class _Config {
tapi_server_url = 'https://3cxtapi.cp-austria.at';
}
const Config = new _Config();
;// ./src/call-history.ts ;// ./src/call-history.ts
@@ -4531,24 +4789,6 @@ class CallNotification {
} }
} }
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js
var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js");
var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js
var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js");
var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js
var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js");
var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js
var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js");
var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js
var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js");
var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform);
// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/presence.css // EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/presence.css
var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/presence.css"); var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/presence.css");
;// ./src/presence.css ;// ./src/presence.css
@@ -4563,15 +4803,15 @@ var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/
var options = {}; var presence_options = {};
options.styleTagTransform = (styleTagTransform_default()); presence_options.styleTagTransform = (styleTagTransform_default());
options.setAttributes = (setAttributesWithoutAttributes_default()); presence_options.setAttributes = (setAttributesWithoutAttributes_default());
options.insert = insertBySelector_default().bind(null, "head"); presence_options.insert = insertBySelector_default().bind(null, "head");
options.domAPI = (styleDomAPI_default()); presence_options.domAPI = (styleDomAPI_default());
options.insertStyleElement = (insertStyleElement_default()); presence_options.insertStyleElement = (insertStyleElement_default());
var update = injectStylesIntoStyleTag_default()(presence/* default */.A, options); var presence_update = injectStylesIntoStyleTag_default()(presence/* default */.A, presence_options);
@@ -4864,32 +5104,44 @@ var status_update = injectStylesIntoStyleTag_default()(cjs_js_src_status/* defau
;// ./src/status.ts ;// ./src/status.ts
const zcIcon = __webpack_require__("./src/stopwatch-regular-full.svg"); const zcIcon = __webpack_require__("./src/stopwatch-regular-full.svg");
class Status { class Status {
_service;
_user; _user;
_enabled = false; _enabled = false;
_statusOn = 'menuAvailable'; _statusOn = 'menuAvailable';
_statusOff = 'menuAway'; _statusOff = 'menuAway';
_currentStatus = undefined; _currentStatus = undefined;
_subscribed = false;
constructor(service) {
this._service = service;
}
async showStatus(element) { async showStatus(element) {
this._user = await GM.getValue('tapi-zc-user', ''); this._user = await GM.getValue('tapi-zc-user', '');
this._enabled = await GM.getValue('tapi-zc-enabled', false); this._enabled = await GM.getValue('tapi-zc-enabled', false);
this._statusOn = await GM.getValue('tapi-zc-on', 'menuAvailable'); this._statusOn = await GM.getValue('tapi-zc-on', 'menuAvailable');
this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable'); this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable');
console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff); console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff);
this.checkStatus(); this.subscribeOnce();
waitForKeyElements("wc-account-menu > div > ul", (element) => { this.addZcStatusPopup(element); }, true); waitForKeyElements("wc-account-menu > div > ul", (element) => { this.addZcStatusPopup(element); }, true);
} }
async checkStatus() { subscribeOnce() {
if (this._enabled) { if (this._subscribed) {
try { return;
var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user)); }
if (response.status == 200) { this._subscribed = true;
var status = await response.json(); this._service.subscribe((avs) => this.onAvailabilities(avs));
if (this._currentStatus !== status.loggedIn) { }
this._currentStatus = status.loggedIn; onAvailabilities(avs) {
if (!this._enabled || !this._user) {
return;
}
var entry = avs.find(a => a.user === this._user);
if (!entry) {
return;
}
if (this._currentStatus !== entry.loggedIn) {
this._currentStatus = entry.loggedIn;
console.log('New status, loggedIn', this._currentStatus); console.log('New status, loggedIn', this._currentStatus);
var accMenu = document.getElementsByTagName("wc-account-menu")[0]; var accMenu = document.getElementsByTagName("wc-account-menu")[0];
var avatar = accMenu.getElementsByTagName("app-avatar")[0]; var avatar = accMenu.getElementsByTagName("app-avatar")[0];
@@ -4901,13 +5153,6 @@ class Status {
}, 1000); }, 1000);
} }
} }
}
catch (error) {
console.log(error);
}
setTimeout(() => this.checkStatus(), 30000);
}
}
addZcStatusPopup(element) { addZcStatusPopup(element) {
var divider = document.createElement('li'); var divider = document.createElement('li');
divider.classList.add('divider'); divider.classList.add('divider');
@@ -4998,7 +5243,9 @@ class Status {
GM.setValue('tapi-zc-enabled', this._enabled); GM.setValue('tapi-zc-enabled', this._enabled);
console.log('tapi-zc-enabled', this._enabled); console.log('tapi-zc-enabled', this._enabled);
this._currentStatus = undefined; this._currentStatus = undefined;
this.checkStatus(); if (this._enabled) {
this.onAvailabilities(this._service.availabilities);
}
}; };
var zcOn = document.getElementById('tapi-zc-on'); var zcOn = document.getElementById('tapi-zc-on');
zcOn.value = this._statusOn; zcOn.value = this._statusOn;
@@ -5054,6 +5301,8 @@ class Status {
console.log('script start'); console.log('script start');
const src_search_0 = new Search(); const src_search_0 = new Search();
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
@@ -5075,7 +5324,9 @@ const callHistory = new CallHistory();
waitForKeyElements('call', element => { waitForKeyElements('call', element => {
callHistory.showCallHistory(element); callHistory.showCallHistory(element);
}, false); }, false);
const src_status_0 = new Status(); const availabilityService = new AvailabilityService();
availabilityService.start();
const src_status_0 = new Status(availabilityService);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
waitForKeyElements('wc-account-menu', element => { waitForKeyElements('wc-account-menu', element => {
src_status_0.showStatus(element); src_status_0.showStatus(element);
@@ -5083,6 +5334,8 @@ waitForKeyElements('wc-account-menu', element => {
waitForKeyElements('wc-account-menu i.status-indicator', element => { waitForKeyElements('wc-account-menu i.status-indicator', element => {
src_status_0.watchStatus(element); src_status_0.watchStatus(element);
}, false); }, false);
const src_availability_0 = new Availability(availabilityService);
src_availability_0.start();
})(); })();
/******/ })() /******/ })()
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "3cx-tapi", "name": "3cx-tapi",
"version": "9.3.0", "version": "9.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "3cx-tapi", "name": "3cx-tapi",
"version": "9.3.0", "version": "9.6.0",
"dependencies": { "dependencies": {
"@trim21/gm-fetch": "^0.3.0", "@trim21/gm-fetch": "^0.3.0",
"chrono-node": "^2.9.0" "chrono-node": "^2.9.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "3cx-tapi", "name": "3cx-tapi",
"description": "3CX CP Tapi and Projectmanager integration", "description": "3CX CP Tapi and Projectmanager integration",
"version": "9.5.1", "version": "9.6.0",
"author": { "author": {
"name": "Daniel Triendl", "name": "Daniel Triendl",
"email": "d.triendl@cp-solutions.at" "email": "d.triendl@cp-solutions.at"
+7
View File
@@ -0,0 +1,7 @@
export class AvailabilityInfo {
public user: string;
public loggedIn: boolean;
public extension: string;
public lastStamp: string;
public firma: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
import { Config } from './config'
import { AvailabilityInfo } from './availability-info'
import GM_fetch from '@trim21/gm-fetch'
type Listener = (availabilities: AvailabilityInfo[]) => void
export class AvailabilityService {
private _availabilities: AvailabilityInfo[] = []
private _listeners: Listener[] = []
private _started = false
public start() {
if (this._started) {
return
}
this._started = true
this.fetch()
setInterval(() => this.fetch(), 30000)
}
public subscribe(listener: Listener) {
this._listeners.push(listener)
if (this._availabilities.length > 0) {
listener(this._availabilities)
}
}
public get availabilities(): AvailabilityInfo[] {
return this._availabilities
}
private async fetch() {
try {
var response = await GM_fetch(Config.tapi_server_url + '/availability')
if (response.status === 200) {
this._availabilities = await response.json() as AvailabilityInfo[]
this._listeners.forEach(l => l(this._availabilities))
}
} catch (error) {
console.log(error)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
.tapi-availability {
grid-column: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
font-size: 9px;
opacity: 0.7;
line-height: 1;
white-space: nowrap;
overflow: hidden;
margin-top: -4px;
margin-bottom: 6px;
}
.tapi-availability small {
font-size: inherit;
}
.tapi-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.tapi-dot-on {
background-color: #5cb85c;
}
.tapi-dot-off {
background-color: #d9534f;
}
.tapi-avatar-square {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 5px;
z-index: 10;
}
people-tile .avatar-size-m,
people-tile .avatar-size-m .avatar-container,
people-tile .avatar-size-m .avatar-content {
width: 57px !important;
height: 57px !important;
line-height: 57px !important;
margin-top: 10px;
}
people-tile {
grid-template-rows: 29px 23px;
}
people-tile app-avatar {
grid-row: 1 / span 2 !important;
align-self: center !important;
}
.tapi-square-on {
background-color: #5cb85c;
}
.tapi-square-off {
background-color: #d9534f;
}
+92
View File
@@ -0,0 +1,92 @@
import './availability.css'
import { AvailabilityInfo } from './availability-info'
import { AvailabilityService } from './availability-service'
declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any;
export class Availability {
private _service: AvailabilityService
private _availabilities: AvailabilityInfo[] = []
constructor(service: AvailabilityService) {
this._service = service
}
public start() {
this._service.subscribe((avs) => {
this._availabilities = avs
this.updateAllIndicators()
})
waitForKeyElements('people-tile', (element: HTMLElement) => { this.decorateTile(element) }, false)
}
private decorateTile(tile: HTMLElement) {
if (tile.querySelector('.tapi-availability')) {
return
}
var extEl = tile.querySelector('span[data-id="extPhone"]') as HTMLElement
if (!extEl) {
return
}
var extension = extEl.textContent.trim()
var indicator = document.createElement('div')
indicator.classList.add('tapi-availability')
indicator.dataset.tapiExtension = extension
tile.appendChild(indicator)
this.updateIndicator(indicator)
var avatarContainer = tile.querySelector('app-avatar .avatar-container') as HTMLElement
if (avatarContainer) {
var square = document.createElement('i')
square.classList.add('tapi-avatar-square')
square.dataset.tapiExtension = extension
avatarContainer.appendChild(square)
this.updateSquare(square)
}
}
private updateAllIndicators() {
var indicators = document.getElementsByClassName('tapi-availability')
for (var i of indicators) {
this.updateIndicator(i as HTMLElement)
}
var squares = document.getElementsByClassName('tapi-avatar-square')
for (var s of squares) {
this.updateSquare(s as HTMLElement)
}
}
private updateIndicator(indicator: HTMLElement) {
var extension = indicator.dataset.tapiExtension
var entry = this._availabilities.find(a => a.extension === extension)
if (!entry) {
indicator.innerHTML = ''
return
}
var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off'
var time = ''
if (entry.lastStamp) {
var d = new Date(entry.lastStamp)
var pad = (n: number) => n.toString().padStart(2, '0')
time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
var location = ''
if (entry.loggedIn) {
location = entry.firma ? ' · Büro' : ' · Home'
}
indicator.innerHTML = '<span class="tapi-dot ' + dotClass + '"></span><small>' + time + location + '</small>'
}
private updateSquare(square: HTMLElement) {
var extension = square.dataset.tapiExtension
var entry = this._availabilities.find(a => a.extension === extension)
square.classList.remove('tapi-square-on', 'tapi-square-off')
if (!entry) {
square.style.display = 'none'
return
}
square.style.display = ''
square.classList.add(entry.loggedIn ? 'tapi-square-on' : 'tapi-square-off')
}
}
+9 -1
View File
@@ -1,5 +1,7 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import { Availability } from './availability'
import { AvailabilityService } from './availability-service'
import { CallHistory } from './call-history' import { CallHistory } from './call-history'
import { CallNotification } from './call-notification' import { CallNotification } from './call-notification'
import { Presence } from './presence' import { Presence } from './presence'
@@ -24,8 +26,14 @@ const callHistory = new CallHistory()
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
waitForKeyElements('call', (element) => { callHistory.showCallHistory(element) }, false) waitForKeyElements('call', (element) => { callHistory.showCallHistory(element) }, false)
const status = new Status() const availabilityService = new AvailabilityService()
availabilityService.start()
const status = new Status(availabilityService)
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
waitForKeyElements('wc-account-menu', (element) => { status.showStatus(element) }, false) waitForKeyElements('wc-account-menu', (element) => { status.showStatus(element) }, false)
waitForKeyElements('wc-account-menu i.status-indicator', (element) => { status.watchStatus(element) }, false) waitForKeyElements('wc-account-menu i.status-indicator', (element) => { status.watchStatus(element) }, false)
const availability = new Availability(availabilityService)
availability.start()
+30 -19
View File
@@ -1,17 +1,22 @@
import { Config } from './config';
import './status.css'; import './status.css';
import { ZcStatus } from './zc-status'; import { AvailabilityInfo } from './availability-info';
import GM_fetch from "@trim21/gm-fetch"; import { AvailabilityService } from './availability-service';
const zcIcon = require('./stopwatch-regular-full.svg'); const zcIcon = require('./stopwatch-regular-full.svg');
declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any; declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any;
export class Status { export class Status {
private _service: AvailabilityService;
private _user: string; private _user: string;
private _enabled = false; private _enabled = false;
private _statusOn = 'menuAvailable'; private _statusOn = 'menuAvailable';
private _statusOff = 'menuAway'; private _statusOff = 'menuAway';
private _currentStatus: boolean = undefined; private _currentStatus: boolean = undefined;
private _subscribed = false;
constructor(service: AvailabilityService) {
this._service = service;
}
public async showStatus(element: HTMLElement) { public async showStatus(element: HTMLElement) {
this._user = await GM.getValue('tapi-zc-user', ''); this._user = await GM.getValue('tapi-zc-user', '');
@@ -20,19 +25,29 @@ export class Status {
this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable'); this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable');
console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff); console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff);
this.checkStatus(); this.subscribeOnce();
waitForKeyElements("wc-account-menu > div > ul", (element: HTMLElement) => { this.addZcStatusPopup(element) }, true); waitForKeyElements("wc-account-menu > div > ul", (element: HTMLElement) => { this.addZcStatusPopup(element) }, true);
} }
private async checkStatus() { private subscribeOnce() {
if (this._enabled) { if (this._subscribed) {
try { return;
var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user)); }
if (response.status == 200) { this._subscribed = true;
var status = await response.json() as ZcStatus; this._service.subscribe((avs) => this.onAvailabilities(avs));
if (this._currentStatus !== status.loggedIn) { }
this._currentStatus = status.loggedIn;
private onAvailabilities(avs: AvailabilityInfo[]) {
if (!this._enabled || !this._user) {
return;
}
var entry = avs.find(a => a.user === this._user);
if (!entry) {
return;
}
if (this._currentStatus !== entry.loggedIn) {
this._currentStatus = entry.loggedIn;
console.log('New status, loggedIn', this._currentStatus); console.log('New status, loggedIn', this._currentStatus);
var accMenu = document.getElementsByTagName("wc-account-menu")[0]; var accMenu = document.getElementsByTagName("wc-account-menu")[0];
var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement; var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement;
@@ -44,12 +59,6 @@ export class Status {
}, 1000); }, 1000);
} }
} }
} catch (error) {
console.log(error);
}
setTimeout(() => this.checkStatus(), 30000);
}
}
private addZcStatusPopup(element: HTMLElement) { private addZcStatusPopup(element: HTMLElement) {
var divider = document.createElement('li'); var divider = document.createElement('li');
@@ -149,7 +158,9 @@ export class Status {
GM.setValue('tapi-zc-enabled', this._enabled); GM.setValue('tapi-zc-enabled', this._enabled);
console.log('tapi-zc-enabled', this._enabled); console.log('tapi-zc-enabled', this._enabled);
this._currentStatus = undefined; this._currentStatus = undefined;
this.checkStatus(); if (this._enabled) {
this.onAvailabilities(this._service.availabilities);
}
} }
var zcOn = document.getElementById('tapi-zc-on') as HTMLSelectElement; var zcOn = document.getElementById('tapi-zc-on') as HTMLSelectElement;
-4
View File
@@ -1,4 +0,0 @@
export class ZcStatus {
public user: string;
public loggedIn: boolean;
}
@@ -11,4 +11,8 @@ public class Availability
public bool LOGGED_IN { get; set; } public bool LOGGED_IN { get; set; }
[JsonPropertyName("extension")] [JsonPropertyName("extension")]
public string? US_EXTENSION { get; set; } public string? US_EXTENSION { get; set; }
[JsonPropertyName("lastStamp")]
public DateTime? LAST_STAMP { get; set; }
[JsonPropertyName("firma")]
public bool? FIRMA { get; set; }
} }
@@ -11,15 +11,24 @@ internal class ZeitConsensRepository(IConfiguration config) : Repository(config)
ma.MA_USER_NAME ma.MA_USER_NAME
,bu.LOGGED_IN ,bu.LOGGED_IN
,us.US_EXTENSION ,us.US_EXTENSION
,buLast.LAST_STAMP
,buLast.FIRMA
FROM dbo.MA_DATEN ma FROM dbo.MA_DATEN ma
INNER JOIN projectmanagement.dbo.CP_USER us on us.US_LOGINNAME = ma.MA_USER_NAME INNER JOIN projectmanagement.dbo.CP_USER us ON us.US_LOGINNAME = ma.MA_USER_NAME
OUTER APPLY ( OUTER APPLY (
SELECT count(*) % 2 AS LOGGED_IN SELECT COUNT(*) % 2 AS LOGGED_IN
FROM dbo.BU FROM dbo.BU bu
WHERE bu.BU_MA_NR = ma.MA_NR WHERE bu.BU_MA_NR = ma.MA_NR
AND AND bu.BU_BU >= @from AND bu.BU_BU < @to
BU_BU >= @from AND BU_BU < @to
) bu ) bu
OUTER APPLY (
SELECT TOP 1
bu.BU_BU AS LAST_STAMP
,CASE WHEN bu.BU_TERM = 'Zeiterfassung' THEN 1 ELSE 0 END AS FIRMA
FROM dbo.BU bu
WHERE bu.BU_MA_NR = ma.MA_NR
ORDER BY bu.BU_BU DESC
) buLast
WHERE WHERE
ma.MA_USER_AKTIV = 1 ma.MA_USER_AKTIV = 1
"""; """;