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;