11 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
CPATRD 18c6f6f10e Updated client library 2026-04-13 10:52:35 +02:00
CPATRD 02c8e0ea3c Get Availability and extension for all users 2026-04-13 10:44:55 +02:00
CPATRD ccf1f63f1b Bump Version to 9.5.1 2026-04-10 12:06:16 +02:00
CPATRD 8459f7938d Move userscript to server 2026-04-10 12:02:03 +02:00
31 changed files with 992 additions and 319 deletions
+3
View File
@@ -0,0 +1,3 @@
*Dockerfile*
*docker-compose*
node_modules
+472 -219
View File
@@ -1,11 +1,11 @@
// ==UserScript== // ==UserScript==
// @name 3CX TAPI // @name 3CX TAPI
// @namespace http://cp-solutions.at // @namespace http://cp-solutions.at
// @version 9.5.0 // @version 9.5.1
// @author Daniel Triendl <d.triendl@cp-solutions.at> // @author Daniel Triendl <d.triendl@cp-solutions.at>
// @copyright Copyright CP Solutions GmbH // @copyright Copyright CP Solutions GmbH
// @source https://source.cp-austria.at/CPATRD/3cx_tapi.git // @source https://source.cp-austria.at/CPATRD/3cx_tapi.git
// @downloadURL https://source.cp-austria.at/CPATRD/3cx_tapi.git/raw/branch/master/3CX_TAPI.user.js // @downloadURL https://3cxtapi.cp-austria.at/3CX_TAPI.user.js
// @match https://192.168.0.154:5001/* // @match https://192.168.0.154:5001/*
// @match https://cpsolution.my3cx.at:5001/* // @match https://cpsolution.my3cx.at:5001/*
// @require https://cdn.jsdelivr.net/gh/CoeJoder/waitForKeyElements.js@v1.2/waitForKeyElements.js // @require https://cdn.jsdelivr.net/gh/CoeJoder/waitForKeyElements.js@v1.2/waitForKeyElements.js
@@ -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();
})(); })();
/******/ })() /******/ })()
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
author: pkg.author, author: pkg.author,
copyright: 'Copyright CP Solutions GmbH', copyright: 'Copyright CP Solutions GmbH',
source: pkg.repository.url, source: pkg.repository.url,
downloadURL: `${pkg.repository.url}/raw/branch/master/3CX_TAPI.user.js`, downloadURL: 'https://3cxtapi.cp-austria.at/3CX_TAPI.user.js',
match: [ match: [
'https://192.168.0.154:5001/*', 'https://192.168.0.154:5001/*',
'https://cpsolution.my3cx.at:5001/*' 'https://cpsolution.my3cx.at:5001/*'
+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.0", "version": "9.6.0",
"author": { "author": {
"name": "Daniel Triendl", "name": "Daniel Triendl",
"email": "d.triendl@cp-solutions.at" "email": "d.triendl@cp-solutions.at"
+2 -6
View File
@@ -38,15 +38,11 @@ just install a package and import it in your js file. webpack will pack them wit
npm run build npm run build
``` ```
`dist/index.prod.user.js` is the final script. `dist/3CX TAPI.prod.user.js` is the final script.
## distribution ## distribution
``` Userscript is included in server docker image
cp "dist/3CX TAPI.prod.user.js" ../3CX_TAPI.user.js
```
And commit 3CX_TAPI.user.js
## see also ## see also
+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()
+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;
}
@@ -2,7 +2,7 @@
setlocal setlocal
cd /d %~dp0 cd /d %~dp0
docker build -t source.cp-austria.at/cpatrd/3cx_tapi:latest -f Dockerfile .. docker build -t source.cp-austria.at/cpatrd/3cx_tapi:latest -f server/src/CPATapi.Server/Dockerfile .
if errorlevel 1 ( if errorlevel 1 (
echo. echo.
echo ERROR: Docker build failed! echo ERROR: Docker build failed!
+6
View File
@@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Client", "src\CPATa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Client.Tests", "test\CPATapi.Client.Tests\CPATapi.Client.Tests.csproj", "{17F37791-4F68-46D5-8CF5-5F1736F6776E}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Client.Tests", "test\CPATapi.Client.Tests\CPATapi.Client.Tests.csproj", "{17F37791-4F68-46D5-8CF5-5F1736F6776E}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Server.Tests", "test\CPATapi.Server.Tests\CPATapi.Server.Tests.csproj", "{72486DC9-2C7D-409B-9E14-6D90F67B92CC}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -30,6 +32,10 @@ Global
{17F37791-4F68-46D5-8CF5-5F1736F6776E}.Debug|Any CPU.Build.0 = Debug|Any CPU {17F37791-4F68-46D5-8CF5-5F1736F6776E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17F37791-4F68-46D5-8CF5-5F1736F6776E}.Release|Any CPU.ActiveCfg = Release|Any CPU {17F37791-4F68-46D5-8CF5-5F1736F6776E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17F37791-4F68-46D5-8CF5-5F1736F6776E}.Release|Any CPU.Build.0 = Release|Any CPU {17F37791-4F68-46D5-8CF5-5F1736F6776E}.Release|Any CPU.Build.0 = Release|Any CPU
{72486DC9-2C7D-409B-9E14-6D90F67B92CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72486DC9-2C7D-409B-9E14-6D90F67B92CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72486DC9-2C7D-409B-9E14-6D90F67B92CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72486DC9-2C7D-409B-9E14-6D90F67B92CC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -1,12 +1,14 @@
// <auto-generated/> // <auto-generated/>
#pragma warning disable CS0618 #pragma warning disable CS0618
using CPATapi.Client.Availability.Item; using CPATapi.Client.Availability.Item;
using CPATapi.Client.Availability.Users; using CPATapi.Client.Models;
using Microsoft.Kiota.Abstractions.Extensions; using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading;
using System; using System;
namespace CPATapi.Client.Availability namespace CPATapi.Client.Availability
{ {
@@ -16,11 +18,6 @@ namespace CPATapi.Client.Availability
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class AvailabilityRequestBuilder : BaseRequestBuilder public partial class AvailabilityRequestBuilder : BaseRequestBuilder
{ {
/// <summary>The users property</summary>
public global::CPATapi.Client.Availability.Users.UsersRequestBuilder Users
{
get => new global::CPATapi.Client.Availability.Users.UsersRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>Gets an item from the CPATapi.Client.Availability.item collection</summary> /// <summary>Gets an item from the CPATapi.Client.Availability.item collection</summary>
/// <param name="position">Unique identifier of the item</param> /// <param name="position">Unique identifier of the item</param>
/// <returns>A <see cref="global::CPATapi.Client.Availability.Item.WithUserItemRequestBuilder"/></returns> /// <returns>A <see cref="global::CPATapi.Client.Availability.Item.WithUserItemRequestBuilder"/></returns>
@@ -49,6 +46,55 @@ namespace CPATapi.Client.Availability
public AvailabilityRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/Availability", rawUrl) public AvailabilityRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/Availability", rawUrl)
{ {
} }
/// <returns>A List&lt;global::CPATapi.Client.Models.Availability&gt;</returns>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<List<global::CPATapi.Client.Models.Availability>?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<List<global::CPATapi.Client.Models.Availability>> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
var collectionResult = await RequestAdapter.SendCollectionAsync<global::CPATapi.Client.Models.Availability>(requestInfo, global::CPATapi.Client.Models.Availability.CreateFromDiscriminatorValue, default, cancellationToken).ConfigureAwait(false);
return collectionResult?.AsList();
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "text/plain;q=0.9");
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::CPATapi.Client.Availability.AvailabilityRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::CPATapi.Client.Availability.AvailabilityRequestBuilder WithUrl(string rawUrl)
{
return new global::CPATapi.Client.Availability.AvailabilityRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class AvailabilityRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
} }
} }
#pragma warning restore CS0618 #pragma warning restore CS0618
@@ -36,6 +36,7 @@ namespace CPATapi.Client.Availability.Item
/// <returns>A <see cref="global::CPATapi.Client.Models.Availability"/></returns> /// <returns>A <see cref="global::CPATapi.Client.Models.Availability"/></returns>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param> /// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param> /// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::CPATapi.Client.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable #nullable enable
public async Task<global::CPATapi.Client.Models.Availability?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default) public async Task<global::CPATapi.Client.Models.Availability?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
@@ -46,7 +47,11 @@ namespace CPATapi.Client.Availability.Item
{ {
#endif #endif
var requestInfo = ToGetRequestInformation(requestConfiguration); var requestInfo = ToGetRequestInformation(requestConfiguration);
return await RequestAdapter.SendAsync<global::CPATapi.Client.Models.Availability>(requestInfo, global::CPATapi.Client.Models.Availability.CreateFromDiscriminatorValue, default, cancellationToken).ConfigureAwait(false); var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "404", global::CPATapi.Client.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendAsync<global::CPATapi.Client.Models.Availability>(requestInfo, global::CPATapi.Client.Models.Availability.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
} }
/// <returns>A <see cref="RequestInformation"/></returns> /// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param> /// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
@@ -7,7 +7,7 @@
<PackageId>CPATapi.Client</PackageId> <PackageId>CPATapi.Client</PackageId>
<Authors>Daniel Triendl</Authors> <Authors>Daniel Triendl</Authors>
<Company>CP Solutions GmbH</Company> <Company>CP Solutions GmbH</Company>
<Version>9.4.0</Version> <Version>9.6.0</Version>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup> </PropertyGroup>
@@ -14,6 +14,18 @@ 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>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? Extension { get; set; }
#nullable restore
#else
public string Extension { get; set; }
#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>
@@ -49,6 +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(); } },
{ "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(); } },
}; };
@@ -60,6 +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.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": "1B47F98D82C5E24FB3ABEDB3BF90615424A8F19620EA2621D23B7FC69F4F7BF27D512A11098EF4D8940C7D243DEEB59C0CA038264430AD9150B612F5046C8146", "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",
@@ -8,7 +8,7 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext> <DockerfileContext>..\..</DockerfileContext>
<OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory> <OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory>
<Version>9.4.0</Version> <Version>9.6.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -26,4 +26,12 @@
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="CPATapi.Server.Tests" />
</ItemGroup>
</Project> </Project>
@@ -11,19 +11,24 @@ namespace CPATapi.Server.Controllers;
public class AvailabilityController(IZeitConsensRepository zeitConsens) : ControllerBase public class AvailabilityController(IZeitConsensRepository zeitConsens) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Route("users")] [Route("")]
[ProducesResponseType<IEnumerable<string>>(StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<Availability>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetUsers() public async Task<IActionResult> GetUsers()
{ {
return Ok(await zeitConsens.GetUsersAsync()); return Ok(await zeitConsens.GetUsersAvailabilityAsync(DateTime.Now.Date, DateTime.Now.AddDays(1).Date));
} }
[HttpGet] [HttpGet]
[Route("{user}")] [Route("{user}")]
[ProducesResponseType<Availability>(StatusCodes.Status200OK)] [ProducesResponseType<Availability>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetAvailability(string user) public async Task<IActionResult> GetAvailability(string user)
{ {
var stampCount = (await zeitConsens.GetStampsAsync(user, DateTime.Now.Date, DateTime.Now.AddDays(1).Date)).Count(); var availability = await zeitConsens.GetUserAvailabilityAsync(user, DateTime.Now.Date, DateTime.Now.AddDays(1).Date);
return Ok(new Availability { User = user, LoggedIn = stampCount % 2 != 0 }); if (availability == null)
{
return NotFound();
}
return Ok(availability);
} }
} }
+12 -3
View File
@@ -6,14 +6,22 @@ USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
# This stage is used to build the userscript project
FROM node:lts-alpine AS build-userscript
WORKDIR /src
COPY client .
RUN npm ci
RUN npm run build
#RUN ls -la /src/dist
# This stage is used to build the service project # This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["CPATapi.Server/CPATapi.Server.csproj", "CPATapi.Server/"] COPY ["server/src/CPATapi.Server/CPATapi.Server.csproj", "server/src/CPATapi.Server/CPATapi.Server.csproj"]
RUN dotnet restore "CPATapi.Server/CPATapi.Server.csproj" RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj"
COPY . . COPY . .
WORKDIR "/src/CPATapi.Server" WORKDIR "/src/server/src/CPATapi.Server"
RUN dotnet build "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/build RUN dotnet build "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage # This stage is used to publish the service project to be copied to the final stage
@@ -25,4 +33,5 @@ RUN dotnet publish "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/pub
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
COPY --from=build-userscript ["/src/dist/3CX TAPI.prod.user.js", "./wwwroot/3CX_TAPI.user.js"]
ENTRYPOINT ["dotnet", "CPATapi.Server.dll"] ENTRYPOINT ["dotnet", "CPATapi.Server.dll"]
@@ -4,6 +4,6 @@ namespace CPATapi.Server.Interfaces;
public interface IZeitConsensRepository : IRepository public interface IZeitConsensRepository : IRepository
{ {
Task<IEnumerable<string>> GetUsersAsync(); Task<IEnumerable<Availability>> GetUsersAvailabilityAsync(DateTime from, DateTime to);
Task<IEnumerable<Stamp>> GetStampsAsync(string user, DateTime from, DateTime to); Task<Availability?> GetUserAvailabilityAsync(string user, DateTime from, DateTime to);
} }
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace CPATapi.Server.Models; namespace CPATapi.Server.Models;
@@ -5,7 +6,13 @@ namespace CPATapi.Server.Models;
public class Availability public class Availability
{ {
[JsonPropertyName("user")] [JsonPropertyName("user")]
public string User { get; set; } public required string MA_USER_NAME { get; set; }
[JsonPropertyName("loggedIn")] [JsonPropertyName("loggedIn")]
public bool LoggedIn { get; set; } public bool LOGGED_IN { get; set; }
[JsonPropertyName("extension")]
public string? US_EXTENSION { get; set; }
[JsonPropertyName("lastStamp")]
public DateTime? LAST_STAMP { get; set; }
[JsonPropertyName("atCompany")]
public bool? AT_COMPANY { get; set; }
} }
@@ -1,7 +0,0 @@
namespace CPATapi.Server.Models;
public class Stamp
{
public int MA_NR { get; set; }
public DateTime BU_BU { get; set; }
}
+1
View File
@@ -30,6 +30,7 @@ if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
} }
app.MapStaticAssets();
app.MapOpenApi(); app.MapOpenApi();
app.UseSwaggerUI(options => app.UseSwaggerUI(options =>
{ {
@@ -6,29 +6,45 @@ namespace CPATapi.Server.Repository;
internal class ZeitConsensRepository(IConfiguration config) : Repository(config), IZeitConsensRepository internal class ZeitConsensRepository(IConfiguration config) : Repository(config), IZeitConsensRepository
{ {
public async Task<IEnumerable<string>> GetUsersAsync() private const string SelectStampsQuery = """
SELECT
ma.MA_USER_NAME
,bu.LOGGED_IN
,us.US_EXTENSION
,buLast.LAST_STAMP
,buLast.AT_COMPANY
FROM dbo.MA_DATEN ma
INNER JOIN projectmanagement.dbo.CP_USER us ON us.US_LOGINNAME = ma.MA_USER_NAME
OUTER APPLY (
SELECT COUNT(*) % 2 AS LOGGED_IN
FROM dbo.BU bu
WHERE bu.BU_MA_NR = ma.MA_NR
AND bu.BU_BU >= @from AND bu.BU_BU < @to
) 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
ma.MA_USER_AKTIV = 1
""";
public async Task<IEnumerable<Availability>> GetUsersAvailabilityAsync(DateTime from, DateTime to)
{ {
await using var con = await OpenAsync("ZeitConsens"); await using var con = await OpenAsync("ZeitConsens");
return await con.QueryAsync<string>(""" return await con.QueryAsync<Availability>(SelectStampsQuery, new { from, to });
SELECT DISTINCT MA_USER_NAME
FROM dbo.MA_DATEN
WHERE MA_USER_NAME IS NOT NULL AND MA_USER_AKTIV = 1
ORDER BY MA_USER_NAME
""");
} }
public async Task<IEnumerable<Stamp>> GetStampsAsync(string user, DateTime from, DateTime to) public async Task<Availability?> GetUserAvailabilityAsync(string user, DateTime from, DateTime to)
{ {
await using var con = await OpenAsync("ZeitConsens"); await using var con = await OpenAsync("ZeitConsens");
return await con.QueryAsync<Stamp>(""" return await con.QueryFirstOrDefaultAsync<Availability>($"""
SELECT {SelectStampsQuery}
MA_NR AND ma.MA_USER_NAME = @user
,BU_BU
FROM dbo.BU
INNER JOIN dbo.MA_DATEN on MA_NR = BU_MA_NR
WHERE
MA_USER_NAME = @user AND
BU_BU >= @from AND BU_BU < @to
""", new { user, from, to }); """, new { user, from, to });
} }
} }
@@ -1,3 +1,4 @@
using CPATapi.Client.Models;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace CPATapi.Client.Tests; namespace CPATapi.Client.Tests;
@@ -39,7 +40,17 @@ public class CPATapiClientTests
{ {
using var scope = CreateServices(); using var scope = CreateServices();
var client = scope.ServiceProvider.GetRequiredService<CPATapiClient>(); var client = scope.ServiceProvider.GetRequiredService<CPATapiClient>();
var availability = await client.Availability["Unknown"].GetAsync(); Assert.ThrowsAsync<ProblemDetails>(async () => await client.Availability["Unknown"].GetAsync());
}
[Test]
public async Task TestAvailabilityGetAll()
{
using var scope = CreateServices();
var client = scope.ServiceProvider.GetRequiredService<CPATapiClient>();
var availability = await client.Availability.GetAsync();
Assert.That(availability, Is.Not.Null); Assert.That(availability, Is.Not.Null);
Assert.That(availability, Is.Not.Empty);
Assert.That(availability, Has.Count.GreaterThan(1));
} }
} }
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UserSecretsId>a7b40068-a2f6-4c63-bbd9-0fd346908fb0</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CPATapi.Server\CPATapi.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Configuration;
using CPATapi.Server.Repository;
namespace CPATapi.Server.Tests;
public class ZeitConsensRepositoryTest
{
[OneTimeSetUp]
public void Setup()
{
// the type specified here is just so the secrets library can
// find the UserSecretId we added in the csproj file
var builder = new ConfigurationBuilder().AddUserSecrets<ZeitConsensRepositoryTest>();
_configuration = builder.Build();
}
private IConfigurationRoot _configuration { get; set; }
[Test]
public async Task TestGetUsersAvailabilityAsync()
{
var zcRepo = new ZeitConsensRepository(_configuration);
var availability= (await zcRepo.GetUsersAvailabilityAsync(DateTime.Now.Date, DateTime.Now.AddDays(1).Date)).ToList();
Assert.That(availability, Is.Not.Empty);
Assert.That(availability, Has.Count.GreaterThan(1));
}
[Test]
public async Task TestGetUserAvailabilityAsync()
{
var zcRepo = new ZeitConsensRepository(_configuration);
var availability = await zcRepo.GetUserAvailabilityAsync("CPATRD", DateTime.Now.Date, DateTime.Now.AddDays(1).Date);
Assert.That(availability, Is.Not.Null);
Assert.That(availability.MA_USER_NAME, Is.EqualTo("CPATRD"));
Assert.That(availability.US_EXTENSION, Is.EqualTo("203"));
}
}