20 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
CPATRD ccf1f63f1b Bump Version to 9.5.1 2026-04-10 12:06:16 +02:00
CPATRD 8459f7938d Move userscript to server 2026-04-10 12:02:03 +02:00
CPAMAP e6860461ee Extract presence module and add SVG status icons
Move quick status buttons into dedicated presence.ts/css module with
Font Awesome SVG icons (briefcase, headphones, grill, beer mug,
screen-users). Bump version to 9.5.0.
2026-04-10 11:42:31 +02:00
CPAMAP 16d095ca77 Claude gelernt wie man baut 2026-04-10 11:34:45 +02:00
CPAMAP fd8976fedc Claude.md für alle drinnen 2026-04-10 11:09:46 +02:00
44 changed files with 1433 additions and 327 deletions
+3
View File
@@ -0,0 +1,3 @@
*Dockerfile*
*docker-compose*
node_modules
+671 -221
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -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.
+1 -1
View File
@@ -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/*'
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "3cx-tapi",
"description": "3CX CP Tapi and Projectmanager integration",
"version": "9.4.0",
"version": "9.6.1",
"author": {
"name": "Daniel Triendl",
"email": "d.triendl@cp-solutions.at"
+2 -6
View File
@@ -38,15 +38,11 @@ just install a package and import it in your js file. webpack will pack them wit
npm run build
```
`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
+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')
}
}
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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()
+27
View File
@@ -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;
}
+81
View File
@@ -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

+7 -1
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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.toLowerCase() === this._user.toLowerCase());
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;
-4
View File
@@ -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!
+6
View File
@@ -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&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
@@ -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 -1
View File
@@ -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.1</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);
}
}
+12 -3
View File
@@ -6,14 +6,23 @@ 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"
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
# This stage is used to publish the service project to be copied to the final stage
@@ -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; }
}
@@ -2,9 +2,9 @@ namespace CPATapi.Server.Models;
public class TapiContact
{
public string TD_ID { get; set; }
public string TD_NAME { get; set; }
public string TD_NUMBER { get; set; }
public string TD_NUMBER_TAPI { get; set; }
public string TD_MEDIUM { get; set; }
public required string TD_ID { get; set; }
public required string TD_NAME { get; set; }
public required string TD_NUMBER { get; set; }
public required string TD_NUMBER_TAPI { get; set; }
public required string TD_MEDIUM { get; set; }
}
+1
View File
@@ -30,6 +30,7 @@ if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
}
app.MapStaticAssets();
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
@@ -6,29 +6,46 @@ 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
AND us.US_ACTIVE = 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,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"));
}
}