Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21683f5e8b | |||
| 2874ea78c4 | |||
| d5558b61b2 | |||
| b7a952ee81 | |||
| 523477ffba | |||
| 74992a020f | |||
| 235b7cba18 | |||
| 18c6f6f10e | |||
| 02c8e0ea3c | |||
| ccf1f63f1b | |||
| 8459f7938d | |||
| e6860461ee | |||
| 16d095ca77 | |||
| fd8976fedc |
@@ -0,0 +1,3 @@
|
||||
*Dockerfile*
|
||||
*docker-compose*
|
||||
node_modules
|
||||
+671
-221
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
3CX TAPI is a dual-component integration that bridges the 3CX VoIP telephony web client with a CPA project management suite (CP Solutions) and a time tracking system (ZeitConsens). It is deployed as a Tampermonkey UserScript (client) communicating with a self-hosted ASP.NET Core API (server).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── client/ # TypeScript UserScript (Tampermonkey)
|
||||
├── server/ # C# ASP.NET Core 10.0 REST API
|
||||
└── 3CX_TAPI.user.js # Built distribution file (committed artifact)
|
||||
```
|
||||
|
||||
## Client Commands
|
||||
|
||||
All commands run from `client/`:
|
||||
|
||||
```bash
|
||||
npm run build # Production webpack build → dist/3CX TAPI.prod.user.js
|
||||
npm run dev # Development build with live reload
|
||||
npm run lint # ESLint on TypeScript/JavaScript sources
|
||||
npm run anylize # Build with webpack-bundle-analyzer
|
||||
```
|
||||
|
||||
## Versioning & Release
|
||||
|
||||
**Never edit these files manually — they are auto-generated:**
|
||||
- `client/package-lock.json` (generated by `npm install`)
|
||||
- `3CX_TAPI.user.js` (generated by build)
|
||||
|
||||
To release a new version:
|
||||
1. Bump `version` in `client/package.json` (1st digit = major, 2nd = feature, 3rd = bugfix/minor)
|
||||
2. Build: `npm run build` (from `client/`)
|
||||
3. Copy: `cp "dist/3CX TAPI.prod.user.js" ../3CX_TAPI.user.js`
|
||||
|
||||
## Server Commands
|
||||
|
||||
All commands run from `server/src/CPATapi.Server/`:
|
||||
|
||||
```bash
|
||||
dotnet build # Debug build
|
||||
dotnet build -c Release # Release build
|
||||
dotnet publish -c Release -o /app/publish # Publish for deployment
|
||||
docker build -f Dockerfile -t cpatapi-server . # Docker image
|
||||
```
|
||||
|
||||
No automated tests exist — only manual testing and client-side linting.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client (`client/src/`)
|
||||
|
||||
A Tampermonkey UserScript injected into the 3CX web UI at `https://192.168.0.154:5001/*` and `https://cpsolution.my3cx.at:5001/*`. Entry point: `src/index.js`.
|
||||
|
||||
Four main modules:
|
||||
|
||||
- **`search.ts`** — Autocomplete contact lookup box injected near the call button. Debounces input and calls `GET /search?query=...`. On selection, dials the contact via 3CX.
|
||||
- **`call-history.ts`** — Monitors 3CX call history entries, does reverse lookup via `GET /callerid/{number}`, and adds a "PM Zeitbuchung" button that opens the Domizil PM time booking system with pre-filled metadata (contact ID, timestamp, duration).
|
||||
- **`call-notification.ts`** — Enriches incoming call GM notifications with contact name/medium fetched from TAPI directory.
|
||||
- **`status.ts`** — Polls `GET /availability/{user}` every 30 seconds and auto-syncs 3CX presence status with ZeitConsens login state (odd stamp count = logged in, even = logged out). User settings (username, enabled, status IDs) persist via `GM.setValue`.
|
||||
|
||||
API base URL is configured in `src/config.ts` (`Config.tapi_server_url`).
|
||||
UserScript metadata (match URLs, GM grants, CORS) is in `config/metadata.cjs`.
|
||||
|
||||
### Server (`server/src/CPATapi.Server/`)
|
||||
|
||||
ASP.NET Core 10.0 Web API using Dapper for SQL Server queries. Deployed as a Docker Linux container exposing port 8080.
|
||||
|
||||
**API Endpoints:**
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /search?query=` | Splits query into terms, searches `CP_TAPI_DIRECTORY` by name or number (top 10) |
|
||||
| `GET /contact` | Returns all contacts from `CP_TAPI_DIRECTORY` |
|
||||
| `GET /callerid/{number}` | Reverse lookup: extracts last 5 digits, returns first matching contact or 404 |
|
||||
| `GET /availability/users` | Lists active users from `MA_DATEN` |
|
||||
| `GET /availability/{user}` | Counts today's stamps in `BU` table; odd count = logged in |
|
||||
|
||||
**Data flow:** Controllers → Repository interfaces → Dapper repositories → SQL Server.
|
||||
|
||||
Two connection strings in `appsettings.json`:
|
||||
- `"Tapi"` — contacts database (`CP_TAPI_DIRECTORY` table)
|
||||
- `"ZeitConsens"` — time tracking database (`MA_DATEN`, `BU` tables)
|
||||
|
||||
Logging uses Serilog with client IP, correlation ID, and user-agent enrichment.
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
author: pkg.author,
|
||||
copyright: 'Copyright CP Solutions GmbH',
|
||||
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: [
|
||||
'https://192.168.0.154:5001/*',
|
||||
'https://cpsolution.my3cx.at:5001/*'
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "3cx-tapi",
|
||||
"version": "9.3.0",
|
||||
"version": "9.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "3cx-tapi",
|
||||
"version": "9.3.0",
|
||||
"version": "9.6.0",
|
||||
"dependencies": {
|
||||
"@trim21/gm-fetch": "^0.3.0",
|
||||
"chrono-node": "^2.9.0"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "3cx-tapi",
|
||||
"description": "3CX CP Tapi and Projectmanager integration",
|
||||
"version": "9.4.0",
|
||||
"version": "9.6.0",
|
||||
"author": {
|
||||
"name": "Daniel Triendl",
|
||||
"email": "d.triendl@cp-solutions.at"
|
||||
|
||||
+2
-6
@@ -38,15 +38,11 @@ just install a package and import it in your js file. webpack will pack them wit
|
||||
npm run build
|
||||
```
|
||||
|
||||
`dist/index.prod.user.js` is the final script.
|
||||
`dist/3CX TAPI.prod.user.js` is the final script.
|
||||
|
||||
## distribution
|
||||
|
||||
```
|
||||
cp "dist/3CX TAPI.prod.user.js" ../3CX_TAPI.user.js
|
||||
```
|
||||
|
||||
And commit 3CX_TAPI.user.js
|
||||
Userscript is included in server docker image
|
||||
|
||||
## see also
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class AvailabilityInfo {
|
||||
public user: string;
|
||||
public loggedIn: boolean;
|
||||
public extension: string;
|
||||
public lastStamp: Date;
|
||||
public atCompany: boolean;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Pro 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.--><path d="M432 144L432 415.5C432 415.8 432 416.2 432 416.5L432 448C432 474.5 410.5 496 384 496L192 496C165.5 496 144 474.5 144 448L144 144L432 144zM480 448L480 430.8L577 382.3C596 372.8 608 353.4 608 332.2L608 216C608 185.1 582.9 160 552 160L480 160L480 144C480 117.5 458.5 96 432 96L144 96C117.5 96 96 117.5 96 144L96 448C96 501 139 544 192 544L384 544C437 544 480 501 480 448zM555.6 339.4L480 377.2L480 208L552 208C556.4 208 560 211.6 560 216L560 332.2C560 335.2 558.3 338 555.6 339.4zM212 192C201 192 192 201 192 212L192 428C192 439 201 448 212 448C223 448 232 439 232 428L232 212C232 201 223 192 212 192zM288 192C277 192 268 201 268 212L268 428C268 439 277 448 288 448C299 448 308 439 308 428L308 212C308 201 299 192 288 192zM384 212C384 201 375 192 364 192C353 192 344 201 344 212L344 428C344 439 353 448 364 448C375 448 384 439 384 428L384 212z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Pro 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.--><path d="M256 120L256 160L384 160L384 120C384 115.6 380.4 112 376 112L264 112C259.6 112 256 115.6 256 120zM208 160L208 120C208 89.1 233.1 64 264 64L376 64C406.9 64 432 89.1 432 120L432 160L512 160C547.3 160 576 188.7 576 224L576 480C576 515.3 547.3 544 512 544L128 544C92.7 544 64 515.3 64 480L64 224C64 188.7 92.7 160 128 160L208 160zM112 368L112 480C112 488.8 119.2 496 128 496L512 496C520.8 496 528 488.8 528 480L528 368L384 368L384 384C384 401.7 369.7 416 352 416L288 416C270.3 416 256 401.7 256 384L256 368L112 368zM256 320L528 320L528 224C528 215.2 520.8 208 512 208L128 208C119.2 208 112 215.2 112 224L112 320L256 320z"/></svg>
|
||||
|
After Width: | Height: | Size: 862 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Pro 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.--><path d="M200 0C186.7 0 176 10.7 176 24C176 45.7 184.6 66.6 200 81.9L230.1 112C236.5 118.4 240 127 240 136C240 149.3 250.7 160 264 160C277.3 160 288 149.3 288 136C288 114.3 279.4 93.4 264 78.1L233.9 48C227.5 41.6 224 33 224 24C224 10.7 213.3 0 200 0zM145.1 256L495 256C486.3 328.1 424.8 384 350.3 384L289.8 384C215.3 384 153.9 328.1 145.1 256zM126.3 208C109.6 208 96 221.6 96 238.3C96 319 145.4 388.2 215.6 417.3L202.1 448.8C198.8 448.3 195.4 448 192 448C156.7 448 128 476.7 128 512C128 547.3 156.7 576 192 576C227.3 576 256 547.3 256 512L412.7 512L433.9 561.5C439.1 573.7 453.2 579.3 465.4 574.1C477.6 568.9 483.2 554.8 478 542.6L424.4 417.3C494.6 388.2 544 319 544 238.3C544 221.6 530.4 208 513.7 208L126.3 208zM392.2 464L247.8 464L262.3 430.1C271.2 431.4 280.4 432 289.7 432L350.2 432C359.5 432 368.6 431.3 377.6 430.1L392.2 464zM192 488C205.3 488 216 498.7 216 512C216 525.3 205.3 536 192 536C178.7 536 168 525.3 168 512C168 498.7 178.7 488 192 488zM336 24C336 10.7 325.3 0 312 0C298.7 0 288 10.7 288 24C288 45.7 296.6 66.6 312 81.9L342.1 112C348.5 118.4 352 127 352 136C352 149.3 362.7 160 376 160C389.3 160 400 149.3 400 136C400 114.3 391.4 93.4 376 78.1L345.9 48C339.5 41.6 336 33 336 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M144 288C144 190.8 222.8 112 320 112C417.2 112 496 190.8 496 288L496 332.8C481.9 324.6 465.5 320 448 320L432 320C405.5 320 384 341.5 384 368L384 496C384 522.5 405.5 544 432 544L448 544C501 544 544 501 544 448L544 288C544 164.3 443.7 64 320 64C196.3 64 96 164.3 96 288L96 448C96 501 139 544 192 544L208 544C234.5 544 256 522.5 256 496L256 368C256 341.5 234.5 320 208 320L192 320C174.5 320 158.1 324.7 144 332.8L144 288zM144 416C144 389.5 165.5 368 192 368L208 368L208 496L192 496C165.5 496 144 474.5 144 448L144 416zM496 416L496 448C496 474.5 474.5 496 448 496L432 496L432 368L448 368C474.5 368 496 389.5 496 416z"/></svg>
|
||||
|
After Width: | Height: | Size: 843 B |
+14
-1
@@ -1,7 +1,10 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import * as chrono from 'chrono-node'
|
||||
import { Availability } from './availability'
|
||||
import { AvailabilityService } from './availability-service'
|
||||
import { CallHistory } from './call-history'
|
||||
import { CallNotification } from './call-notification'
|
||||
import { Presence } from './presence'
|
||||
import { Search } from './search'
|
||||
import { Status } from './status'
|
||||
|
||||
@@ -11,6 +14,10 @@ const search = new Search()
|
||||
// eslint-disable-next-line no-undef
|
||||
waitForKeyElements('ongoing-call-button', (element) => { search.createSearchWindow(element) }, false)
|
||||
|
||||
const presence = new Presence()
|
||||
// eslint-disable-next-line no-undef
|
||||
waitForKeyElements('#tapiForm', (element) => { presence.createButtons(element) }, true)
|
||||
|
||||
const callNotification = new CallNotification()
|
||||
// eslint-disable-next-line no-undef
|
||||
waitForKeyElements('call-view', (element) => { callNotification.showCallNotification(element) }, false)
|
||||
@@ -19,8 +26,14 @@ const callHistory = new CallHistory()
|
||||
// eslint-disable-next-line no-undef
|
||||
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
|
||||
waitForKeyElements('wc-account-menu', (element) => { status.showStatus(element) }, false)
|
||||
|
||||
waitForKeyElements('wc-account-menu i.status-indicator', (element) => { status.watchStatus(element) }, false)
|
||||
|
||||
const availability = new Availability(availabilityService)
|
||||
availability.start()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.tapi-quick-btn {
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tapi-quick-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.tapi-btn-away {
|
||||
background-color: #f0ad4e;
|
||||
}
|
||||
|
||||
.tapi-btn-dnd {
|
||||
background-color: #d9534f;
|
||||
}
|
||||
|
||||
.tapi-btn-available {
|
||||
background-color: #5cb85c;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import './presence.css'
|
||||
import { fireChangeEvents } from './utils'
|
||||
|
||||
const iconArbeiten = require('./briefcase-regular-full.svg')
|
||||
const iconBesprechung = require('./screen-users-sharp-regular-full.svg')
|
||||
const iconFokus = require('./headphones-regular-full.svg')
|
||||
const iconMittag = require('./grill-hot-regular-full.svg')
|
||||
const iconFeierabend = require('./beer-mug-empty-regular-full.svg')
|
||||
|
||||
const QUICK_BUTTONS = [
|
||||
{ icon: iconArbeiten, menuId: 'menuCustom1', message: '', css: 'tapi-btn-available', title: 'Arbeiten' },
|
||||
{ icon: iconBesprechung, menuId: 'menuOutofoffice', message: 'Besprechung', css: 'tapi-btn-dnd', title: 'Besprechung' },
|
||||
{ icon: iconFokus, menuId: 'menuOutofoffice', message: 'Fokus', css: 'tapi-btn-dnd', title: 'Fokus' },
|
||||
{ icon: iconMittag, menuId: 'menuAway', message: 'Mittag', css: 'tapi-btn-away', title: 'Mittag' },
|
||||
{ icon: iconFeierabend, menuId: 'menuAway', message: 'Feierabend', css: 'tapi-btn-away', title: 'Feierabend' },
|
||||
]
|
||||
|
||||
export class Presence {
|
||||
public createButtons (element: HTMLElement) {
|
||||
console.log('Create TAPI Presence')
|
||||
|
||||
var form = document.getElementById('tapiForm')
|
||||
var searchBox = document.getElementById('tapiSearchBox')
|
||||
|
||||
QUICK_BUTTONS.forEach(btn => {
|
||||
var button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.innerHTML = btn.icon
|
||||
button.classList.add('tapi-quick-btn')
|
||||
button.classList.add(btn.css)
|
||||
button.title = btn.title
|
||||
button.onclick = () => { this.setStatus(btn.menuId, btn.message) }
|
||||
form.insertBefore(button, searchBox)
|
||||
})
|
||||
}
|
||||
|
||||
private delay (ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
private async setStatus (menuId: string, message: string) {
|
||||
var accMenu = document.getElementsByTagName('wc-account-menu')[0]
|
||||
var avatar = accMenu.getElementsByTagName('app-avatar')[0] as HTMLAnchorElement
|
||||
|
||||
avatar.click()
|
||||
await this.delay(1000)
|
||||
|
||||
if (message !== '') {
|
||||
var pencilBtn = document.getElementById(menuId + 'SetStatus') as HTMLElement
|
||||
if (pencilBtn) {
|
||||
pencilBtn.click()
|
||||
await this.delay(500)
|
||||
|
||||
var modalInput = document.querySelector('input[data-qa="input"][maxlength="128"]') as HTMLInputElement
|
||||
if (modalInput) {
|
||||
modalInput.value = message
|
||||
fireChangeEvents(modalInput)
|
||||
await this.delay(300)
|
||||
|
||||
var okBtn = Array.from(document.querySelectorAll('button')).find(btn =>
|
||||
btn.textContent && btn.textContent.trim() === 'OK' && btn.getBoundingClientRect().width > 0
|
||||
) as HTMLButtonElement
|
||||
if (okBtn) {
|
||||
okBtn.click()
|
||||
await this.delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var statusItem = document.getElementById(menuId) as HTMLElement
|
||||
if (!statusItem || statusItem.getBoundingClientRect().width === 0) {
|
||||
avatar.click()
|
||||
await this.delay(1000)
|
||||
statusItem = document.getElementById(menuId) as HTMLElement
|
||||
}
|
||||
if (statusItem) {
|
||||
statusItem.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Pro 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.--><path d="M544 272C555.1 272 565.9 273.6 576 276.7L576 64L64 64L64 276.7C74.1 273.7 84.9 272 96 272C101.4 272 106.8 272.4 112 273.1L112 112L528 112L528 273.1C533.2 272.4 538.6 272 544 272zM160 384C160 348.7 131.3 320 96 320C60.7 320 32 348.7 32 384C32 419.3 60.7 448 96 448C131.3 448 160 419.3 160 384zM160 480L32 480L0 576L192 576L160 480zM384 384C384 348.7 355.3 320 320 320C284.7 320 256 348.7 256 384C256 419.3 284.7 448 320 448C355.3 448 384 419.3 384 384zM384 480L256 480L224 576L416 576L384 480zM544 448C579.3 448 608 419.3 608 384C608 348.7 579.3 320 544 320C508.7 320 480 348.7 480 384C480 419.3 508.7 448 544 448zM640 576L608 480L480 480L448 576L640 576z"/></svg>
|
||||
|
After Width: | Height: | Size: 900 B |
@@ -1,8 +1,14 @@
|
||||
.tapi-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.tapi-search-autocomplete {
|
||||
/*the container must be positioned relative:*/
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.tapi-search-autocomplete input {
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -12,6 +12,8 @@ export class Search {
|
||||
console.log('Create TAPI Search')
|
||||
|
||||
var form = document.createElement('form')
|
||||
form.id = 'tapiForm'
|
||||
form.classList.add('tapi-form')
|
||||
form.onsubmit = () => {
|
||||
var items = document.getElementsByClassName('tapi-search-autocomplete-active')
|
||||
if (items.length === 0) {
|
||||
|
||||
+30
-19
@@ -1,17 +1,22 @@
|
||||
import { Config } from './config';
|
||||
import './status.css';
|
||||
import { ZcStatus } from './zc-status';
|
||||
import GM_fetch from "@trim21/gm-fetch";
|
||||
import { AvailabilityInfo } from './availability-info';
|
||||
import { AvailabilityService } from './availability-service';
|
||||
const zcIcon = require('./stopwatch-regular-full.svg');
|
||||
|
||||
declare function waitForKeyElements(selectorOrFunction: any, callback: any, waitOnce: boolean): any;
|
||||
|
||||
export class Status {
|
||||
private _service: AvailabilityService;
|
||||
private _user: string;
|
||||
private _enabled = false;
|
||||
private _statusOn = 'menuAvailable';
|
||||
private _statusOff = 'menuAway';
|
||||
private _currentStatus: boolean = undefined;
|
||||
private _subscribed = false;
|
||||
|
||||
constructor(service: AvailabilityService) {
|
||||
this._service = service;
|
||||
}
|
||||
|
||||
public async showStatus(element: HTMLElement) {
|
||||
this._user = await GM.getValue('tapi-zc-user', '');
|
||||
@@ -20,19 +25,29 @@ export class Status {
|
||||
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);
|
||||
|
||||
this.checkStatus();
|
||||
this.subscribeOnce();
|
||||
|
||||
waitForKeyElements("wc-account-menu > div > ul", (element: HTMLElement) => { this.addZcStatusPopup(element) }, true);
|
||||
}
|
||||
|
||||
private async checkStatus() {
|
||||
if (this._enabled) {
|
||||
try {
|
||||
var response = await GM_fetch(Config.tapi_server_url + '/availability/' + encodeURIComponent(this._user));
|
||||
if (response.status == 200) {
|
||||
var status = await response.json() as ZcStatus;
|
||||
if (this._currentStatus !== status.loggedIn) {
|
||||
this._currentStatus = status.loggedIn;
|
||||
private subscribeOnce() {
|
||||
if (this._subscribed) {
|
||||
return;
|
||||
}
|
||||
this._subscribed = true;
|
||||
this._service.subscribe((avs) => this.onAvailabilities(avs));
|
||||
}
|
||||
|
||||
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);
|
||||
var accMenu = document.getElementsByTagName("wc-account-menu")[0];
|
||||
var avatar = accMenu.getElementsByTagName("app-avatar")[0] as HTMLAnchorElement;
|
||||
@@ -44,12 +59,6 @@ export class Status {
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
setTimeout(() => this.checkStatus(), 30000);
|
||||
}
|
||||
}
|
||||
|
||||
private addZcStatusPopup(element: HTMLElement) {
|
||||
var divider = document.createElement('li');
|
||||
@@ -149,7 +158,9 @@ export class Status {
|
||||
GM.setValue('tapi-zc-enabled', this._enabled);
|
||||
console.log('tapi-zc-enabled', this._enabled);
|
||||
this._currentStatus = undefined;
|
||||
this.checkStatus();
|
||||
if (this._enabled) {
|
||||
this.onAvailabilities(this._service.availabilities);
|
||||
}
|
||||
}
|
||||
|
||||
var zcOn = document.getElementById('tapi-zc-on') as HTMLSelectElement;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export class ZcStatus {
|
||||
public user: string;
|
||||
public loggedIn: boolean;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
setlocal
|
||||
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 (
|
||||
echo.
|
||||
echo ERROR: Docker build failed!
|
||||
@@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Client", "src\CPATa
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CPATapi.Client.Tests", "test\CPATapi.Client.Tests\CPATapi.Client.Tests.csproj", "{17F37791-4F68-46D5-8CF5-5F1736F6776E}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// <auto-generated/>
|
||||
#pragma warning disable CS0618
|
||||
using CPATapi.Client.Availability.Item;
|
||||
using CPATapi.Client.Availability.Users;
|
||||
using CPATapi.Client.Models;
|
||||
using Microsoft.Kiota.Abstractions.Extensions;
|
||||
using Microsoft.Kiota.Abstractions.Serialization;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System;
|
||||
namespace CPATapi.Client.Availability
|
||||
{
|
||||
@@ -16,11 +18,6 @@ namespace CPATapi.Client.Availability
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
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>
|
||||
/// <param name="position">Unique identifier of the item</param>
|
||||
/// <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)
|
||||
{
|
||||
}
|
||||
/// <returns>A List<global::CPATapi.Client.Models.Availability></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
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace CPATapi.Client.Availability.Item
|
||||
/// <returns>A <see cref="global::CPATapi.Client.Models.Availability"/></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>
|
||||
/// <exception cref="global::CPATapi.Client.Models.ProblemDetails">When receiving a 404 status code</exception>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
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
|
||||
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>
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<PackageId>CPATapi.Client</PackageId>
|
||||
<Authors>Daniel Triendl</Authors>
|
||||
<Company>CP Solutions GmbH</Company>
|
||||
<Version>9.4.0</Version>
|
||||
<Version>9.6.0</Version>
|
||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||
</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>
|
||||
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>
|
||||
public bool? LoggedIn { get; set; }
|
||||
/// <summary>The user property</summary>
|
||||
@@ -49,6 +61,9 @@ namespace CPATapi.Client.Models
|
||||
{
|
||||
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(); } },
|
||||
{ "user", n => { User = n.GetStringValue(); } },
|
||||
};
|
||||
@@ -60,6 +75,9 @@ namespace CPATapi.Client.Models
|
||||
public virtual void Serialize(ISerializationWriter 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.WriteStringValue("user", User);
|
||||
writer.WriteAdditionalData(AdditionalData);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"descriptionHash": "1B47F98D82C5E24FB3ABEDB3BF90615424A8F19620EA2621D23B7FC69F4F7BF27D512A11098EF4D8940C7D243DEEB59C0CA038264430AD9150B612F5046C8146",
|
||||
"descriptionHash": "DEDF4FD053830F062E3DE69918EF38C90DCD76DC47BB854DDA337BB592E2A34DFAAAC5F0C23D40481BD2883833366E7AAC243D825DBCCE2897E4E6A78B890BE9",
|
||||
"descriptionLocation": "../CPATapi.Server/CPATapi.Server.json",
|
||||
"lockFileVersion": "1.0.0",
|
||||
"kiotaVersion": "1.30.0",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerfileContext>..\..</DockerfileContext>
|
||||
<OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory>
|
||||
<Version>9.4.0</Version>
|
||||
<Version>9.6.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,4 +26,12 @@
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="CPATapi.Server.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -11,19 +11,24 @@ namespace CPATapi.Server.Controllers;
|
||||
public class AvailabilityController(IZeitConsensRepository zeitConsens) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Route("users")]
|
||||
[ProducesResponseType<IEnumerable<string>>(StatusCodes.Status200OK)]
|
||||
[Route("")]
|
||||
[ProducesResponseType<IEnumerable<Availability>>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetUsers()
|
||||
{
|
||||
return Ok(await zeitConsens.GetUsersAsync());
|
||||
return Ok(await zeitConsens.GetUsersAvailabilityAsync(DateTime.Now.Date, DateTime.Now.AddDays(1).Date));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("{user}")]
|
||||
[ProducesResponseType<Availability>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAvailability(string user)
|
||||
{
|
||||
var stampCount = (await zeitConsens.GetStampsAsync(user, DateTime.Now.Date, DateTime.Now.AddDays(1).Date)).Count();
|
||||
return Ok(new Availability { User = user, LoggedIn = stampCount % 2 != 0 });
|
||||
var availability = await zeitConsens.GetUserAvailabilityAsync(user, DateTime.Now.Date, DateTime.Now.AddDays(1).Date);
|
||||
if (availability == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(availability);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,22 @@ USER $APP_UID
|
||||
WORKDIR /app
|
||||
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
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["CPATapi.Server/CPATapi.Server.csproj", "CPATapi.Server/"]
|
||||
RUN dotnet restore "CPATapi.Server/CPATapi.Server.csproj"
|
||||
COPY ["server/src/CPATapi.Server/CPATapi.Server.csproj", "server/src/CPATapi.Server/CPATapi.Server.csproj"]
|
||||
RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/CPATapi.Server"
|
||||
WORKDIR "/src/server/src/CPATapi.Server"
|
||||
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
|
||||
@@ -25,4 +33,5 @@ RUN dotnet publish "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/pub
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
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"]
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace CPATapi.Server.Interfaces;
|
||||
|
||||
public interface IZeitConsensRepository : IRepository
|
||||
{
|
||||
Task<IEnumerable<string>> GetUsersAsync();
|
||||
Task<IEnumerable<Stamp>> GetStampsAsync(string user, DateTime from, DateTime to);
|
||||
Task<IEnumerable<Availability>> GetUsersAvailabilityAsync(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;
|
||||
|
||||
namespace CPATapi.Server.Models;
|
||||
@@ -5,7 +6,13 @@ namespace CPATapi.Server.Models;
|
||||
public class Availability
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
public string User { get; set; }
|
||||
public required string MA_USER_NAME { get; set; }
|
||||
[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; }
|
||||
}
|
||||
@@ -30,6 +30,7 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapOpenApi();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
|
||||
@@ -6,29 +6,45 @@ namespace CPATapi.Server.Repository;
|
||||
|
||||
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");
|
||||
return await con.QueryAsync<string>("""
|
||||
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
|
||||
""");
|
||||
return await con.QueryAsync<Availability>(SelectStampsQuery, new { from, to });
|
||||
}
|
||||
|
||||
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");
|
||||
return await con.QueryAsync<Stamp>("""
|
||||
SELECT
|
||||
MA_NR
|
||||
,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
|
||||
return await con.QueryFirstOrDefaultAsync<Availability>($"""
|
||||
{SelectStampsQuery}
|
||||
AND ma.MA_USER_NAME = @user
|
||||
""", new { user, from, to });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using CPATapi.Client.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CPATapi.Client.Tests;
|
||||
@@ -39,7 +40,17 @@ public class CPATapiClientTests
|
||||
{
|
||||
using var scope = CreateServices();
|
||||
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.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user