diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f551917
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,85 @@
+# 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
+```
+
+After building, copy the output to the repo root:
+```bash
+cp "client/dist/3CX TAPI.prod.user.js" 3CX_TAPI.user.js
+```
+
+The root `3CX_TAPI.user.js` is the committed distribution artifact installed via Tampermonkey.
+
+## 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.
diff --git a/client/package-lock.json b/client/package-lock.json
index 3f44565..8ca2d7d 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "3cx-tapi",
- "version": "9.3.0",
+ "version": "9.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "3cx-tapi",
- "version": "9.3.0",
+ "version": "9.3.1",
"dependencies": {
"@trim21/gm-fetch": "^0.3.0",
"chrono-node": "^2.9.0"
diff --git a/client/src/beer-mug-empty-regular-full.svg b/client/src/beer-mug-empty-regular-full.svg
new file mode 100644
index 0000000..356fef7
--- /dev/null
+++ b/client/src/beer-mug-empty-regular-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/briefcase-regular-full.svg b/client/src/briefcase-regular-full.svg
new file mode 100644
index 0000000..fcb4c8d
--- /dev/null
+++ b/client/src/briefcase-regular-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/grill-hot-regular-full.svg b/client/src/grill-hot-regular-full.svg
new file mode 100644
index 0000000..023a665
--- /dev/null
+++ b/client/src/grill-hot-regular-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/headphones-regular-full.svg b/client/src/headphones-regular-full.svg
new file mode 100644
index 0000000..fa78716
--- /dev/null
+++ b/client/src/headphones-regular-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/screen-users-sharp-regular-full.svg b/client/src/screen-users-sharp-regular-full.svg
new file mode 100644
index 0000000..24d0ed8
--- /dev/null
+++ b/client/src/screen-users-sharp-regular-full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/search.css b/client/src/search.css
index 7c27081..d8956f3 100644
--- a/client/src/search.css
+++ b/client/src/search.css
@@ -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;
@@ -40,3 +46,31 @@
/*when hovering an item:*/
background-color: #E7E6E6 !important;
}
+
+.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;
+}
diff --git a/client/src/search.ts b/client/src/search.ts
index bca3ff7..552da54 100644
--- a/client/src/search.ts
+++ b/client/src/search.ts
@@ -5,6 +5,20 @@ import { fireChangeEvents } from './utils'
import GM_fetch from '@trim21/gm-fetch'
import { Config } from './config'
+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 Search {
private currentSearchText = ''
@@ -12,6 +26,7 @@ export class Search {
console.log('Create TAPI Search')
var form = document.createElement('form')
+ form.classList.add('tapi-form')
form.onsubmit = () => {
var items = document.getElementsByClassName('tapi-search-autocomplete-active')
if (items.length === 0) {
@@ -22,10 +37,20 @@ export class Search {
} else {
this.dial((document.getElementById('tapiSearchInput')).value)
}
-
return false
}
+ 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.appendChild(button)
+ })
+
var searchBox = document.createElement('div')
searchBox.classList.add('tapi-search-autocomplete')
searchBox.style.width = '200px'
@@ -39,17 +64,58 @@ export class Search {
search.onfocus = () => { this.doSearch() }
search.onkeydown = (ev) => { this.doSearchKeyDown(ev) }
search.onblur = () => {
- console.log('TAPI Search exit', this)
- setTimeout(() => {
- console.log('TAPI clear search results')
- this.removeSearchResults()
- }, 250)
+ setTimeout(() => { this.removeSearchResults() }, 250)
}
searchBox.appendChild(search)
element.parentElement.insertBefore(form, element)
}
+ private delay (ms: number): Promise {
+ 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()
+ }
+ }
+
private removeSearchResults () {
var resultList = document.getElementById('tapi-search-autocomplete-list')
if (resultList) {
@@ -103,11 +169,8 @@ export class Search {
} else if (searchText === this.currentSearchText) {
return
}
- console.log('Searching TAPI')
var response = await GM_fetch(Config.tapi_server_url + '/search?query=' + encodeURIComponent(searchText))
- console.log('TAPI Search response', response)
var contacts = await response.json() as TapiContact[]
- console.log('TAPI Contacts', contacts)
this.removeSearchResults()
this.currentSearchText = searchText
@@ -130,17 +193,14 @@ export class Search {
}, 200)
private selectResult (item: Element) {
- console.log('Select item', item)
var items = document.getElementsByClassName('tapi-search-autocomplete-active')
for (var i of items) {
i.classList.remove('tapi-search-autocomplete-active')
}
-
item.classList.add('tapi-search-autocomplete-active')
}
private dial (number: string) {
- console.log('TAPI Search dialing', number);
var searchInput = document.getElementById('dialpad-input');
(searchInput).value = number;
(searchInput).focus;