7 Commits

Author SHA1 Message Date
CPATRD 21683f5e8b Client aktualisiert 2026-04-13 13:23:50 +02:00
CPAMAP 2874ea78c4 Rename firma to atCompany on server and client
Renames the Zeiterfassung-vs-home flag to a clearer English name
(SQL alias AT_COMPANY, model property AT_COMPANY, JSON atCompany)
in both the server query/model and the client AvailabilityInfo
type and people-tile renderer.
2026-04-13 13:18:02 +02:00
CPAMAP d5558b61b2 Parse lastStamp as Date in AvailabilityService
Type AvailabilityInfo.lastStamp as Date and convert from the JSON
string in the service after fetch, so consumers work with real
Date objects.
2026-04-13 13:15:58 +02:00
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
14 changed files with 772 additions and 268 deletions
+479 -226
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,48 +5104,53 @@ 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) {
console.log('New status, loggedIn', this._currentStatus); if (!this._enabled || !this._user) {
var accMenu = document.getElementsByTagName("wc-account-menu")[0]; return;
var avatar = accMenu.getElementsByTagName("app-avatar")[0]; }
avatar.click(); var entry = avs.find(a => a.user === this._user);
setTimeout(() => { if (!entry) {
var statusId = this._currentStatus ? this._statusOn : this._statusOff; return;
console.log('Clicking status', statusId); }
document.getElementById(statusId).click(); if (this._currentStatus !== entry.loggedIn) {
}, 1000); this._currentStatus = entry.loggedIn;
} console.log('New status, loggedIn', this._currentStatus);
} var accMenu = document.getElementsByTagName("wc-account-menu")[0];
} var avatar = accMenu.getElementsByTagName("app-avatar")[0];
catch (error) { avatar.click();
console.log(error); setTimeout(() => {
} var statusId = this._currentStatus ? this._statusOn : this._statusOff;
setTimeout(() => this.checkStatus(), 30000); console.log('Clicking status', statusId);
document.getElementById(statusId).click();
}, 1000);
} }
} }
addZcStatusPopup(element) { addZcStatusPopup(element) {
@@ -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: Date;
public atCompany: boolean;
}
+47
View File
@@ -0,0 +1,47 @@
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) {
var raw = await response.json() as AvailabilityInfo[]
this._availabilities = raw.map(a => ({
...a,
lastStamp: a.lastStamp ? new Date(a.lastStamp) : null,
}))
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 pad = (n: number) => n.toString().padStart(2, '0')
var d = entry.lastStamp
time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
var location = ''
if (entry.loggedIn) {
location = entry.atCompany ? ' · 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()
+39 -28
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,34 +25,38 @@ 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;
console.log('New status, loggedIn', this._currentStatus); private onAvailabilities(avs: AvailabilityInfo[]) {
var accMenu = document.getElementsByTagName("wc-account-menu")[0]; if (!this._enabled || !this._user) {
var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement; return;
avatar.click(); }
setTimeout(() => { var entry = avs.find(a => a.user === this._user);
var statusId = this._currentStatus ? this._statusOn : this._statusOff; if (!entry) {
console.log('Clicking status', statusId); return;
(document.getElementById(statusId) as HTMLSpanElement).click(); }
}, 1000); if (this._currentStatus !== entry.loggedIn) {
} this._currentStatus = entry.loggedIn;
} console.log('New status, loggedIn', this._currentStatus);
} catch (error) { var accMenu = document.getElementsByTagName("wc-account-menu")[0];
console.log(error); var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement;
} avatar.click();
setTimeout(() => this.checkStatus(), 30000); setTimeout(() => {
var statusId = this._currentStatus ? this._statusOn : this._statusOff;
console.log('Clicking status', statusId);
(document.getElementById(statusId) as HTMLSpanElement).click();
}, 1000);
} }
} }
@@ -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;
}
@@ -14,6 +14,8 @@ namespace CPATapi.Client.Models
{ {
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary> /// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; } public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The atCompany property</summary>
public bool? AtCompany { get; set; }
/// <summary>The extension property</summary> /// <summary>The extension property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable #nullable enable
@@ -22,6 +24,8 @@ namespace CPATapi.Client.Models
#else #else
public string Extension { get; set; } public string Extension { get; set; }
#endif #endif
/// <summary>The lastStamp property</summary>
public DateTimeOffset? LastStamp { get; set; }
/// <summary>The loggedIn property</summary> /// <summary>The loggedIn property</summary>
public bool? LoggedIn { get; set; } public bool? LoggedIn { get; set; }
/// <summary>The user property</summary> /// <summary>The user property</summary>
@@ -57,7 +61,9 @@ namespace CPATapi.Client.Models
{ {
return new Dictionary<string, Action<IParseNode>> return new Dictionary<string, Action<IParseNode>>
{ {
{ "atCompany", n => { AtCompany = n.GetBoolValue(); } },
{ "extension", n => { Extension = n.GetStringValue(); } }, { "extension", n => { Extension = n.GetStringValue(); } },
{ "lastStamp", n => { LastStamp = n.GetDateTimeOffsetValue(); } },
{ "loggedIn", n => { LoggedIn = n.GetBoolValue(); } }, { "loggedIn", n => { LoggedIn = n.GetBoolValue(); } },
{ "user", n => { User = n.GetStringValue(); } }, { "user", n => { User = n.GetStringValue(); } },
}; };
@@ -69,7 +75,9 @@ namespace CPATapi.Client.Models
public virtual void Serialize(ISerializationWriter writer) public virtual void Serialize(ISerializationWriter writer)
{ {
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteBoolValue("atCompany", AtCompany);
writer.WriteStringValue("extension", Extension); writer.WriteStringValue("extension", Extension);
writer.WriteDateTimeOffsetValue("lastStamp", LastStamp);
writer.WriteBoolValue("loggedIn", LoggedIn); writer.WriteBoolValue("loggedIn", LoggedIn);
writer.WriteStringValue("user", User); writer.WriteStringValue("user", User);
writer.WriteAdditionalData(AdditionalData); writer.WriteAdditionalData(AdditionalData);
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"descriptionHash": "3ADB4B190A2637B9EC01981B2508C539F2A582D95310D01FF97D2F2C068B9024CDC66F4D14F486265ED22314E9EEB2EA7CD3BF0F3D1ECC061BA7B9734B520A9D", "descriptionHash": "DEDF4FD053830F062E3DE69918EF38C90DCD76DC47BB854DDA337BB592E2A34DFAAAC5F0C23D40481BD2883833366E7AAC243D825DBCCE2897E4E6A78B890BE9",
"descriptionLocation": "../CPATapi.Server/CPATapi.Server.json", "descriptionLocation": "../CPATapi.Server/CPATapi.Server.json",
"lockFileVersion": "1.0.0", "lockFileVersion": "1.0.0",
"kiotaVersion": "1.30.0", "kiotaVersion": "1.30.0",
@@ -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("atCompany")]
public bool? AT_COMPANY { 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.AT_COMPANY
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 AT_COMPANY
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
"""; """;