15 Commits

Author SHA1 Message Date
CPATRD f1ccb7375b Bump Version to 9.6.1 2026-04-15 18:40:48 +02:00
CPAMAP f5ef2aac0f Match ZeitConsens user case-insensitively
The saved tapi-zc-user and the server's MA_USER_NAME are compared
to decide whether to auto-switch the 3CX presence. A mismatch in
capitalization between the two sources silently disabled the sync,
so both are lowercased before comparison.
2026-04-15 17:21:51 +02:00
CPATRD fae65a637c Fixed docker build order for StaticFiles 2026-04-13 14:46:33 +02:00
CPATRD fd5ec49569 TapiDirectoryRepository Tests 2026-04-13 14:19:46 +02:00
CPAMAP 8daa8bf0ff Mark TapiContact properties as required
Silences nullable-reference warnings (CS8618) using the same
pattern already applied to Availability.MA_USER_NAME.
2026-04-13 14:14:14 +02:00
CPAMAP 788ec55bde Filter availability by US_ACTIVE
Restricts the availability listing to projectmanagement.dbo.CP_USER
rows where US_ACTIVE = 1, so deactivated users are not returned.
2026-04-13 14:14:14 +02:00
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
29 changed files with 993 additions and 312 deletions
+470 -217
View File
@@ -122,6 +122,98 @@ module.exports = function (i) {
return i[1]; return i[1];
}; };
/***/ },
/***/ "./node_modules/css-loader/dist/cjs.js!./src/availability.css"
(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ A: () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./node_modules/css-loader/dist/runtime/noSourceMaps.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./node_modules/css-loader/dist/runtime/api.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__);
// Imports
var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default()));
// Module
___CSS_LOADER_EXPORT___.push([module.id, `.tapi-availability {
grid-column: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
font-size: 9px;
opacity: 0.7;
line-height: 1;
white-space: nowrap;
overflow: hidden;
margin-top: -4px;
margin-bottom: 6px;
}
.tapi-availability small {
font-size: inherit;
}
.tapi-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.tapi-dot-on {
background-color: #5cb85c;
}
.tapi-dot-off {
background-color: #d9534f;
}
.tapi-avatar-square {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 5px;
z-index: 10;
}
people-tile .avatar-size-m,
people-tile .avatar-size-m .avatar-container,
people-tile .avatar-size-m .avatar-content {
width: 57px !important;
height: 57px !important;
line-height: 57px !important;
margin-top: 10px;
}
people-tile {
grid-template-rows: 29px 23px;
}
people-tile app-avatar {
grid-row: 1 / span 2 !important;
align-self: center !important;
}
.tapi-square-on {
background-color: #5cb85c;
}
.tapi-square-off {
background-color: #d9534f;
}
`, ""]);
// Exports
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
/***/ }, /***/ },
/***/ "./node_modules/css-loader/dist/cjs.js!./src/presence.css" /***/ "./node_modules/css-loader/dist/cjs.js!./src/presence.css"
@@ -658,6 +750,344 @@ var __webpack_exports__ = {};
(() => { (() => {
"use strict"; "use strict";
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js
var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js");
var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js
var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js");
var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js
var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js");
var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js
var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js");
var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js
var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js");
var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform);
// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/availability.css
var availability = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/availability.css");
;// ./src/availability.css
var options = {};
options.styleTagTransform = (styleTagTransform_default());
options.setAttributes = (setAttributesWithoutAttributes_default());
options.insert = insertBySelector_default().bind(null, "head");
options.domAPI = (styleDomAPI_default());
options.insertStyleElement = (insertStyleElement_default());
var update = injectStylesIntoStyleTag_default()(availability/* default */.A, options);
/* harmony default export */ const src_availability = (availability/* default */.A && availability/* default */.A.locals ? availability/* default */.A.locals : undefined);
;// ./src/availability.ts
class Availability {
_service;
_availabilities = [];
constructor(service) {
this._service = service;
}
start() {
this._service.subscribe((avs) => {
this._availabilities = avs;
this.updateAllIndicators();
});
waitForKeyElements('people-tile', (element) => { this.decorateTile(element); }, false);
}
decorateTile(tile) {
if (tile.querySelector('.tapi-availability')) {
return;
}
var extEl = tile.querySelector('span[data-id="extPhone"]');
if (!extEl) {
return;
}
var extension = extEl.textContent.trim();
var indicator = document.createElement('div');
indicator.classList.add('tapi-availability');
indicator.dataset.tapiExtension = extension;
tile.appendChild(indicator);
this.updateIndicator(indicator);
var avatarContainer = tile.querySelector('app-avatar .avatar-container');
if (avatarContainer) {
var square = document.createElement('i');
square.classList.add('tapi-avatar-square');
square.dataset.tapiExtension = extension;
avatarContainer.appendChild(square);
this.updateSquare(square);
}
}
updateAllIndicators() {
var indicators = document.getElementsByClassName('tapi-availability');
for (var i of indicators) {
this.updateIndicator(i);
}
var squares = document.getElementsByClassName('tapi-avatar-square');
for (var s of squares) {
this.updateSquare(s);
}
}
updateIndicator(indicator) {
var extension = indicator.dataset.tapiExtension;
var entry = this._availabilities.find(a => a.extension === extension);
if (!entry) {
indicator.innerHTML = '';
return;
}
var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off';
var time = '';
if (entry.lastStamp) {
var d = new Date(entry.lastStamp);
var pad = (n) => n.toString().padStart(2, '0');
time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
var location = '';
if (entry.loggedIn) {
location = entry.firma ? ' · Büro' : ' · Home';
}
indicator.innerHTML = '<span class="tapi-dot ' + dotClass + '"></span><small>' + time + location + '</small>';
}
updateSquare(square) {
var extension = square.dataset.tapiExtension;
var entry = this._availabilities.find(a => a.extension === extension);
square.classList.remove('tapi-square-on', 'tapi-square-off');
if (!entry) {
square.style.display = 'none';
return;
}
square.style.display = '';
square.classList.add(entry.loggedIn ? 'tapi-square-on' : 'tapi-square-off');
}
}
;// ./src/config.ts
class _Config {
tapi_server_url = 'https://3cxtapi.cp-austria.at';
}
const Config = new _Config();
;// ./node_modules/@trim21/gm-fetch/dist/index.mjs
function parseRawHeaders(h) {
const s = h.trim();
if (!s) {
return new Headers();
}
const array = s.split("\r\n").map((value) => {
let s = value.split(":");
return [s[0].trim(), s[1].trim()];
});
return new Headers(array);
}
function parseGMResponse(req, res) {
// workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob"
const headers = parseRawHeaders(res.responseHeaders);
const body = typeof res.response === "string"
? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" })
: res.response;
return new ResImpl(body, {
statusCode: res.status,
statusText: res.statusText,
headers,
finalUrl: res.finalUrl,
redirected: res.finalUrl === req.url,
});
}
class ResImpl {
constructor(body, init) {
this.rawBody = body;
this.init = init;
this.body = body.stream();
const { headers, statusCode, statusText, finalUrl, redirected } = init;
this.headers = headers;
this.status = statusCode;
this.statusText = statusText;
this.url = finalUrl;
this.type = "basic";
this.redirected = redirected;
this._bodyUsed = false;
}
get bodyUsed() {
return this._bodyUsed;
}
get ok() {
return this.status < 300;
}
arrayBuffer() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.arrayBuffer();
}
blob() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read");
}
this._bodyUsed = true;
// `slice` will use empty string as default value, so need to pass all arguments.
return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type));
}
clone() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read");
}
return new ResImpl(this.rawBody, this.init);
}
formData() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text().then(decode);
}
async json() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'json' on 'Response': body stream already read");
}
this._bodyUsed = true;
return JSON.parse(await this.rawBody.text());
}
text() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'text' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text();
}
async bytes() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read");
}
this._bodyUsed = true;
return new Uint8Array(await this.rawBody.arrayBuffer());
}
}
function decode(body) {
const form = new FormData();
body
.trim()
.split("&")
.forEach(function (bytes) {
if (bytes) {
const split = bytes.split("=");
const name = split.shift()?.replace(/\+/g, " ");
const value = split.join("=").replace(/\+/g, " ");
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form;
}
async function GM_fetch(input, init) {
const request = new Request(input, init);
let data;
if (init?.body) {
data = await request.text();
}
return await XHR(request, init, data);
}
function XHR(request, init, data) {
return new Promise((resolve, reject) => {
if (request.signal && request.signal.aborted) {
return reject(new DOMException("Aborted", "AbortError"));
}
GM.xmlHttpRequest({
url: request.url,
method: gmXHRMethod(request.method.toUpperCase()),
headers: Object.fromEntries(new Headers(init?.headers).entries()),
data: data,
responseType: "blob",
onload(res) {
try {
resolve(parseGMResponse(request, res));
}
catch (e) {
reject(e);
}
},
onabort() {
reject(new DOMException("Aborted", "AbortError"));
},
ontimeout() {
reject(new TypeError("Network request failed, timeout"));
},
onerror(err) {
reject(new TypeError("Failed to fetch: " + err.finalUrl));
},
});
});
}
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"];
// a ts type helper to narrow type
function includes(array, element) {
return array.includes(element);
}
function gmXHRMethod(method) {
if (includes(httpMethods, method)) {
return method;
}
throw new Error(`unsupported http method ${method}`);
}
//# sourceMappingURL=index.mjs.map
;// ./src/availability-service.ts
class AvailabilityService {
_availabilities = [];
_listeners = [];
_started = false;
start() {
if (this._started) {
return;
}
this._started = true;
this.fetch();
setInterval(() => this.fetch(), 30000);
}
subscribe(listener) {
this._listeners.push(listener);
if (this._availabilities.length > 0) {
listener(this._availabilities);
}
}
get availabilities() {
return this._availabilities;
}
async fetch() {
try {
var response = await GM_fetch(Config.tapi_server_url + '/availability');
if (response.status === 200) {
this._availabilities = await response.json();
this._listeners.forEach(l => l(this._availabilities));
}
}
catch (error) {
console.log(error);
}
}
}
;// ./node_modules/chrono-node/dist/esm/types.js ;// ./node_modules/chrono-node/dist/esm/types.js
var Meridiem; var Meridiem;
(function (Meridiem) { (function (Meridiem) {
@@ -4248,178 +4678,6 @@ function fireChangeEvents(element) {
console.debug('change event dispatched for element: ' + element.id); console.debug('change event dispatched for element: ' + element.id);
} }
;// ./node_modules/@trim21/gm-fetch/dist/index.mjs
function parseRawHeaders(h) {
const s = h.trim();
if (!s) {
return new Headers();
}
const array = s.split("\r\n").map((value) => {
let s = value.split(":");
return [s[0].trim(), s[1].trim()];
});
return new Headers(array);
}
function parseGMResponse(req, res) {
// workaround TamperMonkey bug(?) where sometimes response is string despite responseType being "blob"
const headers = parseRawHeaders(res.responseHeaders);
const body = typeof res.response === "string"
? new Blob([res.response], { type: headers.get("Content-Type") || "text/plain" })
: res.response;
return new ResImpl(body, {
statusCode: res.status,
statusText: res.statusText,
headers,
finalUrl: res.finalUrl,
redirected: res.finalUrl === req.url,
});
}
class ResImpl {
constructor(body, init) {
this.rawBody = body;
this.init = init;
this.body = body.stream();
const { headers, statusCode, statusText, finalUrl, redirected } = init;
this.headers = headers;
this.status = statusCode;
this.statusText = statusText;
this.url = finalUrl;
this.type = "basic";
this.redirected = redirected;
this._bodyUsed = false;
}
get bodyUsed() {
return this._bodyUsed;
}
get ok() {
return this.status < 300;
}
arrayBuffer() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'arrayBuffer' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.arrayBuffer();
}
blob() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'blob' on 'Response': body stream already read");
}
this._bodyUsed = true;
// `slice` will use empty string as default value, so need to pass all arguments.
return Promise.resolve(this.rawBody.slice(0, this.rawBody.size, this.rawBody.type));
}
clone() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'clone' on 'Response': body stream already read");
}
return new ResImpl(this.rawBody, this.init);
}
formData() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'formData' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text().then(decode);
}
async json() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'json' on 'Response': body stream already read");
}
this._bodyUsed = true;
return JSON.parse(await this.rawBody.text());
}
text() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'text' on 'Response': body stream already read");
}
this._bodyUsed = true;
return this.rawBody.text();
}
async bytes() {
if (this.bodyUsed) {
throw new TypeError("Failed to execute 'bytes' on 'Response': body stream already read");
}
this._bodyUsed = true;
return new Uint8Array(await this.rawBody.arrayBuffer());
}
}
function decode(body) {
const form = new FormData();
body
.trim()
.split("&")
.forEach(function (bytes) {
if (bytes) {
const split = bytes.split("=");
const name = split.shift()?.replace(/\+/g, " ");
const value = split.join("=").replace(/\+/g, " ");
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form;
}
async function GM_fetch(input, init) {
const request = new Request(input, init);
let data;
if (init?.body) {
data = await request.text();
}
return await XHR(request, init, data);
}
function XHR(request, init, data) {
return new Promise((resolve, reject) => {
if (request.signal && request.signal.aborted) {
return reject(new DOMException("Aborted", "AbortError"));
}
GM.xmlHttpRequest({
url: request.url,
method: gmXHRMethod(request.method.toUpperCase()),
headers: Object.fromEntries(new Headers(init?.headers).entries()),
data: data,
responseType: "blob",
onload(res) {
try {
resolve(parseGMResponse(request, res));
}
catch (e) {
reject(e);
}
},
onabort() {
reject(new DOMException("Aborted", "AbortError"));
},
ontimeout() {
reject(new TypeError("Network request failed, timeout"));
},
onerror(err) {
reject(new TypeError("Failed to fetch: " + err.finalUrl));
},
});
});
}
const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "TRACE", "OPTIONS", "CONNECT"];
// a ts type helper to narrow type
function includes(array, element) {
return array.includes(element);
}
function gmXHRMethod(method) {
if (includes(httpMethods, method)) {
return method;
}
throw new Error(`unsupported http method ${method}`);
}
//# sourceMappingURL=index.mjs.map
;// ./src/config.ts
class _Config {
tapi_server_url = 'https://3cxtapi.cp-austria.at';
}
const Config = new _Config();
;// ./src/call-history.ts ;// ./src/call-history.ts
@@ -4531,24 +4789,6 @@ class CallNotification {
} }
} }
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js
var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js");
var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js
var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js");
var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js
var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js");
var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js
var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js");
var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js
var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js");
var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform);
// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/presence.css // EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./src/presence.css
var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/presence.css"); var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/presence.css");
;// ./src/presence.css ;// ./src/presence.css
@@ -4563,15 +4803,15 @@ var presence = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./src/
var options = {}; var presence_options = {};
options.styleTagTransform = (styleTagTransform_default()); presence_options.styleTagTransform = (styleTagTransform_default());
options.setAttributes = (setAttributesWithoutAttributes_default()); presence_options.setAttributes = (setAttributesWithoutAttributes_default());
options.insert = insertBySelector_default().bind(null, "head"); presence_options.insert = insertBySelector_default().bind(null, "head");
options.domAPI = (styleDomAPI_default()); presence_options.domAPI = (styleDomAPI_default());
options.insertStyleElement = (insertStyleElement_default()); presence_options.insertStyleElement = (insertStyleElement_default());
var update = injectStylesIntoStyleTag_default()(presence/* default */.A, options); var presence_update = injectStylesIntoStyleTag_default()(presence/* default */.A, presence_options);
@@ -4864,32 +5104,44 @@ var status_update = injectStylesIntoStyleTag_default()(cjs_js_src_status/* defau
;// ./src/status.ts ;// ./src/status.ts
const zcIcon = __webpack_require__("./src/stopwatch-regular-full.svg"); const zcIcon = __webpack_require__("./src/stopwatch-regular-full.svg");
class Status { class Status {
_service;
_user; _user;
_enabled = false; _enabled = false;
_statusOn = 'menuAvailable'; _statusOn = 'menuAvailable';
_statusOff = 'menuAway'; _statusOff = 'menuAway';
_currentStatus = undefined; _currentStatus = undefined;
_subscribed = false;
constructor(service) {
this._service = service;
}
async showStatus(element) { async showStatus(element) {
this._user = await GM.getValue('tapi-zc-user', ''); this._user = await GM.getValue('tapi-zc-user', '');
this._enabled = await GM.getValue('tapi-zc-enabled', false); this._enabled = await GM.getValue('tapi-zc-enabled', false);
this._statusOn = await GM.getValue('tapi-zc-on', 'menuAvailable'); this._statusOn = await GM.getValue('tapi-zc-on', 'menuAvailable');
this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable'); this._statusOff = await GM.getValue('tapi-zc-off', 'menuAvailable');
console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff); console.log('tapi-zc-user', this._user, 'tapi-zc-enabled', this._enabled, 'tapi-zc-on', this._statusOn, 'tapi-zc-off', this._statusOff);
this.checkStatus(); this.subscribeOnce();
waitForKeyElements("wc-account-menu > div > ul", (element) => { this.addZcStatusPopup(element); }, true); waitForKeyElements("wc-account-menu > div > ul", (element) => { this.addZcStatusPopup(element); }, true);
} }
async checkStatus() { subscribeOnce() {
if (this._enabled) { if (this._subscribed) {
try { return;
var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user)); }
if (response.status == 200) { this._subscribed = true;
var status = await response.json(); this._service.subscribe((avs) => this.onAvailabilities(avs));
if (this._currentStatus !== status.loggedIn) { }
this._currentStatus = status.loggedIn; onAvailabilities(avs) {
if (!this._enabled || !this._user) {
return;
}
var entry = avs.find(a => a.user === this._user);
if (!entry) {
return;
}
if (this._currentStatus !== entry.loggedIn) {
this._currentStatus = entry.loggedIn;
console.log('New status, loggedIn', this._currentStatus); console.log('New status, loggedIn', this._currentStatus);
var accMenu = document.getElementsByTagName("wc-account-menu")[0]; var accMenu = document.getElementsByTagName("wc-account-menu")[0];
var avatar = accMenu.getElementsByTagName("app-avatar")[0]; var avatar = accMenu.getElementsByTagName("app-avatar")[0];
@@ -4901,13 +5153,6 @@ class Status {
}, 1000); }, 1000);
} }
} }
}
catch (error) {
console.log(error);
}
setTimeout(() => this.checkStatus(), 30000);
}
}
addZcStatusPopup(element) { addZcStatusPopup(element) {
var divider = document.createElement('li'); var divider = document.createElement('li');
divider.classList.add('divider'); divider.classList.add('divider');
@@ -4998,7 +5243,9 @@ class Status {
GM.setValue('tapi-zc-enabled', this._enabled); GM.setValue('tapi-zc-enabled', this._enabled);
console.log('tapi-zc-enabled', this._enabled); console.log('tapi-zc-enabled', this._enabled);
this._currentStatus = undefined; this._currentStatus = undefined;
this.checkStatus(); if (this._enabled) {
this.onAvailabilities(this._service.availabilities);
}
}; };
var zcOn = document.getElementById('tapi-zc-on'); var zcOn = document.getElementById('tapi-zc-on');
zcOn.value = this._statusOn; zcOn.value = this._statusOn;
@@ -5054,6 +5301,8 @@ class Status {
console.log('script start'); console.log('script start');
const src_search_0 = new Search(); const src_search_0 = new Search();
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
@@ -5075,7 +5324,9 @@ const callHistory = new CallHistory();
waitForKeyElements('call', element => { waitForKeyElements('call', element => {
callHistory.showCallHistory(element); callHistory.showCallHistory(element);
}, false); }, false);
const src_status_0 = new Status(); const availabilityService = new AvailabilityService();
availabilityService.start();
const src_status_0 = new Status(availabilityService);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
waitForKeyElements('wc-account-menu', element => { waitForKeyElements('wc-account-menu', element => {
src_status_0.showStatus(element); src_status_0.showStatus(element);
@@ -5083,6 +5334,8 @@ waitForKeyElements('wc-account-menu', element => {
waitForKeyElements('wc-account-menu i.status-indicator', element => { waitForKeyElements('wc-account-menu i.status-indicator', element => {
src_status_0.watchStatus(element); src_status_0.watchStatus(element);
}, false); }, false);
const src_availability_0 = new Availability(availabilityService);
src_availability_0.start();
})(); })();
/******/ })() /******/ })()
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "3cx-tapi", "name": "3cx-tapi",
"version": "9.3.0", "version": "9.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "3cx-tapi", "name": "3cx-tapi",
"version": "9.3.0", "version": "9.6.0",
"dependencies": { "dependencies": {
"@trim21/gm-fetch": "^0.3.0", "@trim21/gm-fetch": "^0.3.0",
"chrono-node": "^2.9.0" "chrono-node": "^2.9.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "3cx-tapi", "name": "3cx-tapi",
"description": "3CX CP Tapi and Projectmanager integration", "description": "3CX CP Tapi and Projectmanager integration",
"version": "9.5.1", "version": "9.6.1",
"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()
+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.toLowerCase() === this._user.toLowerCase());
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;
}
+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.5.1</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.5.1</Version> <Version>9.6.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -30,4 +30,8 @@
<Folder Include="wwwroot\" /> <Folder Include="wwwroot\" />
</ItemGroup> </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);
} }
} }
+1 -1
View File
@@ -22,6 +22,7 @@ COPY ["server/src/CPATapi.Server/CPATapi.Server.csproj", "server/src/CPATapi.Ser
RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj" RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj"
COPY . . COPY . .
WORKDIR "/src/server/src/CPATapi.Server" WORKDIR "/src/server/src/CPATapi.Server"
COPY --from=build-userscript ["/src/dist/3CX TAPI.prod.user.js", "./wwwroot/3CX_TAPI.user.js"]
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
@@ -33,5 +34,4 @@ 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; }
}
@@ -2,9 +2,9 @@ namespace CPATapi.Server.Models;
public class TapiContact public class TapiContact
{ {
public string TD_ID { get; set; } public required string TD_ID { get; set; }
public string TD_NAME { get; set; } public required string TD_NAME { get; set; }
public string TD_NUMBER { get; set; } public required string TD_NUMBER { get; set; }
public string TD_NUMBER_TAPI { get; set; } public required string TD_NUMBER_TAPI { get; set; }
public string TD_MEDIUM { get; set; } public required string TD_MEDIUM { get; set; }
} }
@@ -6,29 +6,46 @@ 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
AND us.US_ACTIVE = 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,18 @@
using Microsoft.Extensions.Configuration;
namespace CPATapi.Server.Tests;
public class RepositoryTestsBase
{
[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<RepositoryTestsBase>();
Configuration = builder.Build();
}
protected IConfigurationRoot Configuration { get; private set; }
}
@@ -0,0 +1,15 @@
using CPATapi.Server.Repository;
namespace CPATapi.Server.Tests;
public class TapiDirectoryRepositoryTests : RepositoryTestsBase
{
[Test]
public async Task TestGetAllAsync()
{
var tdRepo = new TapiDirectoryRepository(Configuration);
var contacts = (await tdRepo.GetAllAsync()).ToList();
Assert.That(contacts, Is.Not.Empty);
Assert.That(contacts, Has.Count.GreaterThan(1));
}
}
@@ -0,0 +1,25 @@
using CPATapi.Server.Repository;
namespace CPATapi.Server.Tests;
public class ZeitConsensRepositoryTest : RepositoryTestsBase
{
[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"));
}
}