Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70a01db422 | ||
|
|
9dfa7ca936 | ||
|
|
96f4a36724 | ||
|
|
1e84ba267c | ||
|
|
f4bc2828e8 | ||
|
|
fd8f4d6d88 | ||
|
|
5f2452f222 | ||
|
|
592ed8640d | ||
|
|
c019a0367b | ||
|
|
071d926781 | ||
|
|
94f6735ed5 | ||
|
|
ef8e68fc16 | ||
|
|
468814ca8d | ||
|
|
08f40977f9 | ||
|
|
e162ee3fe1 | ||
|
|
e0c4e138d6 | ||
|
|
1b6b37e96e | ||
|
|
c5478b7e29 | ||
|
|
6a68aecafa | ||
|
|
8e5b3cf3d2 | ||
|
|
d0ec639de0 | ||
|
|
3bc4472525 | ||
|
|
97a4a2435c | ||
|
|
0ed450451d | ||
|
|
aa87285cab | ||
|
|
a36e3e716f | ||
|
|
770f822fc6 | ||
|
|
71f6372496 | ||
|
|
1a44baba62 | ||
|
|
03a1b79737 | ||
|
|
e878336537 | ||
|
|
a5866132ab | ||
|
|
ebdb13168d | ||
|
|
4b5e154dc1 | ||
|
|
f6b78f93eb | ||
|
|
1fe0611453 | ||
|
|
0dcc6c4fdb | ||
|
|
86b3b3be15 | ||
|
|
31fa771626 | ||
|
|
e4e35a1285 | ||
|
|
aa2aea555d | ||
|
|
415abb058a | ||
|
|
b1a4342a9a | ||
|
|
93da2866dc | ||
|
|
a37e0a89fa | ||
|
|
988450919b | ||
|
|
25530c7c94 | ||
|
|
8ec9400b15 | ||
|
|
6f8a0b7b8e | ||
|
|
90a6496c48 | ||
|
|
2cef28ff3b | ||
|
|
5ee962fdb3 | ||
|
|
f2c8af4680 | ||
|
|
785a2d3ffe | ||
|
|
bd44771738 | ||
|
|
ec2c8fe784 | ||
|
|
6133823ade | ||
|
|
5bd54a27dc | ||
|
|
dee82af2ea | ||
|
|
3452472ba9 | ||
|
|
ca6640c33c | ||
|
|
fcc7192015 | ||
|
|
4a52bdb5d6 | ||
|
|
8e8616c835 | ||
|
|
13dc1736b4 | ||
|
|
ceb560e2f2 | ||
|
|
270af0e277 | ||
|
|
ee8c517acc | ||
|
|
807c68679e | ||
|
|
1f834160ca | ||
|
|
fb2243f0d4 | ||
|
|
267f7e716c | ||
|
|
73c477bcea | ||
|
|
ae6217b976 | ||
|
|
9be8b57303 | ||
|
|
0edb78f2ea | ||
|
|
c99790a710 | ||
|
|
ba601e38b1 | ||
|
|
30d1be61eb | ||
|
|
bc504b9257 | ||
|
|
5b24c5d978 | ||
|
|
9f563941e6 | ||
|
|
205c0dab07 | ||
|
|
43fd651723 | ||
|
|
daf2f29471 | ||
|
|
a8eaf6e2a9 | ||
|
|
92b888b78f | ||
|
|
dcae962481 | ||
|
|
243e4375e0 | ||
|
|
153eb728e2 | ||
|
|
73803f894b | ||
|
|
60d692c085 | ||
|
|
beaea0288c | ||
|
|
e07932f2cc | ||
|
|
7a95623094 | ||
|
|
9a3ada380f | ||
|
|
eb5e14e2a1 |
43
.env.example
43
.env.example
@ -16,6 +16,16 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
|
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
|
||||||
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
|
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
|
||||||
|
|
||||||
|
# Customer default economics (used as fallback defaults in customer detail)
|
||||||
|
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
|
||||||
|
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
|
||||||
|
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company lookup)
|
||||||
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
|
FIRMAAPI_API_KEY=
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS=12
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# SECURITY
|
# SECURITY
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -59,6 +69,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# FedEx Integration (Optional)
|
||||||
|
# =====================================================
|
||||||
|
FEDEX_ENABLED=false
|
||||||
|
FEDEX_API_KEY=
|
||||||
|
FEDEX_API_SECRET=
|
||||||
|
FEDEX_ACCOUNT_NUMBER=
|
||||||
|
FEDEX_BASE_URL=
|
||||||
|
FEDEX_TIMEOUT_SECONDS=20
|
||||||
|
|
||||||
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
|
||||||
|
FEDEX_READ_ONLY=true
|
||||||
|
FEDEX_DRY_RUN=true
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# Nextcloud Integration (Optional)
|
# Nextcloud Integration (Optional)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -69,6 +93,20 @@ NEXTCLOUD_CACHE_TTL_SECONDS=300
|
|||||||
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
NEXTCLOUD_ENCRYPTION_KEY=
|
NEXTCLOUD_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# Links / Endpoints Module (Optional)
|
||||||
|
# =====================================================
|
||||||
|
LINKS_MODULE_ENABLED=false
|
||||||
|
LINKS_READ_ONLY=true
|
||||||
|
LINKS_DRY_RUN=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_ENABLED=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL=
|
||||||
|
VAULTWARDEN_API_TOKEN=
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# vTiger Cloud Integration (Required for Subscriptions)
|
# vTiger Cloud Integration (Required for Subscriptions)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -95,6 +133,7 @@ IMAP_USERNAME=your_email@gmail.com
|
|||||||
IMAP_PASSWORD=your_app_password
|
IMAP_PASSWORD=your_app_password
|
||||||
IMAP_USE_SSL=true
|
IMAP_USE_SSL=true
|
||||||
IMAP_FOLDER=INBOX
|
IMAP_FOLDER=INBOX
|
||||||
|
IMAP_TEST_FOLDER=BMC_TEST # Shared test inbox for all mail scenarios
|
||||||
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
|
IMAP_READ_ONLY=true # Safety: READ-ONLY mode
|
||||||
|
|
||||||
# Microsoft Graph API (Alternative to IMAP - for Office365/Outlook)
|
# Microsoft Graph API (Alternative to IMAP - for Office365/Outlook)
|
||||||
@ -111,8 +150,12 @@ EMAIL_RULES_AUTO_PROCESS=false
|
|||||||
EMAIL_AI_ENABLED=false
|
EMAIL_AI_ENABLED=false
|
||||||
EMAIL_AUTO_CLASSIFY=false
|
EMAIL_AUTO_CLASSIFY=false
|
||||||
EMAIL_AI_CONFIDENCE_THRESHOLD=0.7
|
EMAIL_AI_CONFIDENCE_THRESHOLD=0.7
|
||||||
|
EMAIL_REQUIRE_MANUAL_APPROVAL=true
|
||||||
|
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL=false
|
||||||
EMAIL_MAX_FETCH_PER_RUN=50
|
EMAIL_MAX_FETCH_PER_RUN=50
|
||||||
|
EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE=true
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES=5
|
EMAIL_PROCESS_INTERVAL_MINUTES=5
|
||||||
EMAIL_WORKFLOWS_ENABLED=true
|
EMAIL_WORKFLOWS_ENABLED=true
|
||||||
|
EMAIL_WORKFLOW_AUTORUN_ENABLED=false
|
||||||
EMAIL_MAX_UPLOAD_SIZE_MB=50
|
EMAIL_MAX_UPLOAD_SIZE_MB=50
|
||||||
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
|
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip
|
||||||
@ -44,6 +44,16 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
API_RELOAD=false
|
API_RELOAD=false
|
||||||
|
|
||||||
|
# Customer default economics (used as fallback defaults in customer detail)
|
||||||
|
CUSTOMER_DEFAULT_MARGIN_PERCENT=20.0
|
||||||
|
CUSTOMER_DEFAULT_INVOICE_FEE=49.0
|
||||||
|
CUSTOMER_DEFAULT_HOURLY_RATE=1200.0
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company lookup)
|
||||||
|
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
|
||||||
|
FIRMAAPI_API_KEY=
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS=12
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# SECURITY - Production
|
# SECURITY - Production
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@ -76,3 +86,33 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
|
|||||||
# VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje
|
# VIGTIGT: Brug kun 'true' eller 'false' uden kommentarer på samme linje
|
||||||
ECONOMIC_READ_ONLY=true
|
ECONOMIC_READ_ONLY=true
|
||||||
ECONOMIC_DRY_RUN=true
|
ECONOMIC_DRY_RUN=true
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# FedEx Integration - Production
|
||||||
|
# =====================================================
|
||||||
|
FEDEX_ENABLED=false
|
||||||
|
FEDEX_API_KEY=
|
||||||
|
FEDEX_API_SECRET=
|
||||||
|
FEDEX_ACCOUNT_NUMBER=
|
||||||
|
FEDEX_BASE_URL=
|
||||||
|
FEDEX_TIMEOUT_SECONDS=20
|
||||||
|
|
||||||
|
# 🚨 SAFETY SWITCHES
|
||||||
|
# Start ALTID med begge sat til true i ny production deployment!
|
||||||
|
FEDEX_READ_ONLY=true
|
||||||
|
FEDEX_DRY_RUN=true
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# Links / Endpoints Module - Production (Optional)
|
||||||
|
# =====================================================
|
||||||
|
# Start disabled; enable after migration + validation
|
||||||
|
LINKS_MODULE_ENABLED=false
|
||||||
|
LINKS_READ_ONLY=true
|
||||||
|
LINKS_DRY_RUN=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_ENABLED=true
|
||||||
|
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL=
|
||||||
|
VAULTWARDEN_API_TOKEN=
|
||||||
|
|||||||
@ -258,6 +258,32 @@ crontab -e
|
|||||||
|
|
||||||
## 🔄 Opdatering til Ny Version
|
## 🔄 Opdatering til Ny Version
|
||||||
|
|
||||||
|
### Valg Af Update-metode
|
||||||
|
|
||||||
|
| Situation | Brug metode | Hvorfor |
|
||||||
|
|---|---|---|
|
||||||
|
| Små kodeændringer i `app/*` eller `main.py` | `./update_fast.sh --ref <ref>` | Hurtig update uden ny release-tag/pakke |
|
||||||
|
| Ændringer i `migrations/*` | `./updateto.sh <version>` | Kræver kontrolleret release + migrations-flow |
|
||||||
|
| Ændringer i `requirements.txt` eller `Dockerfile` | `./updateto.sh <version>` | Kræver fuld image-build og versionsstyring |
|
||||||
|
| Ændringer i `docker-compose*.yml`, scripts eller `.env` | `./updateto.sh <version>` | Drift/infra-konfiguration skal deployes fuldt |
|
||||||
|
| Når du er i tvivl | `./updateto.sh <version>` | Sikreste og mest forudsigelige metode |
|
||||||
|
|
||||||
|
Hurtig start for fast mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tjek først scope
|
||||||
|
./update_fast.sh --ref main --dry-run --allow-prod
|
||||||
|
|
||||||
|
# Kør update
|
||||||
|
./update_fast.sh --ref <commit-eller-tag> --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback i fast mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update_fast.sh --rollback <backup-id> --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
### På din Mac:
|
### På din Mac:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -107,6 +107,23 @@ if settings.ECONOMIC_READ_ONLY:
|
|||||||
logger.warning("Read-only mode")
|
logger.warning("Read-only mode")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Migration Validation
|
||||||
|
```bash
|
||||||
|
# Validate root migrations against current PostgreSQL schema
|
||||||
|
python scripts/validate_migrations.py
|
||||||
|
|
||||||
|
# Include module-specific migration directory in validation
|
||||||
|
python scripts/validate_migrations.py --module app/modules/sag/migrations
|
||||||
|
|
||||||
|
# Machine-readable report and strict index validation
|
||||||
|
python scripts/validate_migrations.py --json --strict-indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
- `0`: Schema is aligned, or only index differences were found without strict mode.
|
||||||
|
- `1`: Schema mismatches were found (missing tables/columns, or missing indexes with strict mode).
|
||||||
|
- `2`: Runtime error (for example connection/configuration issues).
|
||||||
|
|
||||||
## 🐳 Docker Commands
|
## 🐳 Docker Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libzbar0 \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
g++ \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
|||||||
@ -22,6 +22,34 @@ cd /srv/podman/bmc_hub_v1.0
|
|||||||
./updateto.sh v1.3.16
|
./updateto.sh v1.3.16
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fast small update (kode-only, uden ny release tag)
|
||||||
|
|
||||||
|
Brug denne metode til meget små ændringer i `app/*` eller `main.py`, hvor du ikke vil lave en fuld release-pakke.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh bmcadmin@172.16.31.183
|
||||||
|
cd /srv/podman/bmc_hub_v1.0
|
||||||
|
|
||||||
|
# Download/refresh fast update script
|
||||||
|
curl -O https://g.bmcnetworks.dk/ct/bmc_hub/raw/branch/main/update_fast.sh
|
||||||
|
chmod +x update_fast.sh
|
||||||
|
|
||||||
|
# Tjek først hvad der ændres (anbefalet)
|
||||||
|
./update_fast.sh --ref main --dry-run --allow-prod
|
||||||
|
|
||||||
|
# Kør fast update (eksempel: specifik commit)
|
||||||
|
./update_fast.sh --ref 08f4097 --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Vigtigt:
|
||||||
|
- `update_fast.sh` er kun til kode/templates/static ændringer i fast scope.
|
||||||
|
- Hvis der er ændringer i migrationer, dependencies, docker/compose eller env: brug `./updateto.sh`.
|
||||||
|
- Rollback kan køres med backup-id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update_fast.sh --rollback 20260517-142155 --allow-prod
|
||||||
|
```
|
||||||
|
|
||||||
## Manuel deployment (hvis scriptet ikke virker)
|
## Manuel deployment (hvis scriptet ikke virker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
22
RELEASE_NOTES_v2.2.56.md
Normal file
22
RELEASE_NOTES_v2.2.56.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Release Notes v2.2.56
|
||||||
|
|
||||||
|
Dato: 2026-03-18
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
Stabilisering af email-visning og hardening af supplier-invoices flows.
|
||||||
|
|
||||||
|
## Aendringer
|
||||||
|
- Rettet layout-overflow i email-detaljevisning, saa lange emner, afsenderadresser, HTML-indhold og filnavne ikke skubber kolonnerne ud af layoutet.
|
||||||
|
- Tilfoejet robust wrapping/truncering i emails UI for bedre responsiv opfoersel.
|
||||||
|
- Tilfoejet manglende "Klar til Bogforing" tab i supplier-invoices navigation.
|
||||||
|
- Rettet endpoint mismatch for AI template-analyse i supplier-invoices frontend.
|
||||||
|
- Fjernet JS-funktionskonflikter i supplier-invoices ved at adskille single/bulk send flows.
|
||||||
|
- Tilfoejet backend endpoint til at markere supplier-invoices som betalt.
|
||||||
|
- Fjernet route-konflikt for send-to-economic ved at flytte legacy placeholder til separat sti.
|
||||||
|
- Forbedret approve-flow ved at bruge dynamisk brugeropslag i stedet for hardcoded vaerdi.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
- app/emails/frontend/emails.html
|
||||||
|
- app/billing/frontend/supplier_invoices.html
|
||||||
|
- app/billing/backend/supplier_invoices.py
|
||||||
|
- RELEASE_NOTES_v2.2.56.md
|
||||||
18
RELEASE_NOTES_v2.2.57.md
Normal file
18
RELEASE_NOTES_v2.2.57.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Release Notes v2.2.57
|
||||||
|
|
||||||
|
Dato: 2026-03-18
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
Stabilisering af UI i Email- og SAG-modulerne.
|
||||||
|
|
||||||
|
## Aendringer
|
||||||
|
- Email-visning: yderligere hardening af HTML-tabeller i mail-body, inklusive normalisering af inline styles for at undgaa layout break.
|
||||||
|
- Email-visning: forbedret overflow-haandtering for bredt indhold (tabeller, celler og media).
|
||||||
|
- SAG detaljeside: forbedret tab-loading, saa data hentes ved faneskift for Varekob & Salg, Abonnement og Paamindelser.
|
||||||
|
- SAG detaljeside: robust fallback for reminder user-id via `/api/v1/auth/me`.
|
||||||
|
- SAG detaljeside: rettet API-kald for reminders og kalender til stabil case-id reference.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
- app/emails/frontend/emails.html
|
||||||
|
- app/modules/sag/templates/detail.html
|
||||||
|
- RELEASE_NOTES_v2.2.57.md
|
||||||
15
RELEASE_NOTES_v2.2.58.md
Normal file
15
RELEASE_NOTES_v2.2.58.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Release Notes v2.2.58
|
||||||
|
|
||||||
|
Dato: 2026-03-18
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
Forbedret UX paa SAG detaljesiden, saa fanernes indhold vises i toppen ved faneskift.
|
||||||
|
|
||||||
|
## Aendringer
|
||||||
|
- SAG tabs: aktiv tab-pane flyttes til toppen af tab-content ved faneskift.
|
||||||
|
- SAG tabs: automatisk scroll til fanebjaelken efter faneskift.
|
||||||
|
- SAG tabs: samme top-positionering og scroll ved `?tab=` deep-link aktivering.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
- app/modules/sag/templates/detail.html
|
||||||
|
- RELEASE_NOTES_v2.2.58.md
|
||||||
16
RELEASE_NOTES_v2.2.59.md
Normal file
16
RELEASE_NOTES_v2.2.59.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Release Notes v2.2.59
|
||||||
|
|
||||||
|
Dato: 2026-03-18
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
Stabil scroll/navigation i SAG-faner, saa bruger lander ved reelt indhold i den valgte fane.
|
||||||
|
|
||||||
|
## Aendringer
|
||||||
|
- Fjernet DOM-reordering af tab-pane elementer i SAG detaljesiden.
|
||||||
|
- Ny scroll-logik: ved faneskift scrolles til foerste meningsfulde indholdselement i aktiv fane.
|
||||||
|
- Scroll-offset tager hoejde for navbar-hoejde.
|
||||||
|
- Deep-link (`?tab=...`) bruger nu samme robuste scroll-adfaerd.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
- app/modules/sag/templates/detail.html
|
||||||
|
- RELEASE_NOTES_v2.2.59.md
|
||||||
17
RELEASE_NOTES_v2.2.60.md
Normal file
17
RELEASE_NOTES_v2.2.60.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Release Notes v2.2.60
|
||||||
|
|
||||||
|
Dato: 2026-03-18
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
Korrekt top-visning af aktiv fane paa SAG detaljesiden.
|
||||||
|
|
||||||
|
## Aendringer
|
||||||
|
- Tvang korrekt tab-pane synlighed i `#caseTabsContent`:
|
||||||
|
- inaktive faner skjules (`display: none`)
|
||||||
|
- kun aktiv fane vises (`display: block`)
|
||||||
|
- Fjernet tidligere scroll/DOM-workaround til fanevisning.
|
||||||
|
- Resultat: aktiv fane vises i toppen under fanebjaelken uden tom top-sektion.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
- app/modules/sag/templates/detail.html
|
||||||
|
- RELEASE_NOTES_v2.2.60.md
|
||||||
15
RELEASE_NOTES_v2.2.61.md
Normal file
15
RELEASE_NOTES_v2.2.61.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Release Notes v2.2.61
|
||||||
|
|
||||||
|
Dato: 18. marts 2026
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Rettet SAG-fanevisning i sag-detaljesiden, så kun den aktive fane vises i toppen.
|
||||||
|
- Tilføjet direkte klik-fallback på faneknapper (`onclick`) for robust aktivering, også hvis Bootstrap tab-events fejler.
|
||||||
|
- Sat eksplicit start-visibility på tab-panes for at undgå "lang side"-effekten med indhold langt nede.
|
||||||
|
- Fjernet to ødelagte CSS-blokke i toppen af templaten, som kunne skabe ustabil styling/parsing.
|
||||||
|
|
||||||
|
## Berørte filer
|
||||||
|
|
||||||
|
- `app/modules/sag/templates/detail.html`
|
||||||
|
- `RELEASE_NOTES_v2.2.61.md`
|
||||||
14
RELEASE_NOTES_v2.2.62.md
Normal file
14
RELEASE_NOTES_v2.2.62.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Release Notes v2.2.62
|
||||||
|
|
||||||
|
Dato: 18. marts 2026
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Rettet grid/nesting i SAG detaljevisning, så højre kolonne ligger i samme row som venstre/center.
|
||||||
|
- `Hardware`, `Salgspipeline`, `Opkaldshistorik` og `Todo-opgaver` vises nu i højre kolonne som forventet.
|
||||||
|
- Fjernet en for tidlig afsluttende `</div>` i detaljer-layoutet, som tidligere fik højre modulkolonne til at falde ned under venstre indhold.
|
||||||
|
|
||||||
|
## Berørte filer
|
||||||
|
|
||||||
|
- `app/modules/sag/templates/detail.html`
|
||||||
|
- `RELEASE_NOTES_v2.2.62.md`
|
||||||
14
RELEASE_NOTES_v2.2.63.md
Normal file
14
RELEASE_NOTES_v2.2.63.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Release Notes v2.2.63
|
||||||
|
|
||||||
|
Dato: 18. marts 2026
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Rettet QuickCreate AI-analyse request i frontend.
|
||||||
|
- `POST /api/v1/sag/analyze-quick-create` får nu korrekt payload med både `text` og `user_id` i body.
|
||||||
|
- Forbedret fejllog i frontend ved AI-fejl (inkl. HTTP status), så fejl ikke bliver skjult som generisk "Analysis failed".
|
||||||
|
|
||||||
|
## Berørte filer
|
||||||
|
|
||||||
|
- `app/shared/frontend/quick_create_modal.html`
|
||||||
|
- `RELEASE_NOTES_v2.2.63.md`
|
||||||
18
RELEASE_NOTES_v2.2.64.md
Normal file
18
RELEASE_NOTES_v2.2.64.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Release Notes v2.2.64
|
||||||
|
|
||||||
|
Dato: 18. marts 2026
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Forbedret QuickCreate robusthed når AI/LLM er utilgængelig.
|
||||||
|
- Tilføjet lokal heuristisk fallback i `CaseAnalysisService`, så brugeren stadig får:
|
||||||
|
- foreslået titel
|
||||||
|
- foreslået prioritet
|
||||||
|
- simple tags
|
||||||
|
- kunde-match forsøg
|
||||||
|
- Fjernet afhængighed af at Ollama altid svarer, så QuickCreate ikke længere ender i tom AI-unavailable flow ved midlertidige AI-fejl.
|
||||||
|
|
||||||
|
## Berørte filer
|
||||||
|
|
||||||
|
- `app/services/case_analysis_service.py`
|
||||||
|
- `RELEASE_NOTES_v2.2.64.md`
|
||||||
29
RELEASE_NOTES_v2.2.81.md
Normal file
29
RELEASE_NOTES_v2.2.81.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Release Notes v2.2.81
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Kontakter: Stabiliseret paginering i `/api/v1/contacts` ved at tilfoeje deterministisk tie-break (`ORDER BY ... , c.id`).
|
||||||
|
- Kontakter: Fjernet skrøbelig frontend query-key short-circuit i kontaktlisten, som kunne medfoere at listen ikke blev genindlaest korrekt efter afbrudte requests.
|
||||||
|
- Telefoni: Rettet datofilter i `/api/v1/telefoni/calls` saa `date_to` er inklusiv hele dagen.
|
||||||
|
- Telefoni: Validerer nu tydeligt `date_from`/`date_to` format (`YYYY-MM-DD`) med 422 ved ugyldig input.
|
||||||
|
- Deployment: `updateto.sh` bruger nu dynamiske containernavne baseret paa `STACK_NAME` i stedet for hardcoded `-prod`.
|
||||||
|
|
||||||
|
## Beroerte filer
|
||||||
|
|
||||||
|
- `app/contacts/backend/router_simple.py`
|
||||||
|
- `app/contacts/frontend/contacts.html`
|
||||||
|
- `app/modules/telefoni/backend/router.py`
|
||||||
|
- `updateto.sh`
|
||||||
|
- `VERSION`
|
||||||
|
|
||||||
|
## Drift
|
||||||
|
|
||||||
|
Hvis stacken koerer som `v2`, deploy med:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -iu bmcadmin
|
||||||
|
cd /srv/podman/bmc_hub_v2
|
||||||
|
./updateto.sh v2.2.81
|
||||||
|
```
|
||||||
21
RELEASE_NOTES_v2.2.82.md
Normal file
21
RELEASE_NOTES_v2.2.82.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Release Notes v2.2.82
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- `updateto.sh` fjerner nu automatisk `STACK_NAME` fra `.env` inden startup.
|
||||||
|
- `updateto.sh` vaelger automatisk `STACK_NAME=v2` i `/srv/podman/bmc_hub_v2` (ellers `prod`).
|
||||||
|
|
||||||
|
## Hvorfor
|
||||||
|
|
||||||
|
Nogle prod-deployments crashede API ved startup med:
|
||||||
|
|
||||||
|
- `ValidationError: STACK_NAME Extra inputs are not permitted`
|
||||||
|
|
||||||
|
Aarsagen var, at `STACK_NAME` laa i `.env` og blev indlaest af FastAPI Settings.
|
||||||
|
|
||||||
|
## Berort fil
|
||||||
|
|
||||||
|
- `updateto.sh`
|
||||||
|
- `VERSION`
|
||||||
14
RELEASE_NOTES_v2.2.83.md
Normal file
14
RELEASE_NOTES_v2.2.83.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Release Notes v2.2.83
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- `updateto.sh` loader nu `.env` sikkert uden `source`.
|
||||||
|
- Deploy fejler ikke laengere med shell-fejl som fx `Hub: command not found` ved ugyldige tekstlinjer i `.env`.
|
||||||
|
- Scriptet giver nu tydelig linjenummer-fejl ved ugyldige `.env` linjer.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
|
||||||
|
- `updateto.sh`
|
||||||
|
- `VERSION`
|
||||||
13
RELEASE_NOTES_v2.2.84.md
Normal file
13
RELEASE_NOTES_v2.2.84.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Release Notes v2.2.84
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Rettet logikfejl i `updateto.sh` hvor `podman-compose up -d` kunne blive sprunget over efter successfuld build.
|
||||||
|
- Scriptet bygger nu foerst, og starter derefter stacken i et separat trin med korrekt fejlhaandtering.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
|
||||||
|
- `updateto.sh`
|
||||||
|
- `VERSION`
|
||||||
15
RELEASE_NOTES_v2.2.85.md
Normal file
15
RELEASE_NOTES_v2.2.85.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Release Notes v2.2.85
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Telefoni-siden (`/telefoni`) rendrer nu seneste opkald server-side ved page load (SSR fallback).
|
||||||
|
- Dette sikrer, at brugeren ser opkald med det samme, selv hvis browserens JS/rendering/filter-state fejler eller er cachet.
|
||||||
|
- Klient-side `loadCalls()` koerer stadig bagefter og opdaterer tabellen som foer.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
|
||||||
|
- `app/modules/telefoni/frontend/views.py`
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
- `VERSION`
|
||||||
13
RELEASE_NOTES_v2.2.86.md
Normal file
13
RELEASE_NOTES_v2.2.86.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Release Notes v2.2.86
|
||||||
|
|
||||||
|
Dato: 2026-05-04
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Rettet Telefoni UI race-condition hvor server-renderede kald blev vist ved page load, men kunne blive overskrevet med tom liste efter ca. 1 sekund af foerste JS-refresh.
|
||||||
|
- Siden bevarer nu initialt viste kald, hvis foerste API-refresh uden aktive filtre returnerer tomt.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
- `VERSION`
|
||||||
14
RELEASE_NOTES_v2.2.87.md
Normal file
14
RELEASE_NOTES_v2.2.87.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Release Notes v2.2.87
|
||||||
|
|
||||||
|
Dato: 2026-05-05
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Telefoni: Foerste auto-load ignorerer nu browser-restored filterfelter (dato/user/uden sag).
|
||||||
|
- Dette forhindrer at opkald vises ved load og derefter forsvinder efter ca. 1 sekund.
|
||||||
|
- Filtre aktiveres stadig normalt ved brugerens egen interaktion.
|
||||||
|
|
||||||
|
## Berorte filer
|
||||||
|
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
- `VERSION`
|
||||||
19
RELEASE_NOTES_v2.3.1.md
Normal file
19
RELEASE_NOTES_v2.3.1.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# v2.3.1 — 16. maj 2026
|
||||||
|
|
||||||
|
## Fix: contacts pagination and company enrichment
|
||||||
|
|
||||||
|
- **Hotfix:** Contacts showing too few rows (contacts pagination bug)
|
||||||
|
- **Fix:** File `app/contacts/backend/router_simple.py` to stabilize pagination and company enrichment
|
||||||
|
|
||||||
|
## Contacts list
|
||||||
|
|
||||||
|
- Fixed bug where contacts list showed too few rows (pagination issue)
|
||||||
|
- Stabilized company enrichment data for contacts
|
||||||
|
|
||||||
|
## File changed
|
||||||
|
|
||||||
|
- `app/contacts/backend/router_simple.py`
|
||||||
|
|
||||||
|
## Affected versions
|
||||||
|
|
||||||
|
- v2.3.1
|
||||||
5
RELEASE_NOTES_v2.3.2.md
Normal file
5
RELEASE_NOTES_v2.3.2.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# RELEASE_NOTES_v2.3.2
|
||||||
|
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
**Summary:** Telefoni page no longer appears empty due to SSR fallback rows in `app/modules/telefoni/templates/log.html`
|
||||||
16
RELEASE_NOTES_v2.3.3.md
Normal file
16
RELEASE_NOTES_v2.3.3.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Release Notes: v2.3.3
|
||||||
|
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Telefoni Status Banner
|
||||||
|
A new status banner was added to `app/modules/telefoni/templates/log.html` to show explicit load/error/ready states:
|
||||||
|
|
||||||
|
- **`telefoni_status == "error"`** → "Telefoni: Error" with "Failed to load telefoni data."
|
||||||
|
- **`telefoni_status == "loading"`** → "Telefoni: Loading..." with "Fetching telefoni data."
|
||||||
|
- **`telefoni_status == "ready"`** → "Telefoni: Ready" with "Telefoni data loaded successfully."
|
||||||
|
- **default (no status)** → "Telefoni: No data" with a stronger fallback message: "Telefoni is not available. Try again later or check the network."
|
||||||
|
|
||||||
|
This improves visibility of telefoni data availability and provides clearer error messages.
|
||||||
|
|
||||||
12
RELEASE_NOTES_v2.3.4.md
Normal file
12
RELEASE_NOTES_v2.3.4.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Release Notes: v2.3.4 — 16. maj 2026
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- **Restore telefoni template integrity** after an accidental regression.
|
||||||
|
- **Add explicit telefoniStatus load/error/empty/success banner** to improve user feedback.
|
||||||
|
- File: `app/modules/telefoni/templates/log.html`
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed accidental regression in `app/modules/telefoni/templates/log.html` that corrupted template integrity.
|
||||||
|
- Added explicit `telefoniStatus` load/error/empty/success banner to improve user feedback.
|
||||||
17
RELEASE_NOTES_v2.3.5.md
Normal file
17
RELEASE_NOTES_v2.3.5.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Release Notes - BMC Hub v2.3.5
|
||||||
|
|
||||||
|
**Release Date:** 16. maj 2026
|
||||||
|
**Release Tag:** `v2.3.5`
|
||||||
|
|
||||||
|
## Hotfix
|
||||||
|
|
||||||
|
- Restored `app/modules/telefoni/templates/log.html` from known good state (`v2.3.2`) after template corruption in `v2.3.4`.
|
||||||
|
- Fixes production issue where `/telefoni` rendered blank.
|
||||||
|
|
||||||
|
## Affected File
|
||||||
|
|
||||||
|
- `app/modules/telefoni/templates/log.html`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is a corrective patch release intended to be deployed immediately.
|
||||||
6
RELEASE_NOTES_v2.3.6.md
Normal file
6
RELEASE_NOTES_v2.3.6.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Release Notes v2.3.6 — 16. maj 2026
|
||||||
|
|
||||||
|
## Telefoni Callback Fix
|
||||||
|
- telefoni callbacks now use both env and DB whitelist
|
||||||
|
- added internal 172.16.0.0/12 fallback acceptance
|
||||||
|
- added migration `migrations/186_telefoni_ip_whitelist_setting.sql`
|
||||||
10
RELEASE_NOTES_v2.3.7.md
Normal file
10
RELEASE_NOTES_v2.3.7.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Release Notes: v2.3.7
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- fallback to mission_call_state when telefoni_opkald is empty
|
||||||
|
- legacy rows shown read-only in telefoni UI
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- app/modules/telefoni/backend/router.py
|
||||||
|
- app/modules/telefoni/templates/log.html
|
||||||
9
RELEASE_NOTES_v2.3.8.md
Normal file
9
RELEASE_NOTES_v2.3.8.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Release Notes: v2.3.8
|
||||||
|
**Date:** 16. maj 2026
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- Added secure read-only SQL Console page under /settings/sql
|
||||||
|
- Added superadmin-protected execute endpoint /settings/sql/execute (SELECT/WITH only)
|
||||||
|
- Added SQL Console nav link in settings
|
||||||
|
- Added preset query buttons for telefoni/mission diagnostics
|
||||||
|
- files: app/settings/backend/views.py, app/settings/frontend/sql_console.html, app/settings/frontend/settings.html
|
||||||
23
RELEASE_NOTES_v2.3.md
Normal file
23
RELEASE_NOTES_v2.3.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Release Notes - BMC Hub v2.3
|
||||||
|
|
||||||
|
**Release Date:** 16. maj 2026
|
||||||
|
**Release Tag:** `v2.3`
|
||||||
|
**Gitea:** https://g.bmcnetworks.dk/ct/bmc_hub/releases/tag/v2.3
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Added Service Contract Report page with customer and contract selection.
|
||||||
|
- Implemented bug reporting feature with screenshot support.
|
||||||
|
|
||||||
|
## Included Commits (since v2.2.99)
|
||||||
|
|
||||||
|
- `a36e3e7` - feat: Add Service Contract Report page with customer and contract selection
|
||||||
|
- `770f822` - feat: Implement bug reporting feature with screenshot support
|
||||||
|
- `71f6372` - feat: Implement bug reporting feature with screenshot support
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This release is tagged from the `main` branch.
|
||||||
|
- e-conomic safety switches must remain enabled in production:
|
||||||
|
- `ECONOMIC_READ_ONLY=true`
|
||||||
|
- `ECONOMIC_DRY_RUN=true`
|
||||||
142
add_css.py
Normal file
142
add_css.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
with open('app/modules/sag/templates/detail.html', 'r', encoding='utf-8') as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
css_start = text.find('<style>')
|
||||||
|
if css_start != -1:
|
||||||
|
css_new = '''<style>
|
||||||
|
.time-v1-calendar-container {
|
||||||
|
background: var(--bg-surface, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
.time-v1-calendar-header {
|
||||||
|
background: var(--bg-element, #f8f9fa);
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.time-v1-calendar-grid {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.time-v1-time-axis {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-element, #fafafa);
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
.time-v1-hour-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.time-v1-tech-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.time-v1-tech-col:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.time-v1-tech-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
height: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-element, #f8f9fa);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.time-v1-tech-body {
|
||||||
|
position: relative;
|
||||||
|
height: 600px;
|
||||||
|
background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px);
|
||||||
|
background-size: 100% 60px;
|
||||||
|
}
|
||||||
|
.time-v1-entry-block {
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
right: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s;
|
||||||
|
border-left: 4px solid var(--bs-secondary);
|
||||||
|
background: var(--bg-surface, #fff);
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; }
|
||||||
|
.time-v1-entry-block:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; }
|
||||||
|
.time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; }
|
||||||
|
.time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; }
|
||||||
|
.time-v1-entry-time {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.time-v1-entry-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.time-v1-unplaced-container {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-element);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.time-v1-unplaced-item {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
text = text[:css_start] + css_new + text[css_start+7:]
|
||||||
|
|
||||||
|
with open('app/modules/sag/templates/detail.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
|
print('CSS added successfully!')
|
||||||
14
app/anydesk/backend/views.py
Normal file
14
app/anydesk/backend/views.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anydesk/sessions", response_class=HTMLResponse, tags=["Frontend"])
|
||||||
|
async def anydesk_sessions_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"anydesk/frontend/sessions.html",
|
||||||
|
{"request": request, "page_title": "AnyDesk Sessions"},
|
||||||
|
)
|
||||||
1165
app/anydesk/frontend/sessions.html
Normal file
1165
app/anydesk/frontend/sessions.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.auth_dependencies import get_current_user
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.database import execute_query
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -74,6 +75,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
|||||||
|
|
||||||
requires_2fa_setup = (
|
requires_2fa_setup = (
|
||||||
not user.get("is_shadow_admin", False)
|
not user.get("is_shadow_admin", False)
|
||||||
|
and not settings.AUTH_DISABLE_2FA
|
||||||
|
and AuthService.is_2fa_supported()
|
||||||
and not user.get("is_2fa_enabled", False)
|
and not user.get("is_2fa_enabled", False)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -139,10 +142,18 @@ async def setup_2fa(current_user: dict = Depends(get_current_user)):
|
|||||||
detail="Shadow admin cannot configure 2FA",
|
detail="Shadow admin cannot configure 2FA",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = AuthService.setup_user_2fa(
|
try:
|
||||||
user_id=current_user["id"],
|
result = AuthService.setup_user_2fa(
|
||||||
username=current_user["username"]
|
user_id=current_user["id"],
|
||||||
)
|
username=current_user["username"]
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "2FA columns missing" in str(exc):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="2FA er ikke tilgaengelig i denne database (mangler kolonner).",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -197,3 +208,101 @@ async def disable_2fa(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"message": "2FA disabled"}
|
return {"message": "2FA disabled"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── User Profile ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserProfileUpdate(BaseModel):
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
anydesk_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/profile")
|
||||||
|
async def get_my_profile(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Get current user's extended profile fields"""
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT full_name, phone, title, anydesk_id FROM users WHERE user_id = %s",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return dict(rows[0])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/profile")
|
||||||
|
async def update_my_profile(
|
||||||
|
payload: UserProfileUpdate,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Update current user's profile fields"""
|
||||||
|
fields = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
if payload.full_name is not None:
|
||||||
|
fields.append("full_name = %s")
|
||||||
|
values.append(payload.full_name.strip() or None)
|
||||||
|
if payload.phone is not None:
|
||||||
|
fields.append("phone = %s")
|
||||||
|
values.append(payload.phone.strip() or None)
|
||||||
|
if payload.title is not None:
|
||||||
|
fields.append("title = %s")
|
||||||
|
values.append(payload.title.strip() or None)
|
||||||
|
if payload.anydesk_id is not None:
|
||||||
|
fields.append("anydesk_id = %s")
|
||||||
|
values.append(payload.anydesk_id.strip() or None)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
fields.append("updated_at = NOW()")
|
||||||
|
values.append(current_user["id"])
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
f"UPDATE users SET {', '.join(fields)} WHERE user_id = %s",
|
||||||
|
tuple(values)
|
||||||
|
)
|
||||||
|
return {"message": "Profil opdateret"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── User AnyDesk IDs (multiple per technician) ───────────────────────────────
|
||||||
|
|
||||||
|
class AnyDeskIdAdd(BaseModel):
|
||||||
|
anydesk_id: str
|
||||||
|
label: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/anydesk-ids")
|
||||||
|
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, anydesk_id, label, created_at FROM user_anydesk_ids WHERE user_id = %s ORDER BY created_at",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
return {"ids": [dict(r) for r in (rows or [])]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/anydesk-ids", status_code=201)
|
||||||
|
async def add_my_anydesk_id(payload: AnyDeskIdAdd, current_user: dict = Depends(get_current_user)):
|
||||||
|
ad_id = payload.anydesk_id.strip()
|
||||||
|
if not ad_id:
|
||||||
|
raise HTTPException(status_code=400, detail="anydesk_id cannot be empty")
|
||||||
|
try:
|
||||||
|
execute_query(
|
||||||
|
"INSERT INTO user_anydesk_ids (user_id, anydesk_id, label) VALUES (%s, %s, %s)",
|
||||||
|
(current_user["id"], ad_id, payload.label or None)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=409, detail="AnyDesk ID allerede tilføjet")
|
||||||
|
return {"message": "Tilføjet"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/anydesk-ids/{entry_id}")
|
||||||
|
async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
rows = execute_query(
|
||||||
|
"DELETE FROM user_anydesk_ids WHERE id = %s AND user_id = %s RETURNING id",
|
||||||
|
(entry_id, current_user["id"])
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Ikke fundet")
|
||||||
|
return {"message": "Slettet"}
|
||||||
|
|||||||
@ -16,7 +16,11 @@ async def login_page(request: Request):
|
|||||||
"""
|
"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"auth/frontend/login.html",
|
"auth/frontend/login.html",
|
||||||
{"request": request}
|
{
|
||||||
|
"request": request,
|
||||||
|
"hide_top_nav": True,
|
||||||
|
"hide_bottom_bar": True,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -27,5 +31,9 @@ async def two_factor_setup_page(request: Request):
|
|||||||
"""
|
"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"auth/frontend/2fa_setup.html",
|
"auth/frontend/2fa_setup.html",
|
||||||
{"request": request}
|
{
|
||||||
|
"request": request,
|
||||||
|
"hide_top_nav": True,
|
||||||
|
"hide_bottom_bar": True,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -126,7 +126,25 @@ async def create_backup(backup: BackupCreate):
|
|||||||
"message": "Full backup created successfully"
|
"message": "Full backup created successfully"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=500, detail=f"Full backup partially failed: db={db_job_id}, files={files_job_id}")
|
db_error = None
|
||||||
|
failed_db_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, error_message
|
||||||
|
FROM backup_jobs
|
||||||
|
WHERE job_type = 'database'
|
||||||
|
AND status = 'failed'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if failed_db_row:
|
||||||
|
db_error = failed_db_row.get("error_message")
|
||||||
|
|
||||||
|
detail = f"Full backup partially failed: db={db_job_id}, files={files_job_id}"
|
||||||
|
if db_error:
|
||||||
|
detail = f"{detail}. Database error: {db_error}"
|
||||||
|
|
||||||
|
raise HTTPException(status_code=500, detail=detail)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full")
|
raise HTTPException(status_code=400, detail="Invalid job_type. Must be: database, files, or full")
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import logging
|
|||||||
import hashlib
|
import hashlib
|
||||||
import tarfile
|
import tarfile
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import shutil
|
||||||
import fcntl
|
import fcntl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -25,8 +26,26 @@ class BackupService:
|
|||||||
"""Service for managing backup operations"""
|
"""Service for managing backup operations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
configured_backup_dir = Path(settings.BACKUP_STORAGE_PATH)
|
||||||
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
self.backup_dir = configured_backup_dir
|
||||||
|
try:
|
||||||
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
# Local development can run outside Docker where /app is not writable.
|
||||||
|
# Fall back to the workspace data path so app startup does not fail.
|
||||||
|
if str(configured_backup_dir).startswith('/app/'):
|
||||||
|
project_root = Path(__file__).resolve().parents[3]
|
||||||
|
fallback_dir = project_root / 'data' / 'backups'
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Backup path %s not writable (%s). Using fallback %s",
|
||||||
|
configured_backup_dir,
|
||||||
|
exc,
|
||||||
|
fallback_dir,
|
||||||
|
)
|
||||||
|
fallback_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.backup_dir = fallback_dir
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
# Subdirectories for different backup types
|
# Subdirectories for different backup types
|
||||||
self.db_dir = self.backup_dir / "database"
|
self.db_dir = self.backup_dir / "database"
|
||||||
@ -34,6 +53,29 @@ class BackupService:
|
|||||||
self.db_dir.mkdir(exist_ok=True)
|
self.db_dir.mkdir(exist_ok=True)
|
||||||
self.files_dir.mkdir(exist_ok=True)
|
self.files_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def _resolve_pg_binary(self, binary_name: str) -> str:
|
||||||
|
"""Resolve PostgreSQL CLI binaries across container/host environments."""
|
||||||
|
found = shutil.which(binary_name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
f"/usr/bin/{binary_name}",
|
||||||
|
f"/usr/local/bin/{binary_name}",
|
||||||
|
f"/opt/homebrew/bin/{binary_name}",
|
||||||
|
f"/usr/lib/postgresql/16/bin/{binary_name}",
|
||||||
|
f"/usr/lib/postgresql/15/bin/{binary_name}",
|
||||||
|
f"/usr/lib/postgresql/14/bin/{binary_name}",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if Path(candidate).exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"{binary_name} blev ikke fundet i PATH. Installer postgresql-client eller rebuild API image."
|
||||||
|
)
|
||||||
|
|
||||||
async def create_database_backup(self, is_monthly: bool = False) -> Optional[int]:
|
async def create_database_backup(self, is_monthly: bool = False) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Create PostgreSQL database backup using pg_dump
|
Create PostgreSQL database backup using pg_dump
|
||||||
@ -65,6 +107,8 @@ class BackupService:
|
|||||||
job_id, backup_format, is_monthly)
|
job_id, backup_format, is_monthly)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
pg_dump_bin = self._resolve_pg_binary('pg_dump')
|
||||||
|
|
||||||
# Build pg_dump command - connect via network to postgres service
|
# Build pg_dump command - connect via network to postgres service
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password
|
env['PGPASSWORD'] = settings.DATABASE_URL.split(':')[2].split('@')[0] # Extract password
|
||||||
@ -84,10 +128,10 @@ class BackupService:
|
|||||||
|
|
||||||
if backup_format == 'dump':
|
if backup_format == 'dump':
|
||||||
# Compressed custom format (-Fc)
|
# Compressed custom format (-Fc)
|
||||||
cmd = ['pg_dump', '-h', host, '-U', user, '-Fc', dbname]
|
cmd = [pg_dump_bin, '-h', host, '-U', user, '-Fc', dbname]
|
||||||
else:
|
else:
|
||||||
# Plain SQL format
|
# Plain SQL format
|
||||||
cmd = ['pg_dump', '-h', host, '-U', user, dbname]
|
cmd = [pg_dump_bin, '-h', host, '-U', user, dbname]
|
||||||
|
|
||||||
# Execute pg_dump and write to file
|
# Execute pg_dump and write to file
|
||||||
logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path)
|
logger.info("📦 Executing: %s > %s", ' '.join(cmd), backup_path)
|
||||||
@ -136,6 +180,21 @@ class BackupService:
|
|||||||
backup_path.unlink()
|
backup_path.unlink()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error("❌ Database backup failed: %s", error_msg)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""UPDATE backup_jobs
|
||||||
|
SET status = %s, completed_at = %s, error_message = %s
|
||||||
|
WHERE id = %s""",
|
||||||
|
('failed', datetime.now(), error_msg, job_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if backup_path.exists():
|
||||||
|
backup_path.unlink()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def create_files_backup(self) -> Optional[int]:
|
async def create_files_backup(self) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
@ -394,9 +453,12 @@ class BackupService:
|
|||||||
|
|
||||||
env['PGPASSWORD'] = password
|
env['PGPASSWORD'] = password
|
||||||
|
|
||||||
|
psql_bin = self._resolve_pg_binary('psql')
|
||||||
|
pg_restore_bin = self._resolve_pg_binary('pg_restore')
|
||||||
|
|
||||||
# Step 1: Create new empty database
|
# Step 1: Create new empty database
|
||||||
logger.info("📦 Creating new database: %s", new_dbname)
|
logger.info("📦 Creating new database: %s", new_dbname)
|
||||||
create_cmd = ['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
|
create_cmd = [psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
|
||||||
f"CREATE DATABASE {new_dbname} OWNER {user};"]
|
f"CREATE DATABASE {new_dbname} OWNER {user};"]
|
||||||
result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
|
result = subprocess.run(create_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
text=True, env=env)
|
text=True, env=env)
|
||||||
@ -412,7 +474,7 @@ class BackupService:
|
|||||||
# Build restore command based on format
|
# Build restore command based on format
|
||||||
if backup['backup_format'] == 'dump':
|
if backup['backup_format'] == 'dump':
|
||||||
# Restore from compressed custom format
|
# Restore from compressed custom format
|
||||||
cmd = ['pg_restore', '-h', host, '-U', user, '-d', new_dbname]
|
cmd = [pg_restore_bin, '-h', host, '-U', user, '-d', new_dbname]
|
||||||
|
|
||||||
logger.info("📥 Restoring to %s: %s < %s", new_dbname, ' '.join(cmd), backup_path)
|
logger.info("📥 Restoring to %s: %s < %s", new_dbname, ' '.join(cmd), backup_path)
|
||||||
|
|
||||||
@ -443,7 +505,7 @@ class BackupService:
|
|||||||
if has_real_errors and not is_harmless:
|
if has_real_errors and not is_harmless:
|
||||||
logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000])
|
logger.error("❌ pg_restore had REAL errors: %s", result.stderr[:1000])
|
||||||
# Try to drop the failed database
|
# Try to drop the failed database
|
||||||
subprocess.run(['psql', '-h', host, '-U', user, '-d', 'postgres', '-c',
|
subprocess.run([psql_bin, '-h', host, '-U', user, '-d', 'postgres', '-c',
|
||||||
f"DROP DATABASE IF EXISTS {new_dbname};"], env=env)
|
f"DROP DATABASE IF EXISTS {new_dbname};"], env=env)
|
||||||
raise RuntimeError(f"pg_restore failed with errors")
|
raise RuntimeError(f"pg_restore failed with errors")
|
||||||
else:
|
else:
|
||||||
@ -451,7 +513,7 @@ class BackupService:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Restore from plain SQL
|
# Restore from plain SQL
|
||||||
cmd = ['psql', '-h', host, '-U', user, '-d', new_dbname]
|
cmd = [psql_bin, '-h', host, '-U', user, '-d', new_dbname]
|
||||||
|
|
||||||
logger.info("📥 Executing: %s < %s", ' '.join(cmd), backup_path)
|
logger.info("📥 Executing: %s < %s", ' '.join(cmd), backup_path)
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,14 @@ Billing Router
|
|||||||
API endpoints for billing operations
|
API endpoints for billing operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from datetime import datetime, date
|
||||||
|
import json
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from app.core.database import execute_query, get_db_connection, release_db_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from app.jobs.reconcile_ordre_drafts import reconcile_ordre_drafts_sync_status
|
||||||
from . import supplier_invoices
|
from . import supplier_invoices
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -12,6 +19,83 @@ router = APIRouter()
|
|||||||
router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"])
|
router.include_router(supplier_invoices.router, prefix="", tags=["Supplier Invoices"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/billing/drafts/sync-dashboard")
|
||||||
|
async def get_draft_sync_dashboard(limit: int = 20):
|
||||||
|
"""Operational dashboard data for ordre draft sync lifecycle."""
|
||||||
|
try:
|
||||||
|
summary = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count,
|
||||||
|
COUNT(*) AS total_count
|
||||||
|
FROM ordre_drafts
|
||||||
|
""",
|
||||||
|
(),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
attention = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.title,
|
||||||
|
d.customer_id,
|
||||||
|
d.sync_status,
|
||||||
|
d.economic_order_number,
|
||||||
|
d.economic_invoice_number,
|
||||||
|
d.last_sync_at,
|
||||||
|
d.updated_at,
|
||||||
|
ev.event_type AS latest_event_type,
|
||||||
|
ev.created_at AS latest_event_at
|
||||||
|
FROM ordre_drafts d
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT event_type, created_at
|
||||||
|
FROM ordre_draft_sync_events
|
||||||
|
WHERE draft_id = d.id
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) ev ON TRUE
|
||||||
|
WHERE d.sync_status IN ('pending', 'failed')
|
||||||
|
ORDER BY d.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(max(1, min(limit, 200)),),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
recent_events = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ev.id,
|
||||||
|
ev.draft_id,
|
||||||
|
ev.event_type,
|
||||||
|
ev.from_status,
|
||||||
|
ev.to_status,
|
||||||
|
ev.event_payload,
|
||||||
|
ev.created_by_user_id,
|
||||||
|
ev.created_at,
|
||||||
|
d.title AS draft_title,
|
||||||
|
d.customer_id,
|
||||||
|
d.sync_status
|
||||||
|
FROM ordre_draft_sync_events ev
|
||||||
|
JOIN ordre_drafts d ON d.id = ev.draft_id
|
||||||
|
ORDER BY ev.created_at DESC, ev.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(max(1, min(limit, 200)),),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary[0] if summary else {},
|
||||||
|
"attention_items": attention,
|
||||||
|
"recent_events": recent_events,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to load sync dashboard: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/invoices")
|
@router.get("/billing/invoices")
|
||||||
async def list_invoices():
|
async def list_invoices():
|
||||||
"""List all invoices"""
|
"""List all invoices"""
|
||||||
@ -22,3 +106,390 @@ async def list_invoices():
|
|||||||
async def sync_to_economic():
|
async def sync_to_economic():
|
||||||
"""Sync data to e-conomic"""
|
"""Sync data to e-conomic"""
|
||||||
return {"message": "e-conomic sync coming soon"}
|
return {"message": "e-conomic sync coming soon"}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_date(value: Any) -> date | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_period(start: date, interval: str) -> date:
|
||||||
|
normalized = (interval or "monthly").strip().lower()
|
||||||
|
if normalized == "daily":
|
||||||
|
return start + relativedelta(days=1)
|
||||||
|
if normalized == "biweekly":
|
||||||
|
return start + relativedelta(weeks=2)
|
||||||
|
if normalized == "quarterly":
|
||||||
|
return start + relativedelta(months=3)
|
||||||
|
if normalized == "yearly":
|
||||||
|
return start + relativedelta(years=1)
|
||||||
|
return start + relativedelta(months=1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/billing/subscriptions/preview")
|
||||||
|
async def preview_subscription_billing(payload: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Preview aggregated customer billing from due subscriptions.
|
||||||
|
Generates prorata suggestions for approved-but-not-applied price changes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
as_of = _to_date(payload.get("as_of")) or date.today()
|
||||||
|
customer_id = payload.get("customer_id")
|
||||||
|
|
||||||
|
where = ["s.status = 'active'", "s.next_invoice_date <= %s", "COALESCE(s.billing_blocked, false) = false"]
|
||||||
|
params: List[Any] = [as_of]
|
||||||
|
if customer_id:
|
||||||
|
where.append("s.customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
subscriptions = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.customer_id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
s.product_name,
|
||||||
|
s.billing_interval,
|
||||||
|
s.billing_direction,
|
||||||
|
s.invoice_merge_key,
|
||||||
|
s.next_invoice_date,
|
||||||
|
s.period_start,
|
||||||
|
s.price,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', i.id,
|
||||||
|
'description', i.description,
|
||||||
|
'quantity', i.quantity,
|
||||||
|
'unit_price', i.unit_price,
|
||||||
|
'line_total', i.line_total,
|
||||||
|
'asset_id', i.asset_id,
|
||||||
|
'period_from', i.period_from,
|
||||||
|
'period_to', i.period_to,
|
||||||
|
'billing_blocked', i.billing_blocked
|
||||||
|
) ORDER BY i.line_no ASC, i.id ASC
|
||||||
|
)
|
||||||
|
FROM sag_subscription_items i
|
||||||
|
WHERE i.subscription_id = s.id
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS line_items
|
||||||
|
FROM sag_subscriptions s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE {' AND '.join(where)}
|
||||||
|
ORDER BY s.customer_id, s.next_invoice_date, s.id
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
groups: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for sub in subscriptions:
|
||||||
|
merge_key = sub.get("invoice_merge_key") or f"cust-{sub['customer_id']}"
|
||||||
|
key = f"{sub['customer_id']}|{merge_key}|{sub.get('billing_direction') or 'forward'}|{sub.get('next_invoice_date')}"
|
||||||
|
grp = groups.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"customer_id": sub["customer_id"],
|
||||||
|
"customer_name": sub.get("customer_name"),
|
||||||
|
"merge_key": merge_key,
|
||||||
|
"billing_direction": sub.get("billing_direction") or "forward",
|
||||||
|
"invoice_date": str(sub.get("next_invoice_date")),
|
||||||
|
"coverage_start": None,
|
||||||
|
"coverage_end": None,
|
||||||
|
"subscription_ids": [],
|
||||||
|
"line_count": 0,
|
||||||
|
"amount_total": 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_id = int(sub["id"])
|
||||||
|
grp["subscription_ids"].append(sub_id)
|
||||||
|
start = _to_date(sub.get("period_start") or sub.get("next_invoice_date")) or as_of
|
||||||
|
end = _next_period(start, sub.get("billing_interval") or "monthly")
|
||||||
|
grp["coverage_start"] = str(start) if grp["coverage_start"] is None or str(start) < grp["coverage_start"] else grp["coverage_start"]
|
||||||
|
grp["coverage_end"] = str(end) if grp["coverage_end"] is None or str(end) > grp["coverage_end"] else grp["coverage_end"]
|
||||||
|
|
||||||
|
for item in sub.get("line_items") or []:
|
||||||
|
if item.get("billing_blocked"):
|
||||||
|
continue
|
||||||
|
grp["line_count"] += 1
|
||||||
|
grp["amount_total"] += float(item.get("line_total") or 0)
|
||||||
|
|
||||||
|
price_changes = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
spc.id,
|
||||||
|
spc.subscription_id,
|
||||||
|
spc.subscription_item_id,
|
||||||
|
spc.old_unit_price,
|
||||||
|
spc.new_unit_price,
|
||||||
|
spc.effective_date,
|
||||||
|
spc.approval_status,
|
||||||
|
spc.reason,
|
||||||
|
s.period_start,
|
||||||
|
s.billing_interval
|
||||||
|
FROM subscription_price_changes spc
|
||||||
|
JOIN sag_subscriptions s ON s.id = spc.subscription_id
|
||||||
|
WHERE spc.deleted_at IS NULL
|
||||||
|
AND spc.approval_status IN ('approved', 'pending')
|
||||||
|
AND spc.effective_date <= %s
|
||||||
|
ORDER BY spc.effective_date ASC, spc.id ASC
|
||||||
|
""",
|
||||||
|
(as_of,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
prorata_suggestions: List[Dict[str, Any]] = []
|
||||||
|
for change in price_changes:
|
||||||
|
period_start = _to_date(change.get("period_start"))
|
||||||
|
if not period_start:
|
||||||
|
continue
|
||||||
|
period_end = _next_period(period_start, change.get("billing_interval") or "monthly")
|
||||||
|
eff = _to_date(change.get("effective_date"))
|
||||||
|
if not eff:
|
||||||
|
continue
|
||||||
|
if eff <= period_start or eff >= period_end:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_days = max((period_end - period_start).days, 1)
|
||||||
|
remaining_days = max((period_end - eff).days, 0)
|
||||||
|
old_price = float(change.get("old_unit_price") or 0)
|
||||||
|
new_price = float(change.get("new_unit_price") or 0)
|
||||||
|
delta = new_price - old_price
|
||||||
|
prorata_amount = round(delta * (remaining_days / total_days), 2)
|
||||||
|
if prorata_amount == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prorata_suggestions.append(
|
||||||
|
{
|
||||||
|
"price_change_id": change.get("id"),
|
||||||
|
"subscription_id": change.get("subscription_id"),
|
||||||
|
"subscription_item_id": change.get("subscription_item_id"),
|
||||||
|
"effective_date": str(eff),
|
||||||
|
"period_start": str(period_start),
|
||||||
|
"period_end": str(period_end),
|
||||||
|
"old_unit_price": old_price,
|
||||||
|
"new_unit_price": new_price,
|
||||||
|
"remaining_days": remaining_days,
|
||||||
|
"total_days": total_days,
|
||||||
|
"suggested_adjustment": prorata_amount,
|
||||||
|
"adjustment_type": "debit" if prorata_amount > 0 else "credit",
|
||||||
|
"reason": change.get("reason"),
|
||||||
|
"requires_manual_approval": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "preview",
|
||||||
|
"as_of": str(as_of),
|
||||||
|
"group_count": len(groups),
|
||||||
|
"groups": list(groups.values()),
|
||||||
|
"prorata_suggestions": prorata_suggestions,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to preview subscription billing: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/billing/prorata-adjustments/draft")
|
||||||
|
async def create_prorata_adjustment_draft(payload: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Create a manual adjustment draft from an approved prorata suggestion.
|
||||||
|
Payload expects customer_id, subscription_id, amount, reason and optional effective dates.
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
customer_id = payload.get("customer_id")
|
||||||
|
subscription_id = payload.get("subscription_id")
|
||||||
|
amount = float(payload.get("amount") or 0)
|
||||||
|
reason = (payload.get("reason") or "Prorata justering").strip()
|
||||||
|
effective_date = _to_date(payload.get("effective_date")) or date.today()
|
||||||
|
period_start = _to_date(payload.get("period_start"))
|
||||||
|
period_end = _to_date(payload.get("period_end"))
|
||||||
|
|
||||||
|
if not customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
if not subscription_id:
|
||||||
|
raise HTTPException(status_code=400, detail="subscription_id is required")
|
||||||
|
if amount == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="amount must be non-zero")
|
||||||
|
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, customer_id, product_name
|
||||||
|
FROM sag_subscriptions
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(subscription_id,),
|
||||||
|
)
|
||||||
|
sub = cursor.fetchone()
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
if int(sub.get("customer_id") or 0) != int(customer_id):
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id mismatch for subscription")
|
||||||
|
|
||||||
|
adjustment_label = "Prorata tillæg" if amount > 0 else "Prorata kredit"
|
||||||
|
line = {
|
||||||
|
"product": {
|
||||||
|
"productNumber": "PRORATA",
|
||||||
|
"description": f"{adjustment_label}: {sub.get('product_name') or 'Abonnement'}"
|
||||||
|
},
|
||||||
|
"quantity": 1,
|
||||||
|
"unitNetPrice": amount,
|
||||||
|
"totalNetAmount": amount,
|
||||||
|
"discountPercentage": 0,
|
||||||
|
"metadata": {
|
||||||
|
"subscription_id": subscription_id,
|
||||||
|
"effective_date": str(effective_date),
|
||||||
|
"period_start": str(period_start) if period_start else None,
|
||||||
|
"period_end": str(period_end) if period_end else None,
|
||||||
|
"reason": reason,
|
||||||
|
"manual_approval": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ordre_drafts (
|
||||||
|
title,
|
||||||
|
customer_id,
|
||||||
|
lines_json,
|
||||||
|
notes,
|
||||||
|
coverage_start,
|
||||||
|
coverage_end,
|
||||||
|
billing_direction,
|
||||||
|
source_subscription_ids,
|
||||||
|
invoice_aggregate_key,
|
||||||
|
layout_number,
|
||||||
|
created_by_user_id,
|
||||||
|
sync_status,
|
||||||
|
export_status_json,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s::jsonb, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
RETURNING id, created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"Manuel {adjustment_label}",
|
||||||
|
customer_id,
|
||||||
|
json.dumps([line], ensure_ascii=False),
|
||||||
|
reason,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
"backward",
|
||||||
|
[subscription_id],
|
||||||
|
f"manual-prorata-{customer_id}",
|
||||||
|
1,
|
||||||
|
payload.get("created_by_user_id"),
|
||||||
|
"pending",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source": "prorata_manual",
|
||||||
|
"subscription_id": subscription_id,
|
||||||
|
"effective_date": str(effective_date),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = cursor.fetchone()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return {
|
||||||
|
"status": "draft_created",
|
||||||
|
"draft_id": created.get("id") if created else None,
|
||||||
|
"created_at": created.get("created_at") if created else None,
|
||||||
|
"subscription_id": subscription_id,
|
||||||
|
"amount": amount,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to create prorata adjustment draft: {e}")
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/billing/drafts/reconcile-sync-status")
|
||||||
|
async def reconcile_draft_sync_status(payload: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Reconcile ordre_drafts sync_status from known economic references.
|
||||||
|
Rules:
|
||||||
|
- pending/failed + economic_order_number -> exported
|
||||||
|
- exported + economic_invoice_number -> posted
|
||||||
|
- posted + mark_paid_ids contains draft id -> paid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
apply_changes = bool(payload.get("apply", False))
|
||||||
|
result = await reconcile_ordre_drafts_sync_status(apply_changes=apply_changes)
|
||||||
|
|
||||||
|
mark_paid_ids = set(int(x) for x in (payload.get("mark_paid_ids") or []) if str(x).isdigit())
|
||||||
|
if apply_changes and mark_paid_ids:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
for draft_id in mark_paid_ids:
|
||||||
|
cursor.execute("SELECT sync_status FROM ordre_drafts WHERE id = %s", (draft_id,))
|
||||||
|
before = cursor.fetchone()
|
||||||
|
from_status = (before or {}).get("sync_status")
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ordre_drafts
|
||||||
|
SET sync_status = 'paid',
|
||||||
|
last_sync_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
last_exported_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
AND sync_status = 'posted'
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(draft_id,),
|
||||||
|
)
|
||||||
|
updated = cursor.fetchone()
|
||||||
|
if updated:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ordre_draft_sync_events (
|
||||||
|
draft_id,
|
||||||
|
event_type,
|
||||||
|
from_status,
|
||||||
|
to_status,
|
||||||
|
event_payload,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s::jsonb, NULL)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
draft_id,
|
||||||
|
'sync_status_manual_paid',
|
||||||
|
from_status,
|
||||||
|
'paid',
|
||||||
|
'{"source":"billing_reconcile_endpoint"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
if mark_paid_ids:
|
||||||
|
result["mark_paid_ids"] = sorted(mark_paid_ids)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to reconcile draft sync status: {e}")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -110,6 +110,9 @@
|
|||||||
<p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p>
|
<p class="text-muted mb-0">Kassekladde - Integration med e-conomic</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<a href="/billing/sync-dashboard" class="btn btn-outline-dark me-2">
|
||||||
|
<i class="bi bi-activity me-2"></i>Sync Dashboard
|
||||||
|
</a>
|
||||||
<a href="/billing/templates" class="btn btn-outline-secondary me-2">
|
<a href="/billing/templates" class="btn btn-outline-secondary me-2">
|
||||||
<i class="bi bi-grid-3x3 me-2"></i>Se Templates
|
<i class="bi bi-grid-3x3 me-2"></i>Se Templates
|
||||||
</a>
|
</a>
|
||||||
@ -173,6 +176,11 @@
|
|||||||
<i class="bi bi-calendar-check me-2"></i>Til Betaling
|
<i class="bi bi-calendar-check me-2"></i>Til Betaling
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="ready-tab" data-bs-toggle="tab" href="#ready-content" onclick="switchToReadyTab()">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>Klar til Bogføring
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
|
<a class="nav-link" id="lines-tab" data-bs-toggle="tab" href="#lines-content" onclick="switchToLinesTab()">
|
||||||
<i class="bi bi-list-ul me-2"></i>Varelinjer
|
<i class="bi bi-list-ul me-2"></i>Varelinjer
|
||||||
@ -248,7 +256,7 @@
|
|||||||
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
|
<strong><span id="selectedKassekladdeCount">0</span> fakturaer valgt</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomic()" title="Send til e-conomic kassekladde">
|
<button type="button" class="btn btn-sm btn-success" onclick="bulkSendToEconomicKassekladde()" title="Send til e-conomic kassekladde">
|
||||||
<i class="bi bi-send me-1"></i>Send til e-conomic
|
<i class="bi bi-send me-1"></i>Send til e-conomic
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1392,7 +1400,7 @@ async function markSingleAsPaid(invoiceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send single invoice to e-conomic
|
// Helper function to send single invoice to e-conomic
|
||||||
async function sendToEconomic(invoiceId) {
|
async function sendToEconomicById(invoiceId) {
|
||||||
if (!confirm('Send denne faktura til e-conomic?')) return;
|
if (!confirm('Send denne faktura til e-conomic?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1680,7 +1688,7 @@ async function loadReadyForBookingView() {
|
|||||||
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
|
<button class="btn btn-sm btn-outline-primary" onclick="viewInvoiceDetails(${invoice.id})" title="Se/Rediger detaljer">
|
||||||
<i class="bi bi-pencil-square"></i>
|
<i class="bi bi-pencil-square"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-primary" onclick="sendToEconomic(${invoice.id})" title="Send til e-conomic">
|
<button class="btn btn-sm btn-primary" onclick="sendToEconomicById(${invoice.id})" title="Send til e-conomic">
|
||||||
<i class="bi bi-send"></i>
|
<i class="bi bi-send"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -4051,12 +4059,11 @@ async function bulkMarkAsPaid() {
|
|||||||
|
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'paid',
|
paid_date: new Date().toISOString().split('T')[0]
|
||||||
payment_date: new Date().toISOString().split('T')[0]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4087,12 +4094,11 @@ async function markInvoiceAsPaid(invoiceId) {
|
|||||||
if (!confirm('Marker denne faktura som betalt?')) return;
|
if (!confirm('Marker denne faktura som betalt?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'paid',
|
paid_date: new Date().toISOString().split('T')[0]
|
||||||
payment_date: new Date().toISOString().split('T')[0]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4557,7 +4563,7 @@ async function approveInvoice() {
|
|||||||
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${currentInvoiceId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ approved_by: 'CurrentUser' }) // TODO: Get from auth
|
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -4610,7 +4616,7 @@ async function quickApprove(invoiceId) {
|
|||||||
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
const response = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ approved_by: 'CurrentUser' })
|
body: JSON.stringify({ approved_by: getApprovalUser() })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -4955,7 +4961,7 @@ async function createTemplateFromInvoice(invoiceId, vendorId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: AI analyze
|
// Step 2: AI analyze
|
||||||
const aiResp = await fetch('/api/v1/supplier-invoices/ai-analyze', {
|
const aiResp = await fetch('/api/v1/supplier-invoices/ai/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -5117,7 +5123,7 @@ async function sendSingleToEconomic(invoiceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bulk send selected invoices to e-conomic
|
// Bulk send selected invoices to e-conomic
|
||||||
async function bulkSendToEconomic() {
|
async function bulkSendToEconomicKassekladde() {
|
||||||
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.kassekladde-checkbox:checked');
|
||||||
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
|
const invoiceIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.invoiceId));
|
||||||
|
|
||||||
@ -5165,6 +5171,16 @@ async function bulkSendToEconomic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getApprovalUser() {
|
||||||
|
const bodyUser = document.body?.dataset?.currentUser;
|
||||||
|
if (bodyUser && bodyUser.trim()) return bodyUser.trim();
|
||||||
|
|
||||||
|
const metaUser = document.querySelector('meta[name="current-user"]')?.content;
|
||||||
|
if (metaUser && metaUser.trim()) return metaUser.trim();
|
||||||
|
|
||||||
|
return 'System';
|
||||||
|
}
|
||||||
|
|
||||||
// Select vendor for file (when <100% match)
|
// Select vendor for file (when <100% match)
|
||||||
async function selectVendorForFile(fileId, vendorId) {
|
async function selectVendorForFile(fileId, vendorId) {
|
||||||
if (!vendorId) return;
|
if (!vendorId) return;
|
||||||
|
|||||||
408
app/billing/frontend/sync_dashboard.html
Normal file
408
app/billing/frontend/sync_dashboard.html
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sync Dashboard - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sync-accent: #0f4c75;
|
||||||
|
--sync-accent-soft: rgba(15, 76, 117, 0.1);
|
||||||
|
--sync-ok: #2f855a;
|
||||||
|
--sync-warn: #c05621;
|
||||||
|
--sync-danger: #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-header {
|
||||||
|
background: linear-gradient(130deg, rgba(15, 76, 117, 0.14), rgba(22, 160, 133, 0.08));
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-kpi {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-kpi .label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-kpi .value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-kpi.pending .value { color: var(--sync-warn); }
|
||||||
|
.sync-kpi.failed .value { color: var(--sync-danger); }
|
||||||
|
.sync-kpi.posted .value { color: var(--sync-accent); }
|
||||||
|
.sync-kpi.paid .value { color: var(--sync-ok); }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); }
|
||||||
|
.status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); }
|
||||||
|
.status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); }
|
||||||
|
.status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; }
|
||||||
|
.status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); }
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sync-header {
|
||||||
|
background: linear-gradient(130deg, rgba(61, 139, 253, 0.14), rgba(44, 62, 80, 0.3));
|
||||||
|
border-color: rgba(61, 139, 253, 0.25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="sync-header d-flex flex-wrap justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Draft Sync Dashboard</h2>
|
||||||
|
<p class="text-muted mb-0">Overblik over ordre-draft sync, attention queue og seneste events.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" id="btnPreviewReconcile">
|
||||||
|
<i class="bi bi-search me-1"></i>Preview Reconcile
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" id="btnApplyReconcile">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Kør Reconcile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4" id="kpiRow">
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi">
|
||||||
|
<div class="label">Total</div>
|
||||||
|
<div class="value" id="kpiTotal">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi pending">
|
||||||
|
<div class="label">Pending</div>
|
||||||
|
<div class="value" id="kpiPending">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi">
|
||||||
|
<div class="label">Exported</div>
|
||||||
|
<div class="value" id="kpiExported">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi failed">
|
||||||
|
<div class="label">Failed</div>
|
||||||
|
<div class="value" id="kpiFailed">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi posted">
|
||||||
|
<div class="label">Posted</div>
|
||||||
|
<div class="value" id="kpiPosted">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<div class="sync-kpi paid">
|
||||||
|
<div class="label">Paid</div>
|
||||||
|
<div class="value" id="kpiPaid">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Attention Items</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshAttention">Opdater</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Draft</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Invoice</th>
|
||||||
|
<th>Seneste Event</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="attentionBody">
|
||||||
|
<tr><td colspan="6" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Recent Events</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshEvents">Opdater</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="recentEventsList">
|
||||||
|
<div class="text-muted">Indlæser...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="eventsModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Draft Events</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input class="form-control form-control-sm" id="filterEventType" placeholder="event_type">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input class="form-control form-control-sm" id="filterFromStatus" placeholder="from_status">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input class="form-control form-control-sm" id="filterToStatus" placeholder="to_status">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="btnApplyEventFilters">Filtrer</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="btnClearEventFilters">Nulstil</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tid</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Fra</th>
|
||||||
|
<th>Til</th>
|
||||||
|
<th>Payload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="eventsModalBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<small class="text-muted" id="eventsPagerInfo"></small>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary" id="btnPrevEvents">Forrige</button>
|
||||||
|
<button class="btn btn-outline-secondary" id="btnNextEvents">Næste</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
selectedDraftId: null,
|
||||||
|
eventsLimit: 20,
|
||||||
|
eventsOffset: 0,
|
||||||
|
eventsTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const statusBadge = (status) => {
|
||||||
|
const s = (status || '').toLowerCase();
|
||||||
|
return `<span class="status-badge status-${s || 'pending'}">${s || 'pending'}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchJson = async (url, options = {}) => {
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
const data = await fetchJson('/api/v1/billing/drafts/sync-dashboard?limit=20');
|
||||||
|
const summary = data.summary || {};
|
||||||
|
|
||||||
|
el('kpiTotal').textContent = summary.total_count || 0;
|
||||||
|
el('kpiPending').textContent = summary.pending_count || 0;
|
||||||
|
el('kpiExported').textContent = summary.exported_count || 0;
|
||||||
|
el('kpiFailed').textContent = summary.failed_count || 0;
|
||||||
|
el('kpiPosted').textContent = summary.posted_count || 0;
|
||||||
|
el('kpiPaid').textContent = summary.paid_count || 0;
|
||||||
|
|
||||||
|
const attention = data.attention_items || [];
|
||||||
|
const tbody = el('attentionBody');
|
||||||
|
if (!attention.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Ingen attention items</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = attention.map(row => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">#${row.id} ${row.title || ''}</div>
|
||||||
|
<div class="text-muted small">Kunde ${row.customer_id || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td>${statusBadge(row.sync_status)}</td>
|
||||||
|
<td class="mono">${row.economic_order_number || '-'}</td>
|
||||||
|
<td class="mono">${row.economic_invoice_number || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="small">${row.latest_event_type || '-'}</div>
|
||||||
|
<div class="text-muted small">${row.latest_event_at || ''}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" data-open-events="${row.id}">Events</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = data.recent_events || [];
|
||||||
|
const list = el('recentEventsList');
|
||||||
|
if (!recent.length) {
|
||||||
|
list.innerHTML = '<div class="text-muted">Ingen events endnu.</div>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = recent.map(ev => `
|
||||||
|
<div class="event-card mb-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<strong>#${ev.draft_id} ${ev.event_type}</strong>
|
||||||
|
${statusBadge(ev.to_status || ev.sync_status || 'pending')}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">${ev.created_at || ''}</div>
|
||||||
|
<div class="small">${ev.draft_title || ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runReconcile = async (applyChanges) => {
|
||||||
|
await fetchJson('/api/v1/billing/drafts/reconcile-sync-status', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apply: applyChanges }),
|
||||||
|
});
|
||||||
|
await loadDashboard();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEventsForDraft = async () => {
|
||||||
|
if (!state.selectedDraftId) return;
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
limit: String(state.eventsLimit),
|
||||||
|
offset: String(state.eventsOffset),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventType = el('filterEventType').value.trim();
|
||||||
|
const fromStatus = el('filterFromStatus').value.trim();
|
||||||
|
const toStatus = el('filterToStatus').value.trim();
|
||||||
|
if (eventType) qs.set('event_type', eventType);
|
||||||
|
if (fromStatus) qs.set('from_status', fromStatus);
|
||||||
|
if (toStatus) qs.set('to_status', toStatus);
|
||||||
|
|
||||||
|
const data = await fetchJson(`/api/v1/ordre/drafts/${state.selectedDraftId}/sync-events?${qs.toString()}`);
|
||||||
|
const items = data.items || [];
|
||||||
|
state.eventsTotal = data.total || 0;
|
||||||
|
|
||||||
|
const body = el('eventsModalBody');
|
||||||
|
body.innerHTML = items.map(ev => `
|
||||||
|
<tr>
|
||||||
|
<td class="small">${ev.created_at || ''}</td>
|
||||||
|
<td class="mono">${ev.event_type || ''}</td>
|
||||||
|
<td>${ev.from_status || '-'}</td>
|
||||||
|
<td>${ev.to_status || '-'}</td>
|
||||||
|
<td><pre class="small mb-0 mono">${JSON.stringify(ev.event_payload || {}, null, 2)}</pre></td>
|
||||||
|
</tr>
|
||||||
|
`).join('') || '<tr><td colspan="5" class="text-center text-muted py-3">Ingen events</td></tr>';
|
||||||
|
|
||||||
|
const start = state.eventsOffset + 1;
|
||||||
|
const end = Math.min(state.eventsOffset + state.eventsLimit, state.eventsTotal);
|
||||||
|
el('eventsPagerInfo').textContent = state.eventsTotal ? `${start}-${end} af ${state.eventsTotal}` : '0 resultater';
|
||||||
|
|
||||||
|
el('btnPrevEvents').disabled = state.eventsOffset <= 0;
|
||||||
|
el('btnNextEvents').disabled = (state.eventsOffset + state.eventsLimit) >= state.eventsTotal;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (target.matches('[data-open-events]')) {
|
||||||
|
state.selectedDraftId = Number(target.getAttribute('data-open-events'));
|
||||||
|
state.eventsOffset = 0;
|
||||||
|
await loadEventsForDraft();
|
||||||
|
const modal = new bootstrap.Modal(el('eventsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btnRefreshAttention').addEventListener('click', loadDashboard);
|
||||||
|
el('btnRefreshEvents').addEventListener('click', loadDashboard);
|
||||||
|
el('btnPreviewReconcile').addEventListener('click', async () => runReconcile(false));
|
||||||
|
el('btnApplyReconcile').addEventListener('click', async () => runReconcile(true));
|
||||||
|
|
||||||
|
el('btnApplyEventFilters').addEventListener('click', async () => {
|
||||||
|
state.eventsOffset = 0;
|
||||||
|
await loadEventsForDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btnClearEventFilters').addEventListener('click', async () => {
|
||||||
|
el('filterEventType').value = '';
|
||||||
|
el('filterFromStatus').value = '';
|
||||||
|
el('filterToStatus').value = '';
|
||||||
|
state.eventsOffset = 0;
|
||||||
|
await loadEventsForDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btnPrevEvents').addEventListener('click', async () => {
|
||||||
|
state.eventsOffset = Math.max(0, state.eventsOffset - state.eventsLimit);
|
||||||
|
await loadEventsForDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btnNextEvents').addEventListener('click', async () => {
|
||||||
|
state.eventsOffset += state.eventsLimit;
|
||||||
|
await loadEventsForDraft();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadDashboard().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Kunne ikke indlæse sync dashboard.');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -45,3 +45,12 @@ async def templates_list_page(request: Request):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"title": "Templates"
|
"title": "Templates"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/billing/sync-dashboard", response_class=HTMLResponse)
|
||||||
|
async def billing_sync_dashboard_page(request: Request):
|
||||||
|
"""Operational sync dashboard for ordre_drafts lifecycle."""
|
||||||
|
return templates.TemplateResponse("billing/frontend/sync_dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"title": "Billing Sync Dashboard"
|
||||||
|
})
|
||||||
|
|||||||
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
20
app/bug_reports/backend/models.py
Normal file
20
app/bug_reports/backend/models.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportPayload(BaseModel):
|
||||||
|
actual: str = Field(..., min_length=3, max_length=8000)
|
||||||
|
expected: str = Field(..., min_length=3, max_length=8000)
|
||||||
|
screenshot_base64: Optional[str] = Field(default=None, max_length=25_000_000)
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
logs: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
|
extra_file_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
extra_file_base64: Optional[str] = Field(default=None, max_length=25_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
sag_id: int
|
||||||
|
case_url: str
|
||||||
|
message: str
|
||||||
212
app/bug_reports/backend/router.py
Normal file
212
app/bug_reports/backend/router.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from app.bug_reports.backend.models import BugReportPayload, BugReportResult
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve()
|
||||||
|
SAG_FILE_SUBDIR = "sag_files"
|
||||||
|
(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
||||||
|
# Expected format: data:image/png;base64,....
|
||||||
|
match = re.match(r"^data:([\w/+.-]+);base64,(.+)$", data_url or "", flags=re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 data URL")
|
||||||
|
|
||||||
|
content_type = match.group(1)
|
||||||
|
b64_data = match.group(2)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(b64_data, validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
|
||||||
|
|
||||||
|
return raw, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _store_raw_file(raw: bytes, filename: str) -> tuple[str, int]:
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
stored_name = f"{SAG_FILE_SUBDIR}/{uuid4().hex}_{safe_name}"
|
||||||
|
destination = UPLOAD_BASE_PATH / stored_name
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with destination.open("wb") as f:
|
||||||
|
f.write(raw)
|
||||||
|
|
||||||
|
return stored_name, len(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_sag_file_record(sag_id: int, filename: str, content_type: str, size_bytes: int, stored_name: str) -> None:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, filename, content_type, size_bytes, stored_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit(user_id: int) -> None:
|
||||||
|
if not _table_exists("bug_report_submissions"):
|
||||||
|
# If migration is not yet applied, fail-open to avoid blocking support workflows.
|
||||||
|
return
|
||||||
|
|
||||||
|
max_per_hour = max(1, int(settings.BUG_REPORT_MAX_PER_HOUR))
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM bug_report_submissions
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND created_at >= %s
|
||||||
|
""",
|
||||||
|
(user_id, datetime.utcnow() - timedelta(hours=1)),
|
||||||
|
)
|
||||||
|
count = int((row or {}).get("count") or 0)
|
||||||
|
if count >= max_per_hour:
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded for bug reports")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_customer_id() -> int:
|
||||||
|
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
||||||
|
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
|
||||||
|
if configured_row:
|
||||||
|
return int(configured_row["id"])
|
||||||
|
|
||||||
|
named_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM customers
|
||||||
|
WHERE LOWER(name) = LOWER(%s)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
("BMC Networks",),
|
||||||
|
)
|
||||||
|
if named_row:
|
||||||
|
return int(named_row["id"])
|
||||||
|
|
||||||
|
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
|
||||||
|
if fallback:
|
||||||
|
return int(fallback["id"])
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bug-reports", response_model=BugReportResult)
|
||||||
|
async def create_bug_report(payload: BugReportPayload, request: Request):
|
||||||
|
user_id = getattr(request.state, "user_id", None) or 1
|
||||||
|
|
||||||
|
_rate_limit(int(user_id))
|
||||||
|
|
||||||
|
title_seed = (payload.actual or "").strip().splitlines()[0][:80]
|
||||||
|
title = f"Bug: {title_seed or 'Ukendt fejl'}"
|
||||||
|
|
||||||
|
metadata_json = json.dumps(payload.metadata or {}, ensure_ascii=False, indent=2)
|
||||||
|
logs_preview = (payload.logs or [])[:50]
|
||||||
|
logs_json = json.dumps(logs_preview, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
description = (
|
||||||
|
"## Hvad gik galt\n"
|
||||||
|
f"{payload.actual.strip()}\n\n"
|
||||||
|
"## Hvad burde være sket\n"
|
||||||
|
f"{payload.expected.strip()}\n\n"
|
||||||
|
"## Metadata\n"
|
||||||
|
f"```json\n{metadata_json}\n```\n\n"
|
||||||
|
"## Log preview (seneste 50)\n"
|
||||||
|
f"```json\n{logs_json}\n```\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_id = _resolve_customer_id()
|
||||||
|
assigned_user_id: Optional[int] = settings.BUG_REPORT_AUTO_ASSIGN_USER_ID
|
||||||
|
|
||||||
|
created = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager
|
||||||
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
"bug_report",
|
||||||
|
"åben",
|
||||||
|
customer_id,
|
||||||
|
assigned_user_id,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create bug case")
|
||||||
|
|
||||||
|
sag_id = int(created[0]["id"])
|
||||||
|
|
||||||
|
# Attach screenshot if provided
|
||||||
|
if payload.screenshot_base64:
|
||||||
|
raw, content_type = _decode_data_url(payload.screenshot_base64)
|
||||||
|
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Screenshot too large")
|
||||||
|
|
||||||
|
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
|
||||||
|
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
|
||||||
|
|
||||||
|
# Attach logs as json file
|
||||||
|
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
stored_name, size = _store_raw_file(logs_raw, f"bugreport_{sag_id}_logs.json")
|
||||||
|
_create_sag_file_record(sag_id, "logs.json", "application/json", size, stored_name)
|
||||||
|
|
||||||
|
# Optional extra file
|
||||||
|
if payload.extra_file_base64 and payload.extra_file_name:
|
||||||
|
raw, content_type = _decode_data_url(payload.extra_file_base64)
|
||||||
|
if len(raw) > settings.BUG_REPORT_MAX_ATTACHMENT_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Extra file too large")
|
||||||
|
stored_name, size = _store_raw_file(raw, payload.extra_file_name)
|
||||||
|
_create_sag_file_record(sag_id, payload.extra_file_name, content_type, size, stored_name)
|
||||||
|
|
||||||
|
# Track submission for rate-limiting/audit
|
||||||
|
if _table_exists("bug_report_submissions"):
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO bug_report_submissions (sag_id, user_id, screenshot_attached)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, user_id, bool(payload.screenshot_base64)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Bug report case created: SAG-%s by user_id=%s", sag_id, user_id)
|
||||||
|
|
||||||
|
return BugReportResult(
|
||||||
|
success=True,
|
||||||
|
sag_id=sag_id,
|
||||||
|
case_url=f"/sag/{sag_id}/v3",
|
||||||
|
message="Fejl rapporteret og sag oprettet",
|
||||||
|
)
|
||||||
@ -88,8 +88,26 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
where_clauses.append(
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
"""
|
||||||
|
(
|
||||||
|
c.first_name ILIKE %s
|
||||||
|
OR c.last_name ILIKE %s
|
||||||
|
OR c.email ILIKE %s
|
||||||
|
OR c.phone ILIKE %s
|
||||||
|
OR c.mobile ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM contact_companies cc2
|
||||||
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||||
|
WHERE cc2.contact_id = c.id
|
||||||
|
AND cu2.name ILIKE %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like, like, like, like])
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
|
|||||||
@ -113,38 +113,80 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
where_clauses.append(
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
"""
|
||||||
|
(
|
||||||
|
c.first_name ILIKE %s
|
||||||
|
OR c.last_name ILIKE %s
|
||||||
|
OR c.email ILIKE %s
|
||||||
|
OR c.phone ILIKE %s
|
||||||
|
OR c.mobile ILIKE %s
|
||||||
|
OR c.user_company ILIKE %s
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM contact_companies cc2
|
||||||
|
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||||
|
WHERE cc2.contact_id = c.id
|
||||||
|
AND cu2.name ILIKE %s
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
like = f"%{search}%"
|
||||||
|
params.extend([like, like, like, like, like, like, like])
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
params.append(is_active)
|
params.append(is_active)
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
where_clauses.append(
|
||||||
|
"EXISTS (SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id AND cc.customer_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||||
|
|
||||||
# Count total (needs alias c for consistency)
|
# Count total (distinct id for consistency with optional filters/joins)
|
||||||
count_query = f"SELECT COUNT(*) as count FROM contacts c {where_sql}"
|
count_query = f"SELECT COUNT(DISTINCT c.id) as count FROM contacts c {where_sql}"
|
||||||
count_result = execute_query(count_query, tuple(params))
|
count_result = execute_query(count_query, tuple(params))
|
||||||
total = count_result[0]['count'] if count_result else 0
|
total = count_result[0]['count'] if count_result else 0
|
||||||
|
|
||||||
# Get contacts with company info
|
# Step 1: Fetch contacts only (stable pagination)
|
||||||
query = f"""
|
contacts_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
||||||
c.title, c.department, c.is_active, c.created_at, c.updated_at,
|
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
|
||||||
COUNT(DISTINCT cc.customer_id) as company_count,
|
|
||||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
|
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
||||||
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
||||||
{where_sql}
|
{where_sql}
|
||||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
ORDER BY c.last_name, c.first_name, c.id
|
||||||
c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
||||||
ORDER BY company_count DESC, c.last_name, c.first_name
|
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
params.extend([limit, offset])
|
contacts_params = list(params)
|
||||||
contacts = execute_query(query, tuple(params))
|
contacts_params.extend([limit, offset])
|
||||||
|
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
|
||||||
|
|
||||||
|
# Step 2: Enrich page contacts with aggregated company info
|
||||||
|
if contacts:
|
||||||
|
contact_ids = [row["id"] for row in contacts]
|
||||||
|
placeholders = ",".join(["%s"] * len(contact_ids))
|
||||||
|
companies_query = f"""
|
||||||
|
SELECT
|
||||||
|
cc.contact_id,
|
||||||
|
COUNT(DISTINCT cc.customer_id) AS company_count,
|
||||||
|
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
||||||
|
FROM contact_companies cc
|
||||||
|
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
||||||
|
WHERE cc.contact_id IN ({placeholders})
|
||||||
|
GROUP BY cc.contact_id
|
||||||
|
"""
|
||||||
|
company_rows = execute_query(companies_query, tuple(contact_ids)) or []
|
||||||
|
company_map = {row["contact_id"]: row for row in company_rows}
|
||||||
|
|
||||||
|
for contact in contacts:
|
||||||
|
info = company_map.get(contact["id"])
|
||||||
|
contact["company_count"] = int(info["company_count"]) if info and info.get("company_count") is not None else 0
|
||||||
|
contact["company_names"] = info.get("company_names") if info and info.get("company_names") else []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
@ -325,6 +367,114 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contacts/admin/backfill-company-links")
|
||||||
|
async def backfill_contact_company_links(dry_run: bool = Query(default=True)):
|
||||||
|
"""
|
||||||
|
Backfill missing contact_companies links by matching contacts.user_company to customers.name.
|
||||||
|
|
||||||
|
- Uses case-insensitive trimmed exact name matching
|
||||||
|
- Picks lowest customer ID if duplicate customer names exist
|
||||||
|
- Idempotent: will not create duplicate links
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Contacts that have a company name on the contact row.
|
||||||
|
contacts_with_company = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM contacts c
|
||||||
|
WHERE c.user_company IS NOT NULL
|
||||||
|
AND TRIM(c.user_company) <> ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contacts where the company name can be matched to a customer record.
|
||||||
|
matchable = execute_query_single(
|
||||||
|
"""
|
||||||
|
WITH company_match AS (
|
||||||
|
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
|
||||||
|
FROM customers
|
||||||
|
GROUP BY LOWER(TRIM(name))
|
||||||
|
)
|
||||||
|
SELECT COUNT(DISTINCT c.id)::int AS count
|
||||||
|
FROM contacts c
|
||||||
|
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
|
||||||
|
WHERE c.user_company IS NOT NULL
|
||||||
|
AND TRIM(c.user_company) <> ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contacts with no links at all (often the primary symptom).
|
||||||
|
unlinked = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM contacts c
|
||||||
|
WHERE c.user_company IS NOT NULL
|
||||||
|
AND TRIM(c.user_company) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"contacts_with_user_company": (contacts_with_company or {}).get("count", 0),
|
||||||
|
"matchable_contacts": (matchable or {}).get("count", 0),
|
||||||
|
"unlinked_contacts": (unlinked or {}).get("count", 0),
|
||||||
|
"message": "Dry run complete. Re-run with dry_run=false to insert links.",
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted = execute_query(
|
||||||
|
"""
|
||||||
|
WITH company_match AS (
|
||||||
|
SELECT LOWER(TRIM(name)) AS norm_name, MIN(id) AS customer_id
|
||||||
|
FROM customers
|
||||||
|
GROUP BY LOWER(TRIM(name))
|
||||||
|
),
|
||||||
|
candidates AS (
|
||||||
|
SELECT
|
||||||
|
c.id AS contact_id,
|
||||||
|
cm.customer_id,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM contact_companies cc1
|
||||||
|
WHERE cc1.contact_id = c.id
|
||||||
|
) THEN FALSE
|
||||||
|
ELSE TRUE
|
||||||
|
END AS is_primary
|
||||||
|
FROM contacts c
|
||||||
|
JOIN company_match cm ON LOWER(TRIM(c.user_company)) = cm.norm_name
|
||||||
|
WHERE c.user_company IS NOT NULL
|
||||||
|
AND TRIM(c.user_company) <> ''
|
||||||
|
)
|
||||||
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||||
|
SELECT contact_id, customer_id, is_primary, 'inferred_user_company'
|
||||||
|
FROM candidates c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM contact_companies cc
|
||||||
|
WHERE cc.contact_id = c.contact_id
|
||||||
|
AND cc.customer_id = c.customer_id
|
||||||
|
)
|
||||||
|
RETURNING contact_id, customer_id, is_primary
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
inserted_count = len(inserted or [])
|
||||||
|
logger.info("✅ Contact-company backfill inserted %s link(s)", inserted_count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": False,
|
||||||
|
"inserted": inserted_count,
|
||||||
|
"sample": (inserted or [])[:20],
|
||||||
|
"message": "Backfill completed",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed backfill_contact_company_links: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}/related-contacts")
|
@router.get("/contacts/{contact_id}/related-contacts")
|
||||||
async def get_related_contacts(contact_id: int):
|
async def get_related_contacts(contact_id: int):
|
||||||
"""Get contacts from the same companies as the contact (excluding itself)."""
|
"""Get contacts from the same companies as the contact (excluding itself)."""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,21 @@ logger = logging.getLogger(__name__)
|
|||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _users_column_exists(column_name: str) -> bool:
|
||||||
|
result = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'users'
|
||||||
|
AND column_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(column_name,),
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
@ -70,9 +85,11 @@ async def get_current_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Get additional user details from database
|
# Get additional user details from database
|
||||||
|
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
|
||||||
user_details = execute_query_single(
|
user_details = execute_query_single(
|
||||||
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
|
f"SELECT email, full_name, {is_2fa_expr} FROM users WHERE user_id = %s",
|
||||||
(user_id,))
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
|
|||||||
@ -15,6 +15,28 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_users_column_cache: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _users_column_exists(column_name: str) -> bool:
|
||||||
|
if column_name in _users_column_cache:
|
||||||
|
return _users_column_cache[column_name]
|
||||||
|
|
||||||
|
result = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'users'
|
||||||
|
AND column_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(column_name,),
|
||||||
|
)
|
||||||
|
exists = bool(result)
|
||||||
|
_users_column_cache[column_name] = exists
|
||||||
|
return exists
|
||||||
|
|
||||||
# JWT Settings
|
# JWT Settings
|
||||||
SECRET_KEY = settings.JWT_SECRET_KEY
|
SECRET_KEY = settings.JWT_SECRET_KEY
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
@ -26,6 +48,11 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"],
|
|||||||
class AuthService:
|
class AuthService:
|
||||||
"""Service for authentication and authorization"""
|
"""Service for authentication and authorization"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_2fa_supported() -> bool:
|
||||||
|
"""Return True only when required 2FA columns exist in users table."""
|
||||||
|
return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -89,6 +116,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
def setup_user_2fa(user_id: int, username: str) -> Dict:
|
||||||
"""Create and store a new TOTP secret (not enabled until verified)"""
|
"""Create and store a new TOTP secret (not enabled until verified)"""
|
||||||
|
if not AuthService.is_2fa_supported():
|
||||||
|
raise RuntimeError("2FA columns missing in users table")
|
||||||
|
|
||||||
secret = AuthService.generate_2fa_secret()
|
secret = AuthService.generate_2fa_secret()
|
||||||
execute_update(
|
execute_update(
|
||||||
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
@ -103,6 +133,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
"""Enable 2FA after verifying TOTP code"""
|
"""Enable 2FA after verifying TOTP code"""
|
||||||
|
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
|
||||||
|
return False
|
||||||
|
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
@ -123,6 +156,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
|
||||||
"""Disable 2FA after verifying TOTP code"""
|
"""Disable 2FA after verifying TOTP code"""
|
||||||
|
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
|
||||||
|
return False
|
||||||
|
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"SELECT totp_secret FROM users WHERE user_id = %s",
|
"SELECT totp_secret FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
@ -151,10 +187,11 @@ class AuthService:
|
|||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
execute_update(
|
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
|
||||||
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
execute_update(
|
||||||
(user_id,)
|
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
)
|
(user_id,)
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -256,13 +293,18 @@ class AuthService:
|
|||||||
request_username = (username or "").strip().lower()
|
request_username = (username or "").strip().lower()
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
|
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
|
||||||
|
totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
|
||||||
|
last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
|
||||||
|
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"""SELECT user_id, username, email, password_hash, full_name,
|
f"""SELECT user_id, username, email, password_hash, full_name,
|
||||||
is_active, is_superadmin, failed_login_attempts, locked_until,
|
is_active, is_superadmin, failed_login_attempts, locked_until,
|
||||||
is_2fa_enabled, totp_secret, last_2fa_at
|
{is_2fa_expr}, {totp_expr}, {last_2fa_expr}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = %s OR email = %s""",
|
WHERE username = %s OR email = %s""",
|
||||||
(username, username))
|
(username, username),
|
||||||
|
)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
# Shadow Admin fallback (only when no regular user matches)
|
# Shadow Admin fallback (only when no regular user matches)
|
||||||
@ -367,10 +409,11 @@ class AuthService:
|
|||||||
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
||||||
return None, "Invalid 2FA code"
|
return None, "Invalid 2FA code"
|
||||||
|
|
||||||
execute_update(
|
if _users_column_exists("last_2fa_at"):
|
||||||
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
execute_update(
|
||||||
(user['user_id'],)
|
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
)
|
(user['user_id'],)
|
||||||
|
)
|
||||||
|
|
||||||
# Success! Reset failed attempts and update last login
|
# Success! Reset failed attempts and update last login
|
||||||
execute_update(
|
execute_update(
|
||||||
@ -416,6 +459,9 @@ class AuthService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def is_user_2fa_enabled(user_id: int) -> bool:
|
def is_user_2fa_enabled(user_id: int) -> bool:
|
||||||
"""Check if user has 2FA enabled"""
|
"""Check if user has 2FA enabled"""
|
||||||
|
if not _users_column_exists("is_2fa_enabled"):
|
||||||
|
return False
|
||||||
|
|
||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
|
|||||||
@ -31,6 +31,11 @@ class Settings(BaseSettings):
|
|||||||
APIGW_TOKEN: str = ""
|
APIGW_TOKEN: str = ""
|
||||||
APIGW_TIMEOUT_SECONDS: int = 12
|
APIGW_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
|
# FirmaAPI (CVR company data)
|
||||||
|
FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1"
|
||||||
|
FIRMAAPI_API_KEY: str = ""
|
||||||
|
FIRMAAPI_TIMEOUT_SECONDS: int = 12
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||||
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
||||||
@ -70,6 +75,18 @@ class Settings(BaseSettings):
|
|||||||
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
|
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
|
||||||
NEXTCLOUD_ENCRYPTION_KEY: str = ""
|
NEXTCLOUD_ENCRYPTION_KEY: str = ""
|
||||||
|
|
||||||
|
# Links / Endpoints Module
|
||||||
|
LINKS_MODULE_ENABLED: bool = False
|
||||||
|
LINKS_READ_ONLY: bool = True
|
||||||
|
LINKS_DRY_RUN: bool = True
|
||||||
|
LINKS_DEAD_LINK_CHECK_ENABLED: bool = True
|
||||||
|
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60
|
||||||
|
LINKS_CHECK_TIMEOUT_SECONDS: int = 5
|
||||||
|
|
||||||
|
# Vaultwarden (Bitwarden-compatible)
|
||||||
|
VAULTWARDEN_BASE_URL: str = ""
|
||||||
|
VAULTWARDEN_API_TOKEN: str = ""
|
||||||
|
|
||||||
# Wiki.js Integration
|
# Wiki.js Integration
|
||||||
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
|
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
|
||||||
WIKI_API_TOKEN: str = ""
|
WIKI_API_TOKEN: str = ""
|
||||||
@ -89,6 +106,7 @@ class Settings(BaseSettings):
|
|||||||
IMAP_PASSWORD: str = ""
|
IMAP_PASSWORD: str = ""
|
||||||
IMAP_USE_SSL: bool = True
|
IMAP_USE_SSL: bool = True
|
||||||
IMAP_FOLDER: str = "INBOX"
|
IMAP_FOLDER: str = "INBOX"
|
||||||
|
IMAP_TEST_FOLDER: str = ""
|
||||||
IMAP_READ_ONLY: bool = True
|
IMAP_READ_ONLY: bool = True
|
||||||
|
|
||||||
# Microsoft Graph API (alternative to IMAP)
|
# Microsoft Graph API (alternative to IMAP)
|
||||||
@ -106,9 +124,12 @@ class Settings(BaseSettings):
|
|||||||
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
|
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
|
||||||
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
|
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
|
||||||
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
|
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
|
||||||
|
EMAIL_AUTO_CREATE_CASES_FROM_EMAIL: bool = False
|
||||||
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
||||||
|
EMAIL_PROCESS_ALLOW_FOLDER_OVERRIDE: bool = True
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
||||||
EMAIL_WORKFLOWS_ENABLED: bool = True
|
EMAIL_WORKFLOWS_ENABLED: bool = True
|
||||||
|
EMAIL_WORKFLOW_AUTORUN_ENABLED: bool = False
|
||||||
EMAIL_MAX_UPLOAD_SIZE_MB: int = 50 # Max file size for email uploads
|
EMAIL_MAX_UPLOAD_SIZE_MB: int = 50 # Max file size for email uploads
|
||||||
ALLOWED_EXTENSIONS: List[str] = ["pdf", "jpg", "jpeg", "png", "gif", "doc", "docx", "xls", "xlsx", "zip"] # Allowed file extensions for uploads
|
ALLOWED_EXTENSIONS: List[str] = ["pdf", "jpg", "jpeg", "png", "gif", "doc", "docx", "xls", "xlsx", "zip"] # Allowed file extensions for uploads
|
||||||
|
|
||||||
@ -143,6 +164,11 @@ class Settings(BaseSettings):
|
|||||||
TIMETRACKING_ROUND_INCREMENT: float = 0.5
|
TIMETRACKING_ROUND_INCREMENT: float = 0.5
|
||||||
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
|
TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
|
||||||
|
|
||||||
|
# Customer economic defaults
|
||||||
|
CUSTOMER_DEFAULT_MARGIN_PERCENT: float = 20.0
|
||||||
|
CUSTOMER_DEFAULT_INVOICE_FEE: float = 49.0
|
||||||
|
CUSTOMER_DEFAULT_HOURLY_RATE: float = 1200.0
|
||||||
|
|
||||||
# Time Tracking Module Safety Flags
|
# Time Tracking Module Safety Flags
|
||||||
TIMETRACKING_VTIGER_READ_ONLY: bool = True
|
TIMETRACKING_VTIGER_READ_ONLY: bool = True
|
||||||
TIMETRACKING_VTIGER_DRY_RUN: bool = True
|
TIMETRACKING_VTIGER_DRY_RUN: bool = True
|
||||||
@ -191,6 +217,15 @@ class Settings(BaseSettings):
|
|||||||
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
|
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
|
||||||
UPLOAD_DIR: str = "uploads" # Upload directory path
|
UPLOAD_DIR: str = "uploads" # Upload directory path
|
||||||
|
|
||||||
|
# Bug report capture
|
||||||
|
BUG_REPORT_ENABLED: bool = True
|
||||||
|
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
|
||||||
|
BUG_REPORT_MAX_PER_HOUR: int = 12
|
||||||
|
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
|
||||||
|
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
|
||||||
|
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
|
||||||
|
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
|
||||||
|
|
||||||
# Offsite Backup Settings (SFTP)
|
# Offsite Backup Settings (SFTP)
|
||||||
OFFSITE_ENABLED: bool = False
|
OFFSITE_ENABLED: bool = False
|
||||||
OFFSITE_WEEKLY_DAY: str = "sunday"
|
OFFSITE_WEEKLY_DAY: str = "sunday"
|
||||||
@ -227,13 +262,19 @@ class Settings(BaseSettings):
|
|||||||
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
REMINDERS_QUEUE_BATCH_SIZE: int = 10
|
||||||
|
|
||||||
# AnyDesk Remote Support Integration
|
# AnyDesk Remote Support Integration
|
||||||
|
ANYDESK_API_URL: str = "https://v1.api.anydesk.com:8081" # AnyDesk REST API base URL
|
||||||
ANYDESK_LICENSE_ID: str = ""
|
ANYDESK_LICENSE_ID: str = ""
|
||||||
ANYDESK_API_TOKEN: str = ""
|
ANYDESK_API_TOKEN: str = "" # API Password (HMAC-SHA1, not Bearer) from my.anydesk.com
|
||||||
ANYDESK_PASSWORD: str = ""
|
ANYDESK_PASSWORD: str = "" # Alias for ANYDESK_API_TOKEN
|
||||||
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
|
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
|
||||||
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
|
||||||
ANYDESK_TIMEOUT_SECONDS: int = 30
|
ANYDESK_TIMEOUT_SECONDS: int = 30
|
||||||
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
|
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
|
||||||
|
ANYDESK_LOCAL_SESSIONS_URL: str = "http://localhost:8001/anydesk/sessions"
|
||||||
|
ANYDESK_LOCAL_SYNC_ENABLED: bool = True
|
||||||
|
ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES: int = 15
|
||||||
|
ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS: int = 20
|
||||||
|
ANYDESK_LOCAL_SYNC_DRY_RUN: bool = False
|
||||||
|
|
||||||
# Telefoni (Yealink) Integration
|
# Telefoni (Yealink) Integration
|
||||||
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
|
||||||
@ -264,6 +305,19 @@ class Settings(BaseSettings):
|
|||||||
SMS_SENDER: str = "BMC Networks"
|
SMS_SENDER: str = "BMC Networks"
|
||||||
SMS_WEBHOOK_SECRET: str = ""
|
SMS_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
|
# FedEx Integration
|
||||||
|
FEDEX_ENABLED: bool = False
|
||||||
|
FEDEX_READ_ONLY: bool = True
|
||||||
|
FEDEX_DRY_RUN: bool = True
|
||||||
|
FEDEX_API_KEY: str = ""
|
||||||
|
FEDEX_API_SECRET: str = ""
|
||||||
|
FEDEX_ACCOUNT_NUMBER: str = ""
|
||||||
|
FEDEX_BASE_URL: str = ""
|
||||||
|
FEDEX_TIMEOUT_SECONDS: int = 20
|
||||||
|
|
||||||
|
# Bottom bar module
|
||||||
|
BOTTOM_BAR_ENABLED: bool = False
|
||||||
|
|
||||||
# Dev-only shortcuts
|
# Dev-only shortcuts
|
||||||
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
|
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ PostgreSQL connection and helpers using psycopg2
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from psycopg2.pool import SimpleConnectionPool
|
from psycopg2.pool import SimpleConnectionPool
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -128,3 +129,34 @@ def execute_query_single(query: str, params: tuple = None):
|
|||||||
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
|
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
return result[0] if result and len(result) > 0 else None
|
return result[0] if result and len(result) > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def table_has_column(table_name: str, column_name: str, schema: str = "public") -> bool:
|
||||||
|
"""Return whether a column exists in the current database schema."""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s
|
||||||
|
AND table_name = %s
|
||||||
|
AND column_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(schema, table_name, column_name),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Schema lookup failed for %s.%s.%s: %s",
|
||||||
|
schema,
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import asyncio
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.cvr_service import get_cvr_service
|
from app.services.cvr_service import get_cvr_service
|
||||||
from app.services.customer_activity_logger import CustomerActivityLogger
|
from app.services.customer_activity_logger import CustomerActivityLogger
|
||||||
@ -23,6 +23,42 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_customer_supplier_tag(customer_id: int) -> None:
|
||||||
|
"""Ensure linked customers are tagged as suppliers."""
|
||||||
|
try:
|
||||||
|
tag = execute_query_single(
|
||||||
|
"SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
|
||||||
|
)
|
||||||
|
if tag and tag.get("id") is not None:
|
||||||
|
tag_id = int(tag["id"])
|
||||||
|
else:
|
||||||
|
created = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO tags (name, type, description, color, is_active)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (name, type)
|
||||||
|
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
|
||||||
|
)
|
||||||
|
tag_id = int(created["id"]) if created and created.get("id") is not None else None
|
||||||
|
|
||||||
|
if not tag_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
("customer", customer_id, tag_id),
|
||||||
|
)
|
||||||
|
except Exception as tag_error:
|
||||||
|
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
|
||||||
|
|
||||||
|
|
||||||
# Pydantic Models
|
# Pydantic Models
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -81,7 +117,8 @@ async def list_customers(
|
|||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
search: Optional[str] = Query(default=None),
|
search: Optional[str] = Query(default=None),
|
||||||
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
|
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
|
||||||
is_active: Optional[bool] = Query(default=None)
|
is_active: Optional[bool] = Query(default=None),
|
||||||
|
vip: Optional[bool] = Query(default=None)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List customers with pagination and filtering
|
List customers with pagination and filtering
|
||||||
@ -138,6 +175,19 @@ async def list_customers(
|
|||||||
query += " AND c.is_active = %s"
|
query += " AND c.is_active = %s"
|
||||||
params.append(is_active)
|
params.append(is_active)
|
||||||
|
|
||||||
|
# Add VIP filter (customer tagged with "vip")
|
||||||
|
if vip is True:
|
||||||
|
query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'customer'
|
||||||
|
AND et.entity_id = c.id
|
||||||
|
AND LOWER(t.name) = 'vip'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
query += """
|
query += """
|
||||||
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
@ -170,6 +220,18 @@ async def list_customers(
|
|||||||
count_query += " AND is_active = %s"
|
count_query += " AND is_active = %s"
|
||||||
count_params.append(is_active)
|
count_params.append(is_active)
|
||||||
|
|
||||||
|
if vip is True:
|
||||||
|
count_query += """
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'customer'
|
||||||
|
AND et.entity_id = customers.id
|
||||||
|
AND LOWER(t.name) = 'vip'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
count_result = execute_query_single(count_query, tuple(count_params))
|
count_result = execute_query_single(count_query, tuple(count_params))
|
||||||
total = count_result['total'] if count_result else 0
|
total = count_result['total'] if count_result else 0
|
||||||
|
|
||||||
@ -491,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
|
|||||||
"supplier": supplier
|
"supplier": supplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/vendors")
|
||||||
|
async def list_customer_vendors(customer_id: int):
|
||||||
|
"""List vendors linked to a customer."""
|
||||||
|
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.customer_id,
|
||||||
|
l.vendor_id,
|
||||||
|
l.relationship_type,
|
||||||
|
l.created_at,
|
||||||
|
l.updated_at,
|
||||||
|
v.name AS vendor_name,
|
||||||
|
v.email AS vendor_email,
|
||||||
|
v.cvr_number AS vendor_cvr
|
||||||
|
FROM customer_vendor_links l
|
||||||
|
JOIN vendors v ON v.id = l.vendor_id
|
||||||
|
WHERE l.customer_id = %s
|
||||||
|
ORDER BY v.name ASC, l.id ASC
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
) or []
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/customers/{customer_id}/vendors/{vendor_id}")
|
||||||
|
async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")):
|
||||||
|
"""Create or update a customer-vendor link."""
|
||||||
|
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
|
||||||
|
if not vendor:
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
rel = str(relationship_type or "supplier").strip().lower()
|
||||||
|
if rel not in {"supplier", "reseller", "partner"}:
|
||||||
|
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
|
||||||
|
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (customer_id, vendor_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
relationship_type = EXCLUDED.relationship_type,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(customer_id, vendor_id, rel),
|
||||||
|
)
|
||||||
|
_ensure_customer_supplier_tag(int(customer_id))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/customers/{customer_id}/vendors/{vendor_id}")
|
||||||
|
async def unlink_customer_from_vendor(customer_id: int, vendor_id: int):
|
||||||
|
"""Remove customer-vendor link."""
|
||||||
|
deleted = execute_update(
|
||||||
|
"DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s",
|
||||||
|
(customer_id, vendor_id),
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Link not found")
|
||||||
|
return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id}
|
||||||
|
|
||||||
@router.post("/customers")
|
@router.post("/customers")
|
||||||
async def create_customer(customer: CustomerCreate):
|
async def create_customer(customer: CustomerCreate):
|
||||||
"""Create a new customer"""
|
"""Create a new customer"""
|
||||||
@ -1070,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create contact
|
normalized_email = (contact.email or "").strip().lower() or None
|
||||||
|
existing_contact = None
|
||||||
|
|
||||||
|
# Prefer exact email match scoped to this customer, then global email match.
|
||||||
|
if normalized_email:
|
||||||
|
existing_contact = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT c.*
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||||
|
WHERE cc.customer_id = %s
|
||||||
|
AND LOWER(COALESCE(c.email, '')) = %s
|
||||||
|
ORDER BY c.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(customer_id, normalized_email),
|
||||||
|
)
|
||||||
|
if not existing_contact:
|
||||||
|
existing_contact = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT c.*
|
||||||
|
FROM contacts c
|
||||||
|
WHERE LOWER(COALESCE(c.email, '')) = %s
|
||||||
|
ORDER BY c.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(normalized_email,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback dedupe by full name within same customer when email is missing.
|
||||||
|
if not existing_contact and not normalized_email:
|
||||||
|
existing_contact = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT c.*
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||||
|
WHERE cc.customer_id = %s
|
||||||
|
AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
|
||||||
|
AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
|
||||||
|
ORDER BY c.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(customer_id, contact.first_name, contact.last_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_contact:
|
||||||
|
contact_id = int(existing_contact["id"])
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (contact_id, customer_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
|
||||||
|
role = COALESCE(contact_companies.role, EXCLUDED.role)
|
||||||
|
""",
|
||||||
|
(contact_id, customer_id, bool(contact.is_primary), contact.role),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
|
||||||
|
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
|
||||||
|
|
||||||
|
# Create contact when no reusable match exists.
|
||||||
contact_id = execute_insert(
|
contact_id = execute_insert(
|
||||||
"""INSERT INTO contacts
|
"""INSERT INTO contacts
|
||||||
(first_name, last_name, email, phone, mobile, title, department)
|
(first_name, last_name, email, phone, mobile, title, department)
|
||||||
@ -1079,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
(
|
(
|
||||||
contact.first_name,
|
contact.first_name,
|
||||||
contact.last_name,
|
contact.last_name,
|
||||||
contact.email,
|
normalized_email,
|
||||||
contact.phone,
|
contact.phone,
|
||||||
contact.mobile,
|
contact.mobile,
|
||||||
contact.title,
|
contact.title,
|
||||||
@ -1088,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Link contact to customer
|
# Link contact to customer
|
||||||
execute_insert(
|
execute_update(
|
||||||
"""INSERT INTO contact_companies
|
"""INSERT INTO contact_companies
|
||||||
(contact_id, customer_id, is_primary, role)
|
(contact_id, customer_id, is_primary, role)
|
||||||
VALUES (%s, %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s)
|
||||||
(contact_id, customer_id, contact.is_primary, contact.role)
|
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
|
||||||
|
(contact_id, customer_id, bool(contact.is_primary), contact.role)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
@ -20,7 +21,10 @@ async def customer_detail_page(request: Request, customer_id: int):
|
|||||||
"""
|
"""
|
||||||
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
|
return templates.TemplateResponse("customers/frontend/customer_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"customer_id": customer_id
|
"customer_id": customer_id,
|
||||||
|
"customer_default_margin_percent": settings.CUSTOMER_DEFAULT_MARGIN_PERCENT,
|
||||||
|
"customer_default_invoice_fee": settings.CUSTOMER_DEFAULT_INVOICE_FEE,
|
||||||
|
"customer_default_hourly_rate": settings.CUSTOMER_DEFAULT_HOURLY_RATE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -245,6 +245,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-light btn-sm" href="/links?customer_id={{ customer_id }}" title="Se links/endpoints for denne kunde">
|
||||||
|
<i class="bi bi-link-45deg me-2"></i>Links
|
||||||
|
</a>
|
||||||
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
|
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
||||||
</button>
|
</button>
|
||||||
@ -309,6 +312,11 @@
|
|||||||
<i class="bi bi-people"></i>Kontakter
|
<i class="bi bi-people"></i>Kontakter
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#cases">
|
||||||
|
<i class="bi bi-list-check"></i>Sager
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
||||||
<i class="bi bi-chat-left-text"></i>Kontakt
|
<i class="bi bi-chat-left-text"></i>Kontakt
|
||||||
@ -344,6 +352,11 @@
|
|||||||
<i class="bi bi-hdd"></i>Hardware
|
<i class="bi bi-hdd"></i>Hardware
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#links">
|
||||||
|
<i class="bi bi-link-45deg"></i>Links
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item d-none" id="nextcloudTabNav">
|
<li class="nav-item d-none" id="nextcloudTabNav">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
|
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
|
||||||
<i class="bi bi-cloud"></i>Nextcloud
|
<i class="bi bi-cloud"></i>Nextcloud
|
||||||
@ -430,6 +443,26 @@
|
|||||||
<span class="info-label">EAN-nummer</span>
|
<span class="info-label">EAN-nummer</span>
|
||||||
<span class="info-value" id="ean">-</span>
|
<span class="info-value" id="ean">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Standard avance</span>
|
||||||
|
<span class="info-value" id="standardMarginPercent">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Standard timepris</span>
|
||||||
|
<span class="info-value" id="standardHourlyRate">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Særlig fragtpris</span>
|
||||||
|
<span class="info-value" id="specialFreightPrice">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Leverandørservice</span>
|
||||||
|
<span class="info-value" id="supplierServiceEnrolled">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Faktureringsgebyr</span>
|
||||||
|
<span class="info-value" id="invoiceFeeAmount">-</span>
|
||||||
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Spærret</span>
|
<span class="info-label">Spærret</span>
|
||||||
<span class="info-value" id="barred">-</span>
|
<span class="info-value" id="barred">-</span>
|
||||||
@ -485,6 +518,32 @@
|
|||||||
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="fw-bold mb-0">Leverandørrelationer</h5>
|
||||||
|
<span class="badge bg-primary" id="customerVendorsCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="customerVendorSearch"
|
||||||
|
placeholder="Søg leverandør (navn, CVR, domæne)"
|
||||||
|
oninput="searchVendorsForCustomer(this.value)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-md-end">
|
||||||
|
<small class="text-muted">Knyt kunde til leverandør</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="customerVendorSearchResults" class="list-group mb-3" style="display:none;"></div>
|
||||||
|
<div id="customerVendorLinksContainer" class="list-group mb-2"></div>
|
||||||
|
<div id="customerVendorLinksEmpty" class="text-muted small">Ingen linked leverandører endnu.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -519,6 +578,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cases Tab -->
|
||||||
|
<div class="tab-pane fade" id="cases">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Kundens sager</h5>
|
||||||
|
<small class="text-muted">Alle sager knyttet til denne kunde</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-sm btn-primary" href="/sag/new?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret sag
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/sag?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn i sagsmodul
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" id="customerCasesContainer">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="customerCasesEmpty" class="text-center py-5 text-muted d-none">
|
||||||
|
Ingen sager fundet for denne kunde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Kontakt Tab -->
|
<!-- Kontakt Tab -->
|
||||||
<div class="tab-pane fade" id="kontakt">
|
<div class="tab-pane fade" id="kontakt">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -748,6 +849,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Links Tab -->
|
||||||
|
<div class="tab-pane fade" id="links">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
|
||||||
|
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" id="customerLinksContainer">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="customerLinksEmpty" class="text-center py-5 text-muted d-none">
|
||||||
|
Ingen links fundet for denne kunde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nextcloud Tab -->
|
<!-- Nextcloud Tab -->
|
||||||
<div class="tab-pane fade d-none" id="nextcloud">
|
<div class="tab-pane fade d-none" id="nextcloud">
|
||||||
{% include "modules/nextcloud/templates/tab.html" %}
|
{% include "modules/nextcloud/templates/tab.html" %}
|
||||||
@ -906,6 +1043,43 @@
|
|||||||
<input type="text" class="form-control" id="editCity">
|
<input type="text" class="form-control" id="editCity">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Economic defaults -->
|
||||||
|
<div class="col-12 mt-4">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-3">
|
||||||
|
<i class="bi bi-currency-exchange me-2"></i>Økonomiske standarder
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="editStandardMarginPercent" class="form-label">Standard avance (%)</label>
|
||||||
|
<input type="number" class="form-control" id="editStandardMarginPercent" min="0" max="1000" step="0.01">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="editStandardHourlyRate" class="form-label">Standard timepris (DKK)</label>
|
||||||
|
<input type="number" class="form-control" id="editStandardHourlyRate" min="0" step="0.01">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="editSpecialFreightPrice" class="form-label">Særlig fragtpris (DKK)</label>
|
||||||
|
<input type="number" class="form-control" id="editSpecialFreightPrice" min="0" step="0.01">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="editInvoiceFeeAmount" class="form-label">Faktureringsgebyr (DKK)</label>
|
||||||
|
<input type="number" class="form-control" id="editInvoiceFeeAmount" min="0" step="0.01">
|
||||||
|
<div class="form-text">Sæt 0 for at slå gebyr fra på ordren.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editSupplierServiceEnrolled">
|
||||||
|
<label class="form-check-label" for="editSupplierServiceEnrolled">
|
||||||
|
Tilmeldt leverandørservice
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="col-12 mt-4">
|
<div class="col-12 mt-4">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
@ -1202,6 +1376,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||||
|
const customerDefaultMarginPercent = Number({{ customer_default_margin_percent | tojson }} || 20);
|
||||||
|
const customerDefaultInvoiceFee = Number({{ customer_default_invoice_fee | tojson }} || 49);
|
||||||
|
const customerDefaultHourlyRate = Number({{ customer_default_hourly_rate | tojson }} || 1200);
|
||||||
let customerData = null;
|
let customerData = null;
|
||||||
let pipelineStages = [];
|
let pipelineStages = [];
|
||||||
let allTagsCache = [];
|
let allTagsCache = [];
|
||||||
@ -1210,6 +1387,11 @@ let customerKontaktFilter = 'all';
|
|||||||
|
|
||||||
let eventListenersAdded = false;
|
let eventListenersAdded = false;
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (eventListenersAdded) {
|
if (eventListenersAdded) {
|
||||||
console.log('Event listeners already added, skipping...');
|
console.log('Event listeners already added, skipping...');
|
||||||
@ -1226,6 +1408,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const casesTab = document.querySelector('a[href="#cases"]');
|
||||||
|
if (casesTab) {
|
||||||
|
casesTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerCases();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
||||||
if (kontaktTab) {
|
if (kontaktTab) {
|
||||||
kontaktTab.addEventListener('shown.bs.tab', () => {
|
kontaktTab.addEventListener('shown.bs.tab', () => {
|
||||||
@ -1266,6 +1455,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linksTab = document.querySelector('a[href="#links"]');
|
||||||
|
if (linksTab) {
|
||||||
|
linksTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerLinks();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Load activity when tab is shown
|
// Load activity when tab is shown
|
||||||
const activityTab = document.querySelector('a[href="#activity"]');
|
const activityTab = document.querySelector('a[href="#activity"]');
|
||||||
if (activityTab) {
|
if (activityTab) {
|
||||||
@ -1313,6 +1509,7 @@ async function loadCustomer() {
|
|||||||
|
|
||||||
await loadUtilityCompany();
|
await loadUtilityCompany();
|
||||||
await loadCustomerTags();
|
await loadCustomerTags();
|
||||||
|
await loadCustomerVendorLinks();
|
||||||
|
|
||||||
// Check data consistency
|
// Check data consistency
|
||||||
await checkDataConsistency();
|
await checkDataConsistency();
|
||||||
@ -1323,6 +1520,141 @@ async function loadCustomer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCustomerVendorLinks() {
|
||||||
|
const container = document.getElementById('customerVendorLinksContainer');
|
||||||
|
const empty = document.getElementById('customerVendorLinksEmpty');
|
||||||
|
const countEl = document.getElementById('customerVendorsCount');
|
||||||
|
|
||||||
|
if (!container || !empty || !countEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers/${customerId}/vendors`);
|
||||||
|
if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks');
|
||||||
|
const links = await response.json();
|
||||||
|
const rows = Array.isArray(links) ? links : [];
|
||||||
|
|
||||||
|
countEl.textContent = String(rows.length);
|
||||||
|
if (!rows.length) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
container.innerHTML = rows.map((row) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
|
||||||
|
${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/vendors/${row.vendor_id}">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="unlinkVendorFromCustomer(${row.vendor_id})">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer vendor links:', error);
|
||||||
|
container.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vendorSearchDebounce = null;
|
||||||
|
async function searchVendorsForCustomer(query) {
|
||||||
|
const resultsEl = document.getElementById('customerVendorSearchResults');
|
||||||
|
if (!resultsEl) return;
|
||||||
|
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
resultsEl.style.display = 'none';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce);
|
||||||
|
vendorSearchDebounce = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`);
|
||||||
|
if (!response.ok) throw new Error('Søgning fejlede');
|
||||||
|
const vendors = await response.json();
|
||||||
|
const rows = Array.isArray(vendors) ? vendors : [];
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen leverandører fundet</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = rows.map((v) => `
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(v.name || '')}</div>
|
||||||
|
<div class="small text-muted">${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="linkVendorToCustomerFromUI(${v.id})">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor search failed:', error);
|
||||||
|
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkVendorToCustomerFromUI(vendorId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.detail || 'Kunne ikke linke leverandør');
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.getElementById('customerVendorSearch');
|
||||||
|
const results = document.getElementById('customerVendorSearchResults');
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (results) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
results.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCustomerVendorLinks();
|
||||||
|
await loadCustomerTags();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Kunne ikke linke leverandør');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkVendorFromCustomer(vendorId) {
|
||||||
|
if (!confirm('Fjern link mellem kunde og leverandør?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.detail || 'Kunne ikke fjerne link');
|
||||||
|
}
|
||||||
|
await loadCustomerVendorLinks();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Kunne ikke fjerne link');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function displayCustomer(customer) {
|
function displayCustomer(customer) {
|
||||||
// Update page title
|
// Update page title
|
||||||
document.title = `${customer.name} - BMC Hub`;
|
document.title = `${customer.name} - BMC Hub`;
|
||||||
@ -1402,6 +1734,22 @@ function displayCustomer(customer) {
|
|||||||
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
document.getElementById('vatZone').textContent = customer.vat_zone || '-';
|
||||||
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
document.getElementById('currency').textContent = customer.currency_code || 'DKK';
|
||||||
document.getElementById('ean').textContent = customer.ean || '-';
|
document.getElementById('ean').textContent = customer.ean || '-';
|
||||||
|
const standardMargin = customer.standard_margin_percent ?? customerDefaultMarginPercent;
|
||||||
|
const invoiceFee = customer.invoice_fee_amount ?? customerDefaultInvoiceFee;
|
||||||
|
const standardHourlyRate = customer.standard_hourly_rate ?? customerDefaultHourlyRate;
|
||||||
|
const freight = customer.special_freight_price;
|
||||||
|
|
||||||
|
document.getElementById('standardMarginPercent').textContent = `${Number(standardMargin).toFixed(2)} %`;
|
||||||
|
document.getElementById('standardHourlyRate').textContent = `${Number(standardHourlyRate).toFixed(2)} DKK`;
|
||||||
|
document.getElementById('specialFreightPrice').textContent = (freight === null || typeof freight === 'undefined')
|
||||||
|
? '-'
|
||||||
|
: `${Number(freight).toFixed(2)} DKK`;
|
||||||
|
document.getElementById('supplierServiceEnrolled').innerHTML = customer.supplier_service_enrolled
|
||||||
|
? '<span class="badge bg-success">Tilmeldt</span>'
|
||||||
|
: '<span class="badge bg-secondary">Ikke tilmeldt</span>';
|
||||||
|
document.getElementById('invoiceFeeAmount').textContent = Number(invoiceFee) === 0
|
||||||
|
? '0,00 DKK (deaktiveret)'
|
||||||
|
: `${Number(invoiceFee).toFixed(2)} DKK`;
|
||||||
document.getElementById('barred').innerHTML = customer.barred
|
document.getElementById('barred').innerHTML = customer.barred
|
||||||
? '<span class="badge bg-danger">Ja</span>'
|
? '<span class="badge bg-danger">Ja</span>'
|
||||||
: '<span class="badge bg-success">Nej</span>';
|
: '<span class="badge bg-success">Nej</span>';
|
||||||
@ -2315,6 +2663,107 @@ async function loadContacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCustomerCases() {
|
||||||
|
const container = document.getElementById('customerCasesContainer');
|
||||||
|
const empty = document.getElementById('customerCasesEmpty');
|
||||||
|
|
||||||
|
if (!container || !empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/sag?customer_id=${customerId}`);
|
||||||
|
const cases = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(cases?.detail || 'Kunne ikke hente kundens sager');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.isArray(cases) ? cases : [];
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.map((item) => {
|
||||||
|
const id = Number(item.id) || 0;
|
||||||
|
const title = escapeHtml(item.titel || '-');
|
||||||
|
const statusRaw = String(item.status || 'ukendt');
|
||||||
|
const statusLabel = escapeHtml(statusRaw);
|
||||||
|
const priority = escapeHtml(item.priority || 'normal');
|
||||||
|
const created = item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '-';
|
||||||
|
|
||||||
|
const statusClass =
|
||||||
|
statusRaw.toLowerCase() === 'lukket' ? 'bg-success-subtle text-success-emphasis' :
|
||||||
|
statusRaw.toLowerCase() === 'afventer' ? 'bg-warning-subtle text-warning-emphasis' :
|
||||||
|
'bg-primary-subtle text-primary-emphasis';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/sag/${id}/v3" class="fw-semibold text-decoration-none">#${id}</a></td>
|
||||||
|
<td>${title}</td>
|
||||||
|
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
||||||
|
<td><span class="badge bg-light text-dark border">${priority}</span></td>
|
||||||
|
<td>${created}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}/v3" title="Åbn sag">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>SagsID</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Prioritet</th>
|
||||||
|
<th>Oprettet</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer cases:', error);
|
||||||
|
container.innerHTML = `<div class="alert alert-danger mb-0"><i class="bi bi-exclamation-circle me-2"></i>${escapeHtml(error.message || 'Fejl ved hentning af sager')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let subscriptionsLoaded = false;
|
let subscriptionsLoaded = false;
|
||||||
|
|
||||||
async function loadSubscriptions() {
|
async function loadSubscriptions() {
|
||||||
@ -2376,6 +2825,7 @@ async function loadCustomerPipeline() {
|
|||||||
|
|
||||||
let customerHardware = [];
|
let customerHardware = [];
|
||||||
let hardwareLocationsById = {};
|
let hardwareLocationsById = {};
|
||||||
|
let customerLinks = [];
|
||||||
|
|
||||||
function getHardwareGroupLabel(item, groupBy) {
|
function getHardwareGroupLabel(item, groupBy) {
|
||||||
if (groupBy === 'location') {
|
if (groupBy === 'location') {
|
||||||
@ -2548,6 +2998,109 @@ document.addEventListener('change', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderCustomerLinksTable() {
|
||||||
|
const container = document.getElementById('customerLinksContainer');
|
||||||
|
const empty = document.getElementById('customerLinksEmpty');
|
||||||
|
if (!container || !empty) return;
|
||||||
|
|
||||||
|
if (!customerLinks.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${customerLinks.map((link) => {
|
||||||
|
const type = (link.type || 'http').toUpperCase();
|
||||||
|
const target = link.url || link.host || '-';
|
||||||
|
const environment = link.environment || 'prod';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">${escapeHtml(link.name || 'Uden navn')}</td>
|
||||||
|
<td><span class="badge text-bg-secondary">${escapeHtml(type)}</span></td>
|
||||||
|
<td>${escapeHtml(target)}</td>
|
||||||
|
<td>${escapeHtml(environment)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id=${customerId}">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerLinks() {
|
||||||
|
const container = document.getElementById('customerLinksContainer');
|
||||||
|
const empty = document.getElementById('customerLinksEmpty');
|
||||||
|
if (!container || !empty) return;
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Mål</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4"><div class="spinner-border text-primary"></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/links?customer_id=${customerId}`, {
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders()
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error('Ingen adgang til links. Log ind igen eller tjek links.read permission.');
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Links-endpoint ikke fundet (modul ikke aktivt eller API ikke genstartet).');
|
||||||
|
}
|
||||||
|
throw new Error('Kunne ikke hente links');
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = await response.json();
|
||||||
|
customerLinks = Array.isArray(links) ? links : [];
|
||||||
|
renderCustomerLinksTable();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load customer links:', error);
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
empty.textContent = error.message || 'Kunne ikke hente links for kunden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCustomerPipeline(opportunities) {
|
function renderCustomerPipeline(opportunities) {
|
||||||
const tbody = document.getElementById('customerOpportunitiesTable');
|
const tbody = document.getElementById('customerOpportunitiesTable');
|
||||||
if (!opportunities || opportunities.length === 0) {
|
if (!opportunities || opportunities.length === 0) {
|
||||||
@ -3422,6 +3975,11 @@ function editCustomer() {
|
|||||||
document.getElementById('editAddress').value = customerData.address || '';
|
document.getElementById('editAddress').value = customerData.address || '';
|
||||||
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
document.getElementById('editPostalCode').value = customerData.postal_code || '';
|
||||||
document.getElementById('editCity').value = customerData.city || '';
|
document.getElementById('editCity').value = customerData.city || '';
|
||||||
|
document.getElementById('editStandardMarginPercent').value = (customerData.standard_margin_percent ?? customerDefaultMarginPercent);
|
||||||
|
document.getElementById('editStandardHourlyRate').value = (customerData.standard_hourly_rate ?? customerDefaultHourlyRate);
|
||||||
|
document.getElementById('editSpecialFreightPrice').value = customerData.special_freight_price ?? '';
|
||||||
|
document.getElementById('editInvoiceFeeAmount').value = (customerData.invoice_fee_amount ?? customerDefaultInvoiceFee);
|
||||||
|
document.getElementById('editSupplierServiceEnrolled').checked = !!customerData.supplier_service_enrolled;
|
||||||
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
document.getElementById('editIsActive').checked = customerData.is_active !== false;
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
@ -3430,6 +3988,11 @@ function editCustomer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCustomerEdit() {
|
async function saveCustomerEdit() {
|
||||||
|
const marginValue = document.getElementById('editStandardMarginPercent').value;
|
||||||
|
const hourlyRateValue = document.getElementById('editStandardHourlyRate').value;
|
||||||
|
const freightValue = document.getElementById('editSpecialFreightPrice').value;
|
||||||
|
const invoiceFeeValue = document.getElementById('editInvoiceFeeAmount').value;
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
name: document.getElementById('editName').value,
|
name: document.getElementById('editName').value,
|
||||||
cvr_number: document.getElementById('editCvrNumber').value || null,
|
cvr_number: document.getElementById('editCvrNumber').value || null,
|
||||||
@ -3443,6 +4006,11 @@ async function saveCustomerEdit() {
|
|||||||
address: document.getElementById('editAddress').value || null,
|
address: document.getElementById('editAddress').value || null,
|
||||||
postal_code: document.getElementById('editPostalCode').value || null,
|
postal_code: document.getElementById('editPostalCode').value || null,
|
||||||
city: document.getElementById('editCity').value || null,
|
city: document.getElementById('editCity').value || null,
|
||||||
|
standard_margin_percent: marginValue === '' ? customerDefaultMarginPercent : Number(marginValue),
|
||||||
|
standard_hourly_rate: hourlyRateValue === '' ? customerDefaultHourlyRate : Number(hourlyRateValue),
|
||||||
|
special_freight_price: freightValue === '' ? null : Number(freightValue),
|
||||||
|
supplier_service_enrolled: document.getElementById('editSupplierServiceEnrolled').checked,
|
||||||
|
invoice_fee_amount: invoiceFeeValue === '' ? customerDefaultInvoiceFee : Number(invoiceFeeValue),
|
||||||
is_active: document.getElementById('editIsActive').checked
|
is_active: document.getElementById('editIsActive').checked
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,53 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
.customers-toolbar {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 460px;
|
||||||
|
width: min(46vw, 460px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap .header-search {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.45rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear.d-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
@ -19,26 +66,56 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lookup-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.customers-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-slot {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
<div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="fw-bold mb-1">Kunder</h2>
|
<h2 class="fw-bold mb-1">Kunder</h2>
|
||||||
<p class="text-muted mb-0">Administrer dine kunder</p>
|
<p class="text-muted mb-0">Administrer dine kunder</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
<div class="toolbar-search-slot">
|
||||||
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
|
<div class="search-wrap">
|
||||||
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
|
<input type="search" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, kontakt eller e-mail..." autocomplete="off" spellcheck="false">
|
||||||
|
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 d-flex gap-2">
|
<div class="mb-4 d-flex gap-2">
|
||||||
<button class="filter-btn active">Alle Kunder</button>
|
<button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
|
||||||
<button class="filter-btn">Aktive</button>
|
<button class="filter-btn" data-filter="active" type="button">Aktive</button>
|
||||||
<button class="filter-btn">Inaktive</button>
|
<button class="filter-btn" data-filter="inactive" type="button">Inaktive</button>
|
||||||
<button class="filter-btn">VIP</button>
|
<button class="filter-btn" data-filter="vip" type="button">VIP</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -73,55 +150,391 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createCustomerModalLabel">Opret ny kunde</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<form id="createCustomerForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="createCustomerCvr">CVR</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="createCustomerCvr" placeholder="fx 24256790" inputmode="numeric" maxlength="8">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="lookupCvrBtn">Hent</button>
|
||||||
|
</div>
|
||||||
|
<div class="lookup-status mt-1" id="lookupCvrStatus">Indtast CVR og klik Hent for autofyld.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="createCustomerName">Virksomhedsnavn *</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerName" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerEmail">E-mail</label>
|
||||||
|
<input type="email" class="form-control" id="createCustomerEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerInvoiceEmail">Faktura e-mail</label>
|
||||||
|
<input type="email" class="form-control" id="createCustomerInvoiceEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerPhone">Telefon</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerPhone">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="createCustomerWebsite">Website</label>
|
||||||
|
<input type="url" class="form-control" id="createCustomerWebsite" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="createCustomerAddress">Adresse</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerAddress">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label" for="createCustomerPostalCode">Postnr.</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerPostalCode">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label" for="createCustomerCity">By</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerCity">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="createCustomerCountry">Land</label>
|
||||||
|
<input type="text" class="form-control" id="createCustomerCountry" value="DK">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 d-flex align-items-end">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="createCustomerIsActive" checked>
|
||||||
|
<label class="form-check-label" for="createCustomerIsActive">Kunden er aktiv</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="createCustomerSubmitBtn">
|
||||||
|
<span class="submit-label">Opret kunde</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
let totalCustomers = 0;
|
let totalCustomers = 0;
|
||||||
let searchTerm = '';
|
let searchTerm = '';
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
let currentRequestController = null;
|
||||||
|
let lastLoadedQueryKey = '';
|
||||||
|
let createCustomerModal = null;
|
||||||
|
let activeFilter = 'all';
|
||||||
|
|
||||||
// Load customers on page load
|
// Load customers on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
|
||||||
|
|
||||||
// Setup search with debounce
|
// Setup search with debounce
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
|
|
||||||
|
const triggerSearch = () => {
|
||||||
|
const nextSearchTerm = searchInput.value.trim();
|
||||||
|
if (nextSearchTerm === searchTerm) {
|
||||||
|
toggleClearButton(nextSearchTerm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTerm = nextSearchTerm;
|
||||||
|
toggleClearButton(searchTerm);
|
||||||
|
loadCustomers(1);
|
||||||
|
};
|
||||||
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
|
toggleClearButton(e.target.value.trim());
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
searchTerm = e.target.value;
|
triggerSearch();
|
||||||
loadCustomers(1);
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
if (!searchInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.value = '';
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
triggerSearch();
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('createCustomerForm').addEventListener('submit', createCustomer);
|
||||||
|
document.getElementById('lookupCvrBtn').addEventListener('click', lookupCvrAndAutofill);
|
||||||
|
document.getElementById('createCustomerCvr').addEventListener('input', onCvrInput);
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const nextFilter = btn.dataset.filter || 'all';
|
||||||
|
if (nextFilter === activeFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeFilter = nextFilter;
|
||||||
|
syncFilterButtons();
|
||||||
|
lastLoadedQueryKey = '';
|
||||||
|
loadCustomers(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('createCustomerModal').addEventListener('hidden.bs.modal', () => {
|
||||||
|
resetCreateCustomerForm();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onCvrInput(e) {
|
||||||
|
const digits = String(e.target.value || '').replace(/\D/g, '').slice(0, 8);
|
||||||
|
e.target.value = digits;
|
||||||
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLookupStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('lookupCvrStatus');
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.toggle('text-danger', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupCvrAndAutofill() {
|
||||||
|
const cvrInput = document.getElementById('createCustomerCvr');
|
||||||
|
const lookupBtn = document.getElementById('lookupCvrBtn');
|
||||||
|
const cvr = String(cvrInput.value || '').replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (cvr.length !== 8) {
|
||||||
|
setLookupStatus('CVR skal være præcis 8 cifre.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupBtn.disabled = true;
|
||||||
|
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
|
||||||
|
setLookupStatus('Henter data fra FirmaAPI...', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/cvr/${cvr}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
setLookupStatus('CVR blev ikke fundet.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
applyCustomerAutofill(data || {});
|
||||||
|
setLookupStatus('CVR-data hentet og felter autofyldt.', false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CVR lookup failed:', error);
|
||||||
|
setLookupStatus(`Kunne ikke hente CVR-data: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
lookupBtn.disabled = false;
|
||||||
|
lookupBtn.textContent = 'Hent';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomerAutofill(data) {
|
||||||
|
if (data.name) document.getElementById('createCustomerName').value = data.name;
|
||||||
|
if (data.email) document.getElementById('createCustomerEmail').value = data.email;
|
||||||
|
if (data.phone) document.getElementById('createCustomerPhone').value = data.phone;
|
||||||
|
if (data.address) document.getElementById('createCustomerAddress').value = data.address;
|
||||||
|
if (data.city) document.getElementById('createCustomerCity').value = data.city;
|
||||||
|
if (data.postal_code || data.zipcode) {
|
||||||
|
document.getElementById('createCustomerPostalCode').value = data.postal_code || data.zipcode;
|
||||||
|
}
|
||||||
|
if (data.country) document.getElementById('createCustomerCountry').value = data.country;
|
||||||
|
if (data.website) document.getElementById('createCustomerWebsite').value = data.website;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateCustomerPayload() {
|
||||||
|
const email = document.getElementById('createCustomerEmail').value.trim();
|
||||||
|
const domain = email.includes('@') ? email.split('@').pop().toLowerCase() : null;
|
||||||
|
|
||||||
|
const cleanValue = (id) => {
|
||||||
|
const value = document.getElementById(id).value.trim();
|
||||||
|
return value || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: document.getElementById('createCustomerName').value.trim(),
|
||||||
|
cvr_number: cleanValue('createCustomerCvr'),
|
||||||
|
email: email || null,
|
||||||
|
email_domain: domain,
|
||||||
|
phone: cleanValue('createCustomerPhone'),
|
||||||
|
address: cleanValue('createCustomerAddress'),
|
||||||
|
city: cleanValue('createCustomerCity'),
|
||||||
|
postal_code: cleanValue('createCustomerPostalCode'),
|
||||||
|
country: cleanValue('createCustomerCountry') || 'DK',
|
||||||
|
website: cleanValue('createCustomerWebsite'),
|
||||||
|
is_active: document.getElementById('createCustomerIsActive').checked,
|
||||||
|
invoice_email: cleanValue('createCustomerInvoiceEmail'),
|
||||||
|
mobile_phone: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomer(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('createCustomerSubmitBtn');
|
||||||
|
const submitLabel = submitBtn.querySelector('.submit-label');
|
||||||
|
const payload = buildCreateCustomerPayload();
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
setLookupStatus('Virksomhedsnavn er påkrævet.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitLabel.textContent = 'Opretter...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await response.json();
|
||||||
|
createCustomerModal.hide();
|
||||||
|
searchTerm = '';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
toggleClearButton('');
|
||||||
|
lastLoadedQueryKey = '';
|
||||||
|
await loadCustomers(1);
|
||||||
|
|
||||||
|
if (created && created.id) {
|
||||||
|
window.location.href = `/customers/${created.id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create customer:', error);
|
||||||
|
setLookupStatus(`Oprettelse fejlede: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitLabel.textContent = 'Opret kunde';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreateCustomerForm() {
|
||||||
|
const form = document.getElementById('createCustomerForm');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('createCustomerCountry').value = 'DK';
|
||||||
|
document.getElementById('createCustomerIsActive').checked = true;
|
||||||
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFilterButtons() {
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.filter === activeFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCustomers(page = 1) {
|
async function loadCustomers(page = 1) {
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (currentRequestController) {
|
||||||
|
currentRequestController.abort();
|
||||||
|
}
|
||||||
|
currentRequestController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
url += `&search=${encodeURIComponent(searchTerm)}`;
|
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||||
}
|
}
|
||||||
const response = await fetch(url);
|
|
||||||
|
if (activeFilter === 'active') {
|
||||||
|
url += '&is_active=true';
|
||||||
|
} else if (activeFilter === 'inactive') {
|
||||||
|
url += '&is_active=false';
|
||||||
|
} else if (activeFilter === 'vip') {
|
||||||
|
url += '&vip=true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKey = `${page}|${searchTerm}|${activeFilter}`;
|
||||||
|
if (queryKey === lastLoadedQueryKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal: currentRequestController.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
lastLoadedQueryKey = queryKey;
|
||||||
totalCustomers = data.total;
|
totalCustomers = data.total;
|
||||||
renderCustomers(data.customers);
|
renderCustomers(data.customers);
|
||||||
renderPagination();
|
renderPagination();
|
||||||
updateCount();
|
updateCount();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Error loading customers:', error);
|
console.error('Error loading customers:', error);
|
||||||
document.getElementById('customersTableBody').innerHTML = `
|
document.getElementById('customersTableBody').innerHTML = `
|
||||||
<tr><td colspan="6" class="text-center text-danger py-5">
|
<tr><td colspan="6" class="text-center text-danger py-5">
|
||||||
❌ Fejl ved indlæsning: ${error.message}
|
❌ Fejl ved indlæsning: ${error.message}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
`;
|
`;
|
||||||
|
} finally {
|
||||||
|
currentRequestController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleClearButton(value) {
|
||||||
|
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function renderCustomers(customers) {
|
function renderCustomers(customers) {
|
||||||
const tbody = document.getElementById('customersTableBody');
|
const tbody = document.getElementById('customersTableBody');
|
||||||
|
|
||||||
@ -139,6 +552,13 @@ function renderCustomers(customers) {
|
|||||||
const statusBadge = customer.is_active ?
|
const statusBadge = customer.is_active ?
|
||||||
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
||||||
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
||||||
|
const safeInitials = escapeHtml(initials);
|
||||||
|
const safeName = escapeHtml(customer.name);
|
||||||
|
const safeAddress = escapeHtml(customer.address);
|
||||||
|
const safeContactName = escapeHtml(customer.contact_name);
|
||||||
|
const safeContactPhone = escapeHtml(customer.contact_phone);
|
||||||
|
const safeCvr = escapeHtml(customer.cvr_number);
|
||||||
|
const safeEmail = escapeHtml(customer.email);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
||||||
@ -146,21 +566,21 @@ function renderCustomers(customers) {
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
||||||
style="width: 40px; height: 40px; color: var(--accent);">
|
style="width: 40px; height: 40px; color: var(--accent);">
|
||||||
${initials}
|
${safeInitials}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold">${customer.name || '-'}</div>
|
<div class="fw-bold">${safeName}</div>
|
||||||
<div class="small text-muted">${customer.address || '-'}</div>
|
<div class="small text-muted">${safeAddress}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-medium">${customer.contact_name || '-'}</div>
|
<div class="fw-medium">${safeContactName}</div>
|
||||||
<div class="small text-muted">${customer.contact_phone || '-'}</div>
|
<div class="small text-muted">${safeContactPhone}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">${customer.cvr_number || '-'}</td>
|
<td class="text-muted">${safeCvr}</td>
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td class="text-muted">${customer.email || '-'}</td>
|
<td class="text-muted">${safeEmail}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
||||||
@ -236,6 +656,11 @@ function renderPagination() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
function updateCount() {
|
||||||
|
if (totalCustomers === 0) {
|
||||||
|
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const start = (currentPage - 1) * pageSize + 1;
|
const start = (currentPage - 1) * pageSize + 1;
|
||||||
const end = Math.min(currentPage * pageSize, totalCustomers);
|
const end = Math.min(currentPage * pageSize, totalCustomers);
|
||||||
document.getElementById('customerCount').textContent =
|
document.getElementById('customerCount').textContent =
|
||||||
|
|||||||
@ -289,7 +289,7 @@ async function createOpportunity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToDetail(id) {
|
function goToDetail(id) {
|
||||||
window.location.href = `/sag/${id}`;
|
window.location.href = `/sag/${id}/v3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value, currency) {
|
function formatCurrency(value, currency) {
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import io
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
@ -32,6 +36,91 @@ class MissionUptimeWebhook(BaseModel):
|
|||||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionCameraConfigUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
camera_name: Optional[str] = None
|
||||||
|
feed_url: Optional[str] = None
|
||||||
|
spotlight_seconds: Optional[int] = 20
|
||||||
|
|
||||||
|
|
||||||
|
class MissionCameraMotionWebhook(BaseModel):
|
||||||
|
camera_name: Optional[str] = None
|
||||||
|
motion: Optional[bool] = True
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
snapshot_url: Optional[str] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionAccessPinUpdate(BaseModel):
|
||||||
|
pin: str = Field(..., min_length=4, max_length=10)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionTemperatureWebhook(BaseModel):
|
||||||
|
sensor_id: Optional[str] = None
|
||||||
|
sensor_name: Optional[str] = None
|
||||||
|
temperature: float
|
||||||
|
unit: Optional[str] = "°C"
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectCreatePayload(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = "planned"
|
||||||
|
score: Optional[int] = 0
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
ended_at: Optional[datetime] = None
|
||||||
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectUpdatePayload(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
score: Optional[int] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
ended_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectMilestonePayload(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = "active"
|
||||||
|
target_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectMilestoneUpdatePayload(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
target_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectBlockerPayload(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = "open"
|
||||||
|
severity: Optional[str] = "medium"
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectBlockerUpdatePayload(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
severity: Optional[str] = None
|
||||||
|
resolved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MissionProjectLinkCasePayload(BaseModel):
|
||||||
|
sag_id: int
|
||||||
|
project_milestone_id: Optional[int] = None
|
||||||
|
is_project_blocker: Optional[bool] = False
|
||||||
|
project_task_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
||||||
for name in names:
|
for name in names:
|
||||||
value = request.query_params.get(name)
|
value = request.query_params.get(name)
|
||||||
@ -128,21 +217,397 @@ def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_feed_url(candidate: Optional[str]) -> bool:
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlparse(candidate.strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return parsed.scheme in {"http", "https", "rtsp"} and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_authenticated_user(request: Request) -> Dict[str, Any]:
|
||||||
|
token = None
|
||||||
|
auth_header = (request.headers.get("authorization") or "").strip()
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
token = (request.cookies.get("access_token") or "").strip()
|
||||||
|
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload or payload.get("scope") == "mission_pin":
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
user_id = payload.get("sub") or payload.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_access_pin(pin: str) -> bool:
|
||||||
|
return pin.isdigit() and 4 <= len(pin) <= 10
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_mjpeg_frames(feed_url: str, target_fps: float = 5.0):
|
||||||
|
"""Transcode camera frames to MJPEG for browser playback."""
|
||||||
|
try:
|
||||||
|
import av
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ PyAV import failed for camera stream: %s", exc)
|
||||||
|
raise HTTPException(status_code=503, detail="PyAV ikke installeret på serveren")
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"rtsp_transport": "tcp",
|
||||||
|
"fflags": "nobuffer",
|
||||||
|
"flags": "low_delay",
|
||||||
|
"stimeout": "5000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary = b"frame"
|
||||||
|
frame_interval = 1.0 / max(1.0, float(target_fps))
|
||||||
|
last_emit = 0.0
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
container = av.open(feed_url, options=options)
|
||||||
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
||||||
|
if video_stream is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Feed indeholder ingen video stream")
|
||||||
|
|
||||||
|
for frame in container.decode(video=0):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_emit < frame_interval:
|
||||||
|
continue
|
||||||
|
last_emit = now
|
||||||
|
|
||||||
|
image = frame.to_image()
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="JPEG", quality=80)
|
||||||
|
jpeg = buffer.getvalue()
|
||||||
|
|
||||||
|
yield (
|
||||||
|
b"--" + boundary + b"\r\n"
|
||||||
|
+ b"Content-Type: image/jpeg\r\n"
|
||||||
|
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
|
||||||
|
+ jpeg
|
||||||
|
+ b"\r\n"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Camera MJPEG stream failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="Kunne ikke åbne kamera stream")
|
||||||
|
finally:
|
||||||
|
if container is not None:
|
||||||
|
try:
|
||||||
|
container.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_camera_stream(feed_url: str) -> Dict[str, Any]:
|
||||||
|
"""Attempt opening and decoding one frame to provide actionable diagnostics."""
|
||||||
|
try:
|
||||||
|
import av
|
||||||
|
except Exception:
|
||||||
|
return {"ok": False, "detail": "PyAV ikke installeret på serveren"}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"rtsp_transport": "tcp",
|
||||||
|
"fflags": "nobuffer",
|
||||||
|
"flags": "low_delay",
|
||||||
|
"stimeout": "5000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
container = av.open(feed_url, options=options)
|
||||||
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
||||||
|
if video_stream is None:
|
||||||
|
return {"ok": False, "detail": "Feed indeholder ingen video stream"}
|
||||||
|
|
||||||
|
frame_found = False
|
||||||
|
for _ in container.decode(video=0):
|
||||||
|
frame_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not frame_found:
|
||||||
|
return {"ok": False, "detail": "Ingen frames modtaget fra kamera"}
|
||||||
|
|
||||||
|
return {"ok": True, "detail": "Stream OK"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "detail": f"Kamera stream fejl: {exc}"}
|
||||||
|
finally:
|
||||||
|
if container is not None:
|
||||||
|
try:
|
||||||
|
container.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mission/state")
|
@router.get("/mission/state")
|
||||||
async def get_mission_state():
|
async def get_mission_state():
|
||||||
return MissionService.get_state()
|
return MissionService.get_state()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/projects")
|
||||||
|
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
|
||||||
|
return {
|
||||||
|
"projects": MissionService.get_projects(limit=limit),
|
||||||
|
"summary": MissionService.get_projects_state_payload(limit=limit).get("summary", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/projects/workload")
|
||||||
|
async def get_mission_projects_workload(limit: int = Query(120, ge=1, le=500)):
|
||||||
|
return {"workload": MissionService.get_project_workload(limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/projects/{project_id}")
|
||||||
|
async def get_mission_project_detail(project_id: int):
|
||||||
|
project = MissionService.get_project_detail(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Projekt ikke fundet")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/projects")
|
||||||
|
async def create_mission_project(request: Request, payload: MissionProjectCreatePayload):
|
||||||
|
user_payload = _require_authenticated_user(request)
|
||||||
|
actor_user_id = user_payload.get("sub") or user_payload.get("user_id")
|
||||||
|
try:
|
||||||
|
actor_user_id = int(actor_user_id) if actor_user_id is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
actor_user_id = None
|
||||||
|
|
||||||
|
project = MissionService.create_project(payload.model_dump(mode="json"), actor_user_id=actor_user_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=400, detail="Kunne ikke oprette projekt")
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("project_created", project)
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/mission/projects/{project_id}")
|
||||||
|
async def update_mission_project(project_id: int, request: Request, payload: MissionProjectUpdatePayload):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
project = MissionService.update_project(project_id, payload.model_dump(mode="json", exclude_none=True))
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Projekt ikke fundet")
|
||||||
|
|
||||||
|
event_name = "project_status_changed" if "status" in payload.model_dump(exclude_none=True) else "project_updated"
|
||||||
|
await mission_ws_manager.broadcast(event_name, project)
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/projects/{project_id}/milestones")
|
||||||
|
async def create_mission_project_milestone(project_id: int, request: Request, payload: MissionProjectMilestonePayload):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
milestone = MissionService.add_project_milestone(project_id, payload.model_dump(mode="json"))
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=400, detail="Kunne ikke oprette milepael")
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone})
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return milestone
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/mission/projects/{project_id}/milestones/{milestone_id}")
|
||||||
|
async def update_mission_project_milestone(
|
||||||
|
project_id: int,
|
||||||
|
milestone_id: int,
|
||||||
|
request: Request,
|
||||||
|
payload: MissionProjectMilestoneUpdatePayload,
|
||||||
|
):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
milestone = MissionService.update_project_milestone(
|
||||||
|
project_id,
|
||||||
|
milestone_id,
|
||||||
|
payload.model_dump(mode="json", exclude_none=True),
|
||||||
|
)
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milepael ikke fundet")
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("project_milestone_updated", {"project_id": project_id, "milestone": milestone})
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return milestone
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/projects/{project_id}/blockers")
|
||||||
|
async def create_mission_project_blocker(project_id: int, request: Request, payload: MissionProjectBlockerPayload):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
blocker = MissionService.add_project_blocker(project_id, payload.model_dump(mode="json"))
|
||||||
|
if not blocker:
|
||||||
|
raise HTTPException(status_code=400, detail="Kunne ikke oprette blocker")
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("project_blocked", {"project_id": project_id, "blocker": blocker})
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return blocker
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/mission/projects/{project_id}/blockers/{blocker_id}")
|
||||||
|
async def update_mission_project_blocker(
|
||||||
|
project_id: int,
|
||||||
|
blocker_id: int,
|
||||||
|
request: Request,
|
||||||
|
payload: MissionProjectBlockerUpdatePayload,
|
||||||
|
):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
blocker = MissionService.update_project_blocker(
|
||||||
|
project_id,
|
||||||
|
blocker_id,
|
||||||
|
payload.model_dump(mode="json", exclude_none=True),
|
||||||
|
)
|
||||||
|
if not blocker:
|
||||||
|
raise HTTPException(status_code=404, detail="Blocker ikke fundet")
|
||||||
|
|
||||||
|
blocker_status = str(blocker.get("status") or "").lower()
|
||||||
|
event_name = "project_unblocked" if blocker_status in {"resolved", "cancelled"} else "project_blocked"
|
||||||
|
await mission_ws_manager.broadcast(event_name, {"project_id": project_id, "blocker": blocker})
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return blocker
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/projects/{project_id}/link-case")
|
||||||
|
async def link_case_to_mission_project(project_id: int, request: Request, payload: MissionProjectLinkCasePayload):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
linked_case = MissionService.link_case_to_project(project_id, payload.model_dump(mode="json"))
|
||||||
|
if not linked_case:
|
||||||
|
raise HTTPException(status_code=404, detail="Sag eller projekt ikke fundet")
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("project_task_assigned", {"project_id": project_id, "case": linked_case})
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return linked_case
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/camera/mjpeg")
|
||||||
|
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
|
||||||
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
||||||
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Kamera feed er ikke aktiveret")
|
||||||
|
if not feed_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Kamera feed URL mangler")
|
||||||
|
if not _is_valid_feed_url(feed_url):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig kamera feed URL")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_iter_mjpeg_frames(feed_url=feed_url, target_fps=fps),
|
||||||
|
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||||
|
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/camera/status")
|
||||||
|
async def mission_camera_status():
|
||||||
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
||||||
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return {"ok": False, "detail": "Kamera feed er ikke aktiveret", "enabled": False}
|
||||||
|
if not feed_url:
|
||||||
|
return {"ok": False, "detail": "Kamera feed URL mangler", "enabled": True}
|
||||||
|
if not _is_valid_feed_url(feed_url):
|
||||||
|
return {"ok": False, "detail": "Ugyldig kamera feed URL", "enabled": True}
|
||||||
|
|
||||||
|
probe = _probe_camera_stream(feed_url)
|
||||||
|
return {
|
||||||
|
"ok": bool(probe.get("ok")),
|
||||||
|
"detail": probe.get("detail") or "Ukendt status",
|
||||||
|
"enabled": True,
|
||||||
|
"feed_scheme": feed_url.split(":", 1)[0].lower() if ":" in feed_url else "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mission/camera/config")
|
||||||
|
async def update_mission_camera_config(config: MissionCameraConfigUpdate):
|
||||||
|
feed_url = (config.feed_url or "").strip()
|
||||||
|
camera_name = (config.camera_name or "Mission Kamera").strip() or "Mission Kamera"
|
||||||
|
spotlight_seconds = int(config.spotlight_seconds or 20)
|
||||||
|
spotlight_seconds = max(5, min(spotlight_seconds, 120))
|
||||||
|
|
||||||
|
if feed_url and not _is_valid_feed_url(feed_url):
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldig feed URL. Brug rtsp/http/https")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, 'mission', 'Enable one camera feed in Mission Control', 'boolean', true),
|
||||||
|
(%s, %s, 'mission', 'Camera name for Mission Control', 'string', true),
|
||||||
|
(%s, %s, 'mission', 'Camera feed URL for Mission Control', 'string', true),
|
||||||
|
(%s, %s, 'mission', 'Camera spotlight duration in seconds for motion events', 'integer', true)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"mission_camera_enabled",
|
||||||
|
"true" if config.enabled else "false",
|
||||||
|
"mission_camera_name",
|
||||||
|
camera_name,
|
||||||
|
"mission_camera_feed_url",
|
||||||
|
feed_url,
|
||||||
|
"mission_camera_spotlight_seconds",
|
||||||
|
str(spotlight_seconds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"camera": {
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"feed_url": feed_url,
|
||||||
|
"spotlight_seconds": spotlight_seconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mission/access-pin")
|
||||||
|
async def update_mission_access_pin(request: Request, payload: MissionAccessPinUpdate):
|
||||||
|
_require_authenticated_user(request)
|
||||||
|
|
||||||
|
new_pin = (payload.pin or "").strip()
|
||||||
|
if not _is_valid_access_pin(new_pin):
|
||||||
|
raise HTTPException(status_code=400, detail="PIN skal være 4-10 cifre")
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, 'mission', 'Access PIN for Mission Control kiosk mode', 'string', false)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
("mission_access_pin", new_pin),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "Mission PIN opdateret"}
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/mission/ws")
|
@router.websocket("/mission/ws")
|
||||||
async def mission_ws(websocket: WebSocket):
|
async def mission_ws(websocket: WebSocket):
|
||||||
token = websocket.query_params.get("token")
|
token = websocket.query_params.get("token")
|
||||||
auth_header = (websocket.headers.get("authorization") or "").strip()
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
||||||
if not token and auth_header.lower().startswith("bearer "):
|
if not token and auth_header.lower().startswith("bearer "):
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
if not token:
|
|
||||||
token = (websocket.cookies.get("access_token") or "").strip() or None
|
|
||||||
|
|
||||||
payload = AuthService.verify_token(token) if token else None
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload:
|
||||||
|
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
|
||||||
|
if not payload:
|
||||||
|
mission_pin_cookie_token = (websocket.cookies.get("mission_pin_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(mission_pin_cookie_token) if mission_pin_cookie_token else None
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
@ -453,3 +918,126 @@ async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request
|
|||||||
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
||||||
|
|
||||||
return {"status": "ok", "normalized": normalized}
|
return {"status": "ok", "normalized": normalized}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/webhook/camera/motion")
|
||||||
|
async def mission_camera_motion_webhook(
|
||||||
|
payload: MissionCameraMotionWebhook,
|
||||||
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
_validate_mission_webhook_token(request, token)
|
||||||
|
|
||||||
|
raw_payload = dict(payload.payload or {})
|
||||||
|
motion_detected = bool(payload.motion)
|
||||||
|
if payload.event_type and str(payload.event_type).strip().lower() in {"no_motion", "idle", "clear"}:
|
||||||
|
motion_detected = False
|
||||||
|
|
||||||
|
camera_name = (payload.camera_name or MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera").strip()
|
||||||
|
event_timestamp = payload.timestamp or datetime.utcnow()
|
||||||
|
event_timestamp_iso = event_timestamp.isoformat()
|
||||||
|
snapshot_url = (payload.snapshot_url or "").strip() or None
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast(
|
||||||
|
"camera_motion",
|
||||||
|
{
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"motion": motion_detected,
|
||||||
|
"timestamp": event_timestamp_iso,
|
||||||
|
"snapshot_url": snapshot_url,
|
||||||
|
"payload": raw_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"motion": motion_detected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/webhook/environment/temperature")
|
||||||
|
async def mission_environment_temperature_webhook(
|
||||||
|
payload: MissionTemperatureWebhook,
|
||||||
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
_validate_mission_webhook_token(request, token)
|
||||||
|
|
||||||
|
sensor_id = (payload.sensor_id or "").strip() or None
|
||||||
|
sensor_name = (payload.sensor_name or "").strip() or sensor_id or "Temperatur"
|
||||||
|
unit = (payload.unit or "°C").strip() or "°C"
|
||||||
|
timestamp = payload.timestamp or datetime.utcnow()
|
||||||
|
|
||||||
|
raw_payload = dict(payload.payload or {})
|
||||||
|
reading = {
|
||||||
|
"sensor_id": sensor_id,
|
||||||
|
"sensor_name": sensor_name,
|
||||||
|
"temperature": float(payload.temperature),
|
||||||
|
"unit": unit,
|
||||||
|
"timestamp": timestamp.isoformat(),
|
||||||
|
"payload": raw_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = MissionService.parse_json_setting("mission_environment_readings", [])
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
merged: list[Dict[str, Any]] = []
|
||||||
|
replaced = False
|
||||||
|
for item in existing:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_sensor_id = str(item.get("sensor_id") or "").strip() or None
|
||||||
|
item_sensor_name = str(item.get("sensor_name") or "").strip()
|
||||||
|
|
||||||
|
# Keep one latest entry per sensor when possible.
|
||||||
|
if sensor_id and item_sensor_id == sensor_id and not replaced:
|
||||||
|
merged.append(reading)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (not sensor_id) and item_sensor_name and item_sensor_name == sensor_name and not replaced:
|
||||||
|
merged.append(reading)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged.append(item)
|
||||||
|
|
||||||
|
if not replaced:
|
||||||
|
merged.insert(0, reading)
|
||||||
|
|
||||||
|
merged = merged[:12]
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, 'mission', 'Latest environment sensor readings for Mission Control', 'json', true)
|
||||||
|
ON CONFLICT (key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
("mission_environment_readings", json.dumps(merged, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
event_row = MissionService.insert_event(
|
||||||
|
event_type="environment_temperature",
|
||||||
|
title=f"Temperatur {sensor_name}: {payload.temperature:.1f}{unit}",
|
||||||
|
severity="info",
|
||||||
|
source="home_assistant",
|
||||||
|
payload=reading,
|
||||||
|
)
|
||||||
|
|
||||||
|
await mission_ws_manager.broadcast(
|
||||||
|
"mission_environment_temperature",
|
||||||
|
{"reading": reading, "environment_readings": merged},
|
||||||
|
)
|
||||||
|
if event_row:
|
||||||
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"reading": reading,
|
||||||
|
"count": len(merged),
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -198,11 +198,35 @@ async def search_sag(q: str):
|
|||||||
CAST(s.id AS TEXT) ILIKE %s OR
|
CAST(s.id AS TEXT) ILIKE %s OR
|
||||||
s.titel ILIKE %s OR
|
s.titel ILIKE %s OR
|
||||||
s.beskrivelse ILIKE %s OR
|
s.beskrivelse ILIKE %s OR
|
||||||
c.name ILIKE %s
|
c.name ILIKE %s OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_tags et
|
||||||
|
JOIN tags t ON t.id = et.tag_id
|
||||||
|
WHERE et.entity_type = 'case'
|
||||||
|
AND et.entity_id = s.id
|
||||||
|
AND t.name ILIKE %s
|
||||||
|
) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_tags st
|
||||||
|
WHERE st.sag_id = s.id
|
||||||
|
AND st.deleted_at IS NULL
|
||||||
|
AND st.tag_navn ILIKE %s
|
||||||
|
) OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_buzzwords sb
|
||||||
|
JOIN buzzwords b ON b.id = sb.buzzword_id
|
||||||
|
WHERE sb.sag_id = s.id
|
||||||
|
AND sb.deleted_at IS NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.word ILIKE %s
|
||||||
|
)
|
||||||
)
|
)
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
""", (search_term, search_term, search_term, search_term))
|
""", (search_term, search_term, search_term, search_term, search_term, search_term, search_term))
|
||||||
|
|
||||||
return sager or []
|
return sager or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Request, Form
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
import jwt
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
@ -74,6 +78,126 @@ def _is_sales_group(group_names) -> bool:
|
|||||||
for group in (group_names or [])
|
for group in (group_names or [])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mission_access_pin() -> str:
|
||||||
|
row = execute_query_single("SELECT value FROM settings WHERE key = %s", ("mission_access_pin",))
|
||||||
|
db_pin = str((row or {}).get("value") or "").strip()
|
||||||
|
env_pin = str(getattr(settings, "MISSION_ACCESS_PIN", "") or "").strip()
|
||||||
|
return db_pin or env_pin
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_mission_pin_token(request: Request) -> bool:
|
||||||
|
token = request.cookies.get("mission_pin_token")
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
return bool(payload and payload.get("scope") == "mission_pin")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mission_pin_token() -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": "0",
|
||||||
|
"username": "mission-kiosk",
|
||||||
|
"shadow_admin": True,
|
||||||
|
"scope": "mission_pin",
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
"exp": datetime.utcnow() + timedelta(hours=12),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mission_next(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return "/dashboard/mission-control"
|
||||||
|
candidate = value.strip()
|
||||||
|
if candidate in {
|
||||||
|
"/dashboard/mission-control",
|
||||||
|
"/dashboard/mission-control/",
|
||||||
|
"/dashboard/mission-control/projects",
|
||||||
|
"/dashboard/mission-control/projects/",
|
||||||
|
"/dashboard/mission-control.old",
|
||||||
|
"/dashboard/mission-control.old/",
|
||||||
|
}:
|
||||||
|
return candidate
|
||||||
|
if candidate.startswith("/api/v1/mission/"):
|
||||||
|
return candidate
|
||||||
|
return "/dashboard/mission-control"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mission_pin_page(error_text: str = "", next_path: str = "/dashboard/mission-control") -> HTMLResponse:
|
||||||
|
safe_next = _sanitize_mission_next(next_path)
|
||||||
|
error_html = f'<div style="margin:0.75rem 0;color:#ffb4b4;">{error_text}</div>' if error_text else ""
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=\"da\">
|
||||||
|
<head>
|
||||||
|
<meta charset=\"utf-8\" />
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||||
|
<title>Mission Control PIN</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: #0b1320; color: #e9f1ff; display: grid; place-items: center; min-height: 100vh; }}
|
||||||
|
.card {{ width: min(92vw, 420px); padding: 1.2rem; border: 1px solid #2c3c58; border-radius: 14px; background: #121d2f; }}
|
||||||
|
.title {{ font-size: 1.2rem; font-weight: 700; margin: 0 0 0.5rem 0; }}
|
||||||
|
.hint {{ color: #9fb3d1; font-size: 0.9rem; margin: 0 0 0.9rem 0; }}
|
||||||
|
input {{ width: 100%; box-sizing: border-box; border: 1px solid #2c3c58; border-radius: 10px; background: #0f1a2b; color: #e9f1ff; padding: 0.7rem; font-size: 1rem; }}
|
||||||
|
button {{ width: 100%; margin-top: 0.75rem; border: 1px solid #3b82f6; border-radius: 10px; background: #1f5bb8; color: #fff; font-weight: 600; padding: 0.65rem; cursor: pointer; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class=\"card\">
|
||||||
|
<p class=\"title\">Mission Control</p>
|
||||||
|
<p class=\"hint\">Indtast PIN-kode for at fortsætte.</p>
|
||||||
|
{error_html}
|
||||||
|
<form method=\"post\" action=\"/mission/pin/verify\">
|
||||||
|
<input type=\"hidden\" name=\"next\" value=\"{safe_next}\" />
|
||||||
|
<input type=\"password\" name=\"pin\" inputmode=\"numeric\" autocomplete=\"one-time-code\" placeholder=\"PIN-kode\" required />
|
||||||
|
<button type=\"submit\">Åbn Mission Control</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/pin", response_class=HTMLResponse)
|
||||||
|
async def mission_pin_page(request: Request, next: str = "/dashboard/mission-control"):
|
||||||
|
if _has_valid_mission_pin_token(request):
|
||||||
|
return RedirectResponse(url=_sanitize_mission_next(next), status_code=302)
|
||||||
|
return _render_mission_pin_page(next_path=next)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mission/pin/", response_class=HTMLResponse)
|
||||||
|
async def mission_pin_page_trailing_slash(request: Request, next: str = "/dashboard/mission-control"):
|
||||||
|
return await mission_pin_page(request, next)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/pin/verify")
|
||||||
|
async def mission_pin_verify(pin: str = Form(...), next: str = Form("/dashboard/mission-control")):
|
||||||
|
configured_pin = _get_mission_access_pin()
|
||||||
|
if not configured_pin:
|
||||||
|
return _render_mission_pin_page("PIN er ikke konfigureret på serveren.", next)
|
||||||
|
|
||||||
|
if pin.strip() != configured_pin:
|
||||||
|
return _render_mission_pin_page("Forkert PIN-kode.", next)
|
||||||
|
|
||||||
|
token = _create_mission_pin_token()
|
||||||
|
redirect_target = _sanitize_mission_next(next)
|
||||||
|
response = RedirectResponse(url=redirect_target, status_code=302)
|
||||||
|
response.set_cookie(
|
||||||
|
key="mission_pin_token",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=60 * 60 * 12,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mission/pin/logout")
|
||||||
|
async def mission_pin_logout():
|
||||||
|
response = RedirectResponse(url="/mission/pin", status_code=302)
|
||||||
|
response.delete_cookie("mission_pin_token")
|
||||||
|
return response
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""
|
"""
|
||||||
@ -125,10 +249,24 @@ async def dashboard(request: Request):
|
|||||||
|
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|
||||||
result = execute_query_single(unknown_query)
|
try:
|
||||||
unknown_count = result['count'] if result else 0
|
result = execute_query_single(unknown_query)
|
||||||
|
unknown_count = result['count'] if result else 0
|
||||||
|
except Exception as exc:
|
||||||
|
if "tticket_worklog" in str(exc):
|
||||||
|
logger.warning("⚠️ tticket_worklog table not found; defaulting unknown worklog count to 0")
|
||||||
|
unknown_count = 0
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
raw_alerts = execute_query(bankruptcy_query) or []
|
try:
|
||||||
|
raw_alerts = execute_query(bankruptcy_query) or []
|
||||||
|
except Exception as exc:
|
||||||
|
if "email_messages" in str(exc):
|
||||||
|
logger.warning("⚠️ email_messages table not found; skipping bankruptcy alerts")
|
||||||
|
raw_alerts = []
|
||||||
|
else:
|
||||||
|
raise
|
||||||
bankruptcy_alerts = []
|
bankruptcy_alerts = []
|
||||||
|
|
||||||
for alert in raw_alerts:
|
for alert in raw_alerts:
|
||||||
@ -348,9 +486,52 @@ async def clear_default_dashboard_get_fallback():
|
|||||||
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
|
@router.get("/dashboard/mission-control", response_class=HTMLResponse)
|
||||||
async def mission_control_dashboard(request: Request):
|
async def mission_control_dashboard(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dashboard/frontend/mission_control.html",
|
"dashboard/frontend/mission_control_v2.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"hide_top_nav": True,
|
||||||
|
"mission_control_version": "v2",
|
||||||
|
"mission_initial_view": "project",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control/", response_class=HTMLResponse)
|
||||||
|
async def mission_control_dashboard_trailing_slash(request: Request):
|
||||||
|
return await mission_control_dashboard(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control/projects", response_class=HTMLResponse)
|
||||||
|
async def mission_control_projects_dashboard(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard/frontend/mission_control_v2.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"hide_top_nav": True,
|
||||||
|
"mission_control_version": "v2",
|
||||||
|
"mission_initial_view": "project",
|
||||||
|
"mission_project_only": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control/projects/", response_class=HTMLResponse)
|
||||||
|
async def mission_control_projects_dashboard_trailing_slash(request: Request):
|
||||||
|
return await mission_control_projects_dashboard(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control.old", response_class=HTMLResponse)
|
||||||
|
async def mission_control_dashboard_legacy(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard/frontend/mission_control_legacy.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"mission_control_version": "v1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/mission-control.old/", response_class=HTMLResponse)
|
||||||
|
async def mission_control_dashboard_legacy_trailing_slash(request: Request):
|
||||||
|
return await mission_control_dashboard_legacy(request)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1953
app/dashboard/frontend/mission_control_legacy.html
Normal file
1953
app/dashboard/frontend/mission_control_legacy.html
Normal file
File diff suppressed because it is too large
Load Diff
2756
app/dashboard/frontend/mission_control_v2.html
Normal file
2756
app/dashboard/frontend/mission_control_v2.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -81,7 +81,7 @@
|
|||||||
<td>{{ item.pipeline_stage or '-' }}</td>
|
<td>{{ item.pipeline_stage or '-' }}</td>
|
||||||
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
|
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
|
||||||
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</td>
|
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</td>
|
||||||
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
|
<td><a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-primary">Åbn</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
|
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
|
||||||
@ -102,7 +102,7 @@
|
|||||||
<div class="fw-semibold">{{ item.titel }}</div>
|
<div class="fw-semibold">{{ item.titel }}</div>
|
||||||
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</div>
|
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</div>
|
||||||
<div class="small text-muted">Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
|
<div class="small text-muted">Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
|
||||||
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
|
<a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
|
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query, execute_query_single
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@ -76,7 +76,7 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
|
|||||||
params.append(status)
|
params.append(status)
|
||||||
|
|
||||||
query += " ORDER BY priority DESC, expected_date ASC"
|
query += " ORDER BY priority DESC, expected_date ASC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ async def get_feature(feature_id: int):
|
|||||||
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Feature not found")
|
raise HTTPException(status_code=404, detail="Feature not found")
|
||||||
return result
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/features", response_model=Feature)
|
@router.post("/features", response_model=Feature)
|
||||||
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY votes DESC, created_at DESC"
|
query += " ORDER BY votes DESC, created_at DESC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
|
|||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (idea.title, idea.description, idea.category))
|
result = execute_query_single(query, (idea.title, idea.description, idea.category))
|
||||||
|
|
||||||
logger.info(f"✅ Created idea: {idea.title}")
|
logger.info(f"✅ Created idea: {idea.title}")
|
||||||
return result
|
return result
|
||||||
@ -209,7 +209,7 @@ async def get_workflows(category: Optional[str] = None):
|
|||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC"
|
query += " ORDER BY created_at DESC"
|
||||||
result = execute_query_single(query, tuple(params) if params else None)
|
result = execute_query(query, tuple(params) if params else None)
|
||||||
return result or []
|
return result or []
|
||||||
|
|
||||||
|
|
||||||
@ -219,7 +219,7 @@ async def get_workflow(workflow_id: int):
|
|||||||
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||||
return result
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/workflows", response_model=Workflow)
|
@router.post("/workflows", response_model=Workflow)
|
||||||
|
|||||||
0
app/economy/__init__.py
Normal file
0
app/economy/__init__.py
Normal file
0
app/economy/backend/__init__.py
Normal file
0
app/economy/backend/__init__.py
Normal file
808
app/economy/backend/router.py
Normal file
808
app/economy/backend/router.py
Normal file
@ -0,0 +1,808 @@
|
|||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_insert, execute_query, execute_query_single, execute_update
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/economy", tags=["Economy"])
|
||||||
|
|
||||||
|
|
||||||
|
class BulkIdsRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
description: Optional[str] = None
|
||||||
|
original_hours: Optional[float] = Field(None, gt=0)
|
||||||
|
billable: Optional[bool] = None
|
||||||
|
billing_method: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSoftDeleteRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
reason: Optional[str] = "Soft deleted from economy queue"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkApproveRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
billable: Optional[bool] = None
|
||||||
|
billing_method: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPrepaidRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
prepaid_card_id: int = Field(..., gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkSendRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_ids(ids: List[int]) -> List[int]:
|
||||||
|
clean = sorted(set(int(i) for i in ids if int(i) > 0))
|
||||||
|
if not clean:
|
||||||
|
raise HTTPException(status_code=400, detail="No valid ids provided")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/time-queue")
|
||||||
|
async def list_hub_time_queue(
|
||||||
|
customer_id: Optional[int] = Query(None, gt=0),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
billable: Optional[bool] = Query(None),
|
||||||
|
q: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(500, ge=1, le=2000),
|
||||||
|
):
|
||||||
|
"""List non-billed Hub-created time entries for the economy queue."""
|
||||||
|
try:
|
||||||
|
conditions = [
|
||||||
|
"t.vtiger_id IS NULL",
|
||||||
|
"t.billed_via_thehub_id IS NULL",
|
||||||
|
"t.status <> 'billed'",
|
||||||
|
]
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
conditions.append("t.customer_id = %s")
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
conditions.append("t.status = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if billable is not None:
|
||||||
|
conditions.append("COALESCE(t.billable, true) = %s")
|
||||||
|
params.append(billable)
|
||||||
|
|
||||||
|
if q:
|
||||||
|
conditions.append(
|
||||||
|
"("
|
||||||
|
"COALESCE(t.description, '') ILIKE %s OR "
|
||||||
|
"COALESCE(cust.name, '') ILIKE %s OR "
|
||||||
|
"COALESCE(c.title, s.titel, '') ILIKE %s"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
like = f"%{q}%"
|
||||||
|
params.extend([like, like, like])
|
||||||
|
|
||||||
|
where_sql = " AND ".join(conditions)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.customer_id,
|
||||||
|
cust.name AS customer_name,
|
||||||
|
t.status,
|
||||||
|
t.entry_status,
|
||||||
|
t.billable,
|
||||||
|
t.billing_method,
|
||||||
|
t.prepaid_card_id,
|
||||||
|
t.fixed_price_agreement_id,
|
||||||
|
t.original_hours,
|
||||||
|
t.approved_hours,
|
||||||
|
t.rounded_to,
|
||||||
|
t.worked_date,
|
||||||
|
t.description,
|
||||||
|
t.entry_type,
|
||||||
|
t.kilde,
|
||||||
|
t.case_id,
|
||||||
|
t.sag_id,
|
||||||
|
COALESCE(c.title, s.titel, 'No title') AS case_title,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
|
||||||
|
LEFT JOIN tmodule_cases c ON c.id = t.case_id
|
||||||
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
rows = execute_query(query, tuple(params))
|
||||||
|
return {"items": rows, "count": len(rows)}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed listing economy time queue: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list time queue")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/time-queue/customers")
|
||||||
|
async def list_time_queue_customers():
|
||||||
|
"""List customers that currently have queue-relevant (not billed) Hub entries."""
|
||||||
|
try:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.customer_id,
|
||||||
|
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
|
||||||
|
COUNT(*)::int AS open_count
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
|
||||||
|
WHERE t.customer_id IS NOT NULL
|
||||||
|
AND t.vtiger_id IS NULL
|
||||||
|
AND t.billed_via_thehub_id IS NULL
|
||||||
|
AND t.status = 'pending'
|
||||||
|
GROUP BY t.customer_id, cust.name
|
||||||
|
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {"items": rows, "count": len(rows)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed listing time queue customers: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed listing customer filter options")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/time-queue/prepaid-cards")
|
||||||
|
async def list_prepaid_cards():
|
||||||
|
try:
|
||||||
|
cards = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
|
||||||
|
FROM tticket_prepaid_cards
|
||||||
|
WHERE status IN ('active', 'depleted')
|
||||||
|
ORDER BY remaining_hours DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {"items": cards, "count": len(cards)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed listing prepaid cards: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list prepaid cards")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/time-queue/bulk-update")
|
||||||
|
async def bulk_update_time_queue(payload: BulkUpdateRequest):
|
||||||
|
ids = _ensure_ids(payload.ids)
|
||||||
|
|
||||||
|
updates: List[str] = []
|
||||||
|
values: List[Any] = []
|
||||||
|
|
||||||
|
if payload.description is not None:
|
||||||
|
updates.append("description = %s")
|
||||||
|
values.append(payload.description)
|
||||||
|
|
||||||
|
if payload.original_hours is not None:
|
||||||
|
updates.append("original_hours = %s")
|
||||||
|
values.append(payload.original_hours)
|
||||||
|
|
||||||
|
if payload.billable is not None:
|
||||||
|
updates.append("billable = %s")
|
||||||
|
values.append(payload.billable)
|
||||||
|
if payload.billable is False and payload.billing_method is None:
|
||||||
|
updates.append("billing_method = 'internal'")
|
||||||
|
|
||||||
|
if payload.billing_method is not None:
|
||||||
|
updates.append("billing_method = %s")
|
||||||
|
values.append(payload.billing_method)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No update fields provided")
|
||||||
|
|
||||||
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(ids))
|
||||||
|
query = f"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
AND vtiger_id IS NULL
|
||||||
|
AND billed_via_thehub_id IS NULL
|
||||||
|
AND status <> 'billed'
|
||||||
|
"""
|
||||||
|
execute_update(query, tuple(values + ids))
|
||||||
|
return {"success": True, "updated": len(ids)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed bulk update: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed bulk update")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time-queue/bulk-soft-delete")
|
||||||
|
async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
|
||||||
|
ids = _ensure_ids(payload.ids)
|
||||||
|
reason = (payload.reason or "Soft deleted from economy queue").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(ids))
|
||||||
|
execute_update(
|
||||||
|
f"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET status = 'rejected',
|
||||||
|
entry_status = 'kladde',
|
||||||
|
approval_note = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
AND vtiger_id IS NULL
|
||||||
|
AND billed_via_thehub_id IS NULL
|
||||||
|
AND status <> 'billed'
|
||||||
|
""",
|
||||||
|
tuple([reason] + ids),
|
||||||
|
)
|
||||||
|
return {"success": True, "soft_deleted": len(ids)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed bulk soft delete: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed bulk soft delete")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time-queue/bulk-approve")
|
||||||
|
async def bulk_approve_time_queue(payload: BulkApproveRequest):
|
||||||
|
ids = _ensure_ids(payload.ids)
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_parts = [
|
||||||
|
"status = 'approved'",
|
||||||
|
"entry_status = 'godkendt'",
|
||||||
|
"approved_hours = COALESCE(approved_hours, original_hours)",
|
||||||
|
"approved_at = CURRENT_TIMESTAMP",
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
]
|
||||||
|
params: List[Any] = []
|
||||||
|
|
||||||
|
if payload.billable is not None:
|
||||||
|
set_parts.append("billable = %s")
|
||||||
|
params.append(payload.billable)
|
||||||
|
|
||||||
|
if payload.billing_method is not None:
|
||||||
|
set_parts.append("billing_method = %s")
|
||||||
|
params.append(payload.billing_method)
|
||||||
|
|
||||||
|
placeholders = ",".join(["%s"] * len(ids))
|
||||||
|
query = f"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET {", ".join(set_parts)}
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
AND vtiger_id IS NULL
|
||||||
|
AND billed_via_thehub_id IS NULL
|
||||||
|
AND status <> 'billed'
|
||||||
|
"""
|
||||||
|
execute_update(query, tuple(params + ids))
|
||||||
|
return {"success": True, "approved": len(ids)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed bulk approve: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed bulk approve")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time-queue/bulk-apply-prepaid")
|
||||||
|
async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
|
||||||
|
ids = _ensure_ids(payload.ids)
|
||||||
|
|
||||||
|
card = execute_query_single(
|
||||||
|
"SELECT id FROM tticket_prepaid_cards WHERE id = %s",
|
||||||
|
(payload.prepaid_card_id,),
|
||||||
|
)
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404, detail="Prepaid card not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(ids))
|
||||||
|
execute_update(
|
||||||
|
f"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET prepaid_card_id = %s,
|
||||||
|
billing_method = 'prepaid',
|
||||||
|
billable = TRUE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
AND vtiger_id IS NULL
|
||||||
|
AND billed_via_thehub_id IS NULL
|
||||||
|
AND status <> 'billed'
|
||||||
|
""",
|
||||||
|
tuple([payload.prepaid_card_id] + ids),
|
||||||
|
)
|
||||||
|
return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed applying prepaid card: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed applying prepaid card")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||||
|
|
||||||
|
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||||
|
|
||||||
|
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||||
|
"rows": [],
|
||||||
|
"case_title": "Time entries",
|
||||||
|
"case_id": None,
|
||||||
|
"sag_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
|
||||||
|
grouped[group_key]["rows"].append(row)
|
||||||
|
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
|
||||||
|
grouped[group_key]["case_id"] = row.get("case_id")
|
||||||
|
grouped[group_key]["sag_id"] = row.get("sag_id")
|
||||||
|
|
||||||
|
line_payloads: List[Dict[str, Any]] = []
|
||||||
|
total_hours = Decimal("0")
|
||||||
|
|
||||||
|
for _, group in grouped.items():
|
||||||
|
qty = Decimal("0")
|
||||||
|
ids: List[int] = []
|
||||||
|
latest_date = None
|
||||||
|
|
||||||
|
for row in group["rows"]:
|
||||||
|
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
|
||||||
|
ids.append(int(row["id"]))
|
||||||
|
wd = row.get("worked_date")
|
||||||
|
if wd and (latest_date is None or wd > latest_date):
|
||||||
|
latest_date = wd
|
||||||
|
|
||||||
|
line_total = (qty * hourly_rate).quantize(Decimal("0.01"))
|
||||||
|
line_payloads.append(
|
||||||
|
{
|
||||||
|
"description": group["case_title"],
|
||||||
|
"quantity": qty,
|
||||||
|
"line_total": line_total,
|
||||||
|
"time_entry_ids": ids,
|
||||||
|
"case_id": group["case_id"],
|
||||||
|
"sag_id": group["sag_id"],
|
||||||
|
"time_date": latest_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_hours += qty
|
||||||
|
|
||||||
|
subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01"))
|
||||||
|
vat_rate = Decimal("25.00")
|
||||||
|
vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01"))
|
||||||
|
total_amount = subtotal + vat_amount
|
||||||
|
|
||||||
|
order_id = execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO tmodule_orders
|
||||||
|
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
|
||||||
|
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
customer_id,
|
||||||
|
customer.get("hub_customer_id"),
|
||||||
|
total_hours,
|
||||||
|
hourly_rate,
|
||||||
|
subtotal,
|
||||||
|
vat_rate,
|
||||||
|
vat_amount,
|
||||||
|
total_amount,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, line in enumerate(line_payloads, start=1):
|
||||||
|
execute_insert(
|
||||||
|
"""
|
||||||
|
INSERT INTO tmodule_order_lines
|
||||||
|
(order_id, case_id, sag_id, line_number, description, quantity, unit_price,
|
||||||
|
line_total, time_entry_ids, case_contact, time_date, is_travel)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
order_id,
|
||||||
|
line["case_id"],
|
||||||
|
line["sag_id"],
|
||||||
|
idx,
|
||||||
|
line["description"],
|
||||||
|
line["quantity"],
|
||||||
|
hourly_rate,
|
||||||
|
line["line_total"],
|
||||||
|
line["time_entry_ids"],
|
||||||
|
line["time_date"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return int(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||||
|
|
||||||
|
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||||
|
hub_customer_id = customer.get("hub_customer_id")
|
||||||
|
|
||||||
|
hub_customer = None
|
||||||
|
if hub_customer_id:
|
||||||
|
hub_customer = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
standard_hourly_rate,
|
||||||
|
standard_margin_percent,
|
||||||
|
special_freight_price,
|
||||||
|
supplier_service_enrolled,
|
||||||
|
invoice_fee_amount
|
||||||
|
FROM customers
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(hub_customer_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_fee_amount = Decimal(
|
||||||
|
str(
|
||||||
|
(hub_customer or {}).get("invoice_fee_amount")
|
||||||
|
if (hub_customer or {}).get("invoice_fee_amount") is not None
|
||||||
|
else settings.CUSTOMER_DEFAULT_INVOICE_FEE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
special_freight_price = (hub_customer or {}).get("special_freight_price")
|
||||||
|
special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0")
|
||||||
|
supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled"))
|
||||||
|
standard_margin_percent = Decimal(
|
||||||
|
str(
|
||||||
|
(hub_customer or {}).get("standard_margin_percent")
|
||||||
|
if (hub_customer or {}).get("standard_margin_percent") is not None
|
||||||
|
else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
base_hourly_rate = Decimal(
|
||||||
|
str(
|
||||||
|
(hub_customer or {}).get("standard_hourly_rate")
|
||||||
|
if (hub_customer or {}).get("standard_hourly_rate") is not None
|
||||||
|
else hourly_rate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
||||||
|
"rows": [],
|
||||||
|
"case_title": "Time entries",
|
||||||
|
"case_id": None,
|
||||||
|
"sag_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}"
|
||||||
|
grouped[group_key]["rows"].append(row)
|
||||||
|
grouped[group_key]["case_title"] = row.get("case_title") or "Time entries"
|
||||||
|
grouped[group_key]["case_id"] = row.get("case_id")
|
||||||
|
grouped[group_key]["sag_id"] = row.get("sag_id")
|
||||||
|
|
||||||
|
line_payloads: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for _, group in grouped.items():
|
||||||
|
qty = Decimal("0")
|
||||||
|
ids: List[int] = []
|
||||||
|
latest_date = None
|
||||||
|
|
||||||
|
for row in group["rows"]:
|
||||||
|
qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0))
|
||||||
|
ids.append(int(row["id"]))
|
||||||
|
wd = row.get("worked_date")
|
||||||
|
if wd and (latest_date is None or wd > latest_date):
|
||||||
|
latest_date = wd
|
||||||
|
|
||||||
|
effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0")
|
||||||
|
unit_price = base_hourly_rate.quantize(Decimal("0.01"))
|
||||||
|
amount = (qty * unit_price).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
line_payloads.append(
|
||||||
|
{
|
||||||
|
"line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}",
|
||||||
|
"source_type": "timequeue",
|
||||||
|
"source_id": ids[0] if ids else None,
|
||||||
|
"description": group["case_title"],
|
||||||
|
"quantity": float(qty),
|
||||||
|
"unit_price": float(unit_price),
|
||||||
|
"discount_percentage": 0,
|
||||||
|
"unit": "timer",
|
||||||
|
"product_id": None,
|
||||||
|
"selected": True,
|
||||||
|
"amount": float(amount),
|
||||||
|
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||||
|
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||||
|
"sag_id": group["sag_id"],
|
||||||
|
"time_entry_ids": ids,
|
||||||
|
"time_date": str(latest_date) if latest_date else None,
|
||||||
|
"meta": {
|
||||||
|
"base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))),
|
||||||
|
"standard_margin_percent": float(effective_margin_percent),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if special_freight_amount > 0:
|
||||||
|
line_payloads.append(
|
||||||
|
{
|
||||||
|
"line_key": f"freight:{hub_customer_id or customer_id}",
|
||||||
|
"source_type": "freight",
|
||||||
|
"source_id": None,
|
||||||
|
"description": "Særlig fragtpris",
|
||||||
|
"quantity": 1.0,
|
||||||
|
"unit_price": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||||
|
"discount_percentage": 0,
|
||||||
|
"unit": "stk",
|
||||||
|
"product_id": None,
|
||||||
|
"selected": True,
|
||||||
|
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||||
|
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||||
|
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||||
|
"sag_id": None,
|
||||||
|
"time_entry_ids": [],
|
||||||
|
"time_date": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fee line is included by default unless customer-specific value is 0.
|
||||||
|
if invoice_fee_amount > 0 and not supplier_service_enrolled:
|
||||||
|
line_payloads.append(
|
||||||
|
{
|
||||||
|
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
|
||||||
|
"source_type": "invoice_fee",
|
||||||
|
"source_id": None,
|
||||||
|
"description": "Faktureringsgebyr",
|
||||||
|
"quantity": 1.0,
|
||||||
|
"unit_price": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||||
|
"discount_percentage": 0,
|
||||||
|
"unit": "stk",
|
||||||
|
"product_id": None,
|
||||||
|
"selected": True,
|
||||||
|
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||||
|
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||||
|
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||||
|
"sag_id": None,
|
||||||
|
"time_entry_ids": [],
|
||||||
|
"time_date": None,
|
||||||
|
"meta": {
|
||||||
|
"standard_margin_percent": float(standard_margin_percent),
|
||||||
|
"supplier_service_enrolled": supplier_service_enrolled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not line_payloads:
|
||||||
|
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
|
||||||
|
|
||||||
|
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
|
||||||
|
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
|
||||||
|
|
||||||
|
draft = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO ordre_drafts (
|
||||||
|
title,
|
||||||
|
customer_id,
|
||||||
|
lines_json,
|
||||||
|
notes,
|
||||||
|
layout_number,
|
||||||
|
created_by_user_id,
|
||||||
|
sync_status,
|
||||||
|
export_status_json,
|
||||||
|
invoice_aggregate_key,
|
||||||
|
updated_at
|
||||||
|
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
draft_title,
|
||||||
|
int(hub_customer_id) if hub_customer_id else None,
|
||||||
|
json.dumps(line_payloads, ensure_ascii=False),
|
||||||
|
"Genereret fra Economy Time Queue",
|
||||||
|
1,
|
||||||
|
user_id,
|
||||||
|
json.dumps({}, ensure_ascii=False),
|
||||||
|
invoice_aggregate_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not draft:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed creating ordre draft")
|
||||||
|
|
||||||
|
return int(draft["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
|
||||||
|
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
- direct tmodule customer id
|
||||||
|
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
|
||||||
|
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
|
||||||
|
"""
|
||||||
|
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
|
||||||
|
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM tmodule_customers
|
||||||
|
WHERE hub_customer_id = %s
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(hub_customer_id,),
|
||||||
|
)
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
if raw_customer_id is not None:
|
||||||
|
try:
|
||||||
|
cid = int(raw_customer_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cid = None
|
||||||
|
|
||||||
|
if cid and cid > 0:
|
||||||
|
direct = _find_by_tmodule_id(cid)
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
mapped = _find_by_hub_customer_id(cid)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
if sag_id is not None:
|
||||||
|
try:
|
||||||
|
sid = int(sag_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sid = None
|
||||||
|
|
||||||
|
if sid and sid > 0:
|
||||||
|
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
|
||||||
|
hub_customer_id = (sag or {}).get("customer_id") if sag else None
|
||||||
|
if hub_customer_id:
|
||||||
|
mapped = _find_by_hub_customer_id(int(hub_customer_id))
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/time-queue/send-to-invoices")
|
||||||
|
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||||
|
ids = _ensure_ids(payload.ids)
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(ids))
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.customer_id,
|
||||||
|
t.case_id,
|
||||||
|
t.sag_id,
|
||||||
|
t.status,
|
||||||
|
t.billable,
|
||||||
|
t.billing_method,
|
||||||
|
t.original_hours,
|
||||||
|
t.approved_hours,
|
||||||
|
t.worked_date,
|
||||||
|
COALESCE(c.title, s.titel, 'Time entries') AS case_title
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN tmodule_cases c ON c.id = t.case_id
|
||||||
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE t.id IN ({placeholders})
|
||||||
|
AND t.vtiger_id IS NULL
|
||||||
|
AND t.billed_via_thehub_id IS NULL
|
||||||
|
AND t.status <> 'billed'
|
||||||
|
""",
|
||||||
|
tuple(ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=400, detail="No eligible entries found")
|
||||||
|
|
||||||
|
# Local order creation must not depend on e-conomic data/mapping.
|
||||||
|
# Selected entries are converted to local orders regardless of billing method.
|
||||||
|
selected_order_ids = [int(r["id"]) for r in rows]
|
||||||
|
|
||||||
|
if not selected_order_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="No selected entries found")
|
||||||
|
|
||||||
|
placeholders_invoice = ",".join(["%s"] * len(selected_order_ids))
|
||||||
|
execute_update(
|
||||||
|
f"""
|
||||||
|
UPDATE tmodule_times
|
||||||
|
SET status = 'approved',
|
||||||
|
entry_status = 'godkendt',
|
||||||
|
approved_hours = COALESCE(approved_hours, original_hours),
|
||||||
|
approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id IN ({placeholders_invoice})
|
||||||
|
AND status <> 'billed'
|
||||||
|
""",
|
||||||
|
tuple(selected_order_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||||
|
skipped_missing_customer: List[int] = []
|
||||||
|
for row in rows:
|
||||||
|
if int(row["id"]) not in selected_order_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
|
||||||
|
if not resolved_customer_id:
|
||||||
|
skipped_missing_customer.append(int(row["id"]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows_by_customer[int(resolved_customer_id)].append(row)
|
||||||
|
|
||||||
|
created_drafts = []
|
||||||
|
failed_customers: List[Dict[str, Any]] = []
|
||||||
|
for cust_id, cust_rows in rows_by_customer.items():
|
||||||
|
try:
|
||||||
|
draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id)
|
||||||
|
created_drafts.append({"customer_id": cust_id, "draft_id": draft_id})
|
||||||
|
except HTTPException as ex:
|
||||||
|
failed_customers.append(
|
||||||
|
{
|
||||||
|
"customer_id": cust_id,
|
||||||
|
"entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None],
|
||||||
|
"error": str(ex.detail),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created_drafts:
|
||||||
|
if skipped_missing_customer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No local orders created: selected entries are missing customer linkage",
|
||||||
|
)
|
||||||
|
if failed_customers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No local orders created: customer data is invalid for selected entries",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail="No local orders created")
|
||||||
|
|
||||||
|
# Time queue must never push directly to e-conomic.
|
||||||
|
# Orders are created locally and can be transferred manually from Orders page.
|
||||||
|
draft_ids = [o["draft_id"] for o in created_drafts]
|
||||||
|
orders_url = "/ordre"
|
||||||
|
if len(draft_ids) == 1:
|
||||||
|
orders_url = f"/ordre/{draft_ids[0]}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"selected": len(ids),
|
||||||
|
"order_candidates": len(selected_order_ids),
|
||||||
|
"created_drafts": created_drafts,
|
||||||
|
"created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts],
|
||||||
|
"skipped_missing_customer": skipped_missing_customer,
|
||||||
|
"failed_customers": failed_customers,
|
||||||
|
"orders_url": orders_url,
|
||||||
|
"message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.",
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed send-to-invoices flow: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices")
|
||||||
0
app/economy/frontend/__init__.py
Normal file
0
app/economy/frontend/__init__.py
Normal file
510
app/economy/frontend/time_queue.html
Normal file
510
app/economy/frontend/time_queue.html
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Economy Time Queue{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1">Economy Time Queue</h2>
|
||||||
|
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
|
||||||
|
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
|
||||||
|
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
|
||||||
|
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
|
||||||
|
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="filterCustomer" class="form-label">Firma</label>
|
||||||
|
<select id="filterCustomer" class="form-select">
|
||||||
|
<option value="">Alle firmaer med ubehandlede registreringer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="filterStatus" class="form-label">Status</label>
|
||||||
|
<select id="filterStatus" class="form-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="approved">approved</option>
|
||||||
|
<option value="rejected">rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="filterBillable" class="form-label">Billable</label>
|
||||||
|
<select id="filterBillable" class="form-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="filterQuery" class="form-label">Search</label>
|
||||||
|
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="bulkDescription" class="form-label">Description</label>
|
||||||
|
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<label for="bulkHours" class="form-label">Hours</label>
|
||||||
|
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<label for="bulkBillingMethod" class="form-label">Billing method</label>
|
||||||
|
<select id="bulkBillingMethod" class="form-select">
|
||||||
|
<option value="">No change</option>
|
||||||
|
<option value="invoice">invoice</option>
|
||||||
|
<option value="internal">internal</option>
|
||||||
|
<option value="prepaid">prepaid</option>
|
||||||
|
<option value="fixed_price">fixed_price</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
|
||||||
|
<select id="bulkPrepaidCard" class="form-select">
|
||||||
|
<option value="">Select card</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
|
||||||
|
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
|
||||||
|
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
|
||||||
|
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Case</th>
|
||||||
|
<th>Hours</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Billable</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Hours edit</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="queueBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
items: [],
|
||||||
|
selected: new Set(),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueBody = document.getElementById('queueBody');
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const selectedCountBadge = document.getElementById('selectedCountBadge');
|
||||||
|
|
||||||
|
const filterCustomer = document.getElementById('filterCustomer');
|
||||||
|
const filterStatus = document.getElementById('filterStatus');
|
||||||
|
const filterBillable = document.getElementById('filterBillable');
|
||||||
|
const filterQuery = document.getElementById('filterQuery');
|
||||||
|
|
||||||
|
const bulkDescription = document.getElementById('bulkDescription');
|
||||||
|
const bulkHours = document.getElementById('bulkHours');
|
||||||
|
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
|
||||||
|
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
|
||||||
|
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
|
||||||
|
|
||||||
|
function selectedIds() {
|
||||||
|
return Array.from(state.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
if (!state.items.length) {
|
||||||
|
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueBody.innerHTML = state.items.map((item) => {
|
||||||
|
const id = Number(item.id);
|
||||||
|
const checked = state.selected.has(id) ? 'checked' : '';
|
||||||
|
const date = item.worked_date || '-';
|
||||||
|
const hours = item.approved_hours || item.original_hours || 0;
|
||||||
|
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
|
||||||
|
const title = item.case_title || '-';
|
||||||
|
const desc = (item.description || '').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const method = item.billing_method || 'invoice';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
|
||||||
|
<td>${id}</td>
|
||||||
|
<td>${customer}</td>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td>${title}</td>
|
||||||
|
<td>${hours}</td>
|
||||||
|
<td>${item.status || '-'}</td>
|
||||||
|
<td>${item.billable === false ? 'false' : 'true'}</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm inline-method" data-id="${id}">
|
||||||
|
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
|
||||||
|
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
|
||||||
|
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
|
||||||
|
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('.row-check').forEach((cb) => {
|
||||||
|
cb.addEventListener('change', (e) => {
|
||||||
|
const id = Number(e.target.dataset.id);
|
||||||
|
if (e.target.checked) state.selected.add(id);
|
||||||
|
else state.selected.delete(id);
|
||||||
|
syncSelectAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.inline-save').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const id = Number(e.target.dataset.id);
|
||||||
|
await saveInlineRow(id, e.target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
syncSelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectAll() {
|
||||||
|
const ids = state.items.map((x) => Number(x.id));
|
||||||
|
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
|
||||||
|
selectAll.checked = Boolean(allSelected);
|
||||||
|
selectedCountBadge.textContent = `${state.selected.size} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, options = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
|
||||||
|
if (filterStatus.value) params.set('status', filterStatus.value);
|
||||||
|
if (filterBillable.value) params.set('billable', filterBillable.value);
|
||||||
|
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
|
||||||
|
params.set('limit', '500');
|
||||||
|
return `/api/v1/economy/time-queue?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
if (state.loading) return;
|
||||||
|
state.loading = true;
|
||||||
|
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
|
||||||
|
try {
|
||||||
|
const data = await api(buildListUrl());
|
||||||
|
state.items = data.items || [];
|
||||||
|
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
|
||||||
|
renderRows();
|
||||||
|
} catch (err) {
|
||||||
|
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrepaidCards() {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
|
||||||
|
const opts = ['<option value="">Select card</option>'];
|
||||||
|
(data.items || []).forEach((card) => {
|
||||||
|
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
|
||||||
|
opts.push(`<option value="${card.id}">${label}</option>`);
|
||||||
|
});
|
||||||
|
bulkPrepaidCard.innerHTML = opts.join('');
|
||||||
|
} catch (_) {
|
||||||
|
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/v1/economy/time-queue/customers');
|
||||||
|
const current = filterCustomer.value;
|
||||||
|
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
|
||||||
|
(data.items || []).forEach((row) => {
|
||||||
|
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
|
||||||
|
opts.push(`<option value="${row.customer_id}">${label}</option>`);
|
||||||
|
});
|
||||||
|
filterCustomer.innerHTML = opts.join('');
|
||||||
|
if (current) {
|
||||||
|
filterCustomer.value = current;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearFilters() {
|
||||||
|
filterCustomer.value = '';
|
||||||
|
filterStatus.value = '';
|
||||||
|
filterBillable.value = '';
|
||||||
|
filterQuery.value = '';
|
||||||
|
setActiveQuickFilter(null);
|
||||||
|
await loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveQuickFilter(active) {
|
||||||
|
quickFilterBtns.forEach((btn) => {
|
||||||
|
const isActive = btn.dataset.filter === active;
|
||||||
|
btn.classList.toggle('btn-primary', isActive);
|
||||||
|
btn.classList.toggle('btn-outline-primary', !isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyQuickFilter(type) {
|
||||||
|
if (type === 'pending') {
|
||||||
|
filterStatus.value = 'pending';
|
||||||
|
filterBillable.value = '';
|
||||||
|
} else if (type === 'billable') {
|
||||||
|
filterStatus.value = '';
|
||||||
|
filterBillable.value = 'true';
|
||||||
|
} else if (type === 'ready') {
|
||||||
|
filterStatus.value = 'approved';
|
||||||
|
filterBillable.value = 'true';
|
||||||
|
}
|
||||||
|
setActiveQuickFilter(type);
|
||||||
|
await loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInlineRow(id, buttonEl) {
|
||||||
|
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
|
||||||
|
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
|
||||||
|
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
|
||||||
|
if (!hoursInput || !descInput || !methodSelect) return;
|
||||||
|
|
||||||
|
const originalHours = Number(hoursInput.value);
|
||||||
|
const description = (descInput.value || '').trim();
|
||||||
|
const billingMethod = methodSelect.value;
|
||||||
|
|
||||||
|
if (!originalHours || originalHours <= 0) {
|
||||||
|
alert('Hours must be greater than 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevText = buttonEl.textContent;
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.textContent = 'Gemmer...';
|
||||||
|
try {
|
||||||
|
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ids: [id],
|
||||||
|
original_hours: originalHours,
|
||||||
|
description,
|
||||||
|
billing_method: billingMethod,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await loadEntries();
|
||||||
|
await loadCustomers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.textContent = prevText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBulkUpdate() {
|
||||||
|
const ids = selectedIds();
|
||||||
|
if (!ids.length) return alert('Select at least one entry');
|
||||||
|
|
||||||
|
const payload = { ids };
|
||||||
|
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
|
||||||
|
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
|
||||||
|
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||||
|
|
||||||
|
if (!payload.description && !payload.original_hours && !payload.billing_method) {
|
||||||
|
return alert('Set at least one update field');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
await loadCustomers();
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBulkApprove() {
|
||||||
|
const ids = selectedIds();
|
||||||
|
if (!ids.length) return alert('Select at least one entry');
|
||||||
|
|
||||||
|
const payload = { ids };
|
||||||
|
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/v1/economy/time-queue/bulk-approve', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
await loadCustomers();
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBulkDelete() {
|
||||||
|
const ids = selectedIds();
|
||||||
|
if (!ids.length) return alert('Select at least one entry');
|
||||||
|
|
||||||
|
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
|
||||||
|
if (reason === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, reason }),
|
||||||
|
});
|
||||||
|
await loadCustomers();
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doApplyPrepaid() {
|
||||||
|
const ids = selectedIds();
|
||||||
|
if (!ids.length) return alert('Select at least one entry');
|
||||||
|
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
|
||||||
|
});
|
||||||
|
await loadCustomers();
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSendInvoices() {
|
||||||
|
const ids = selectedIds();
|
||||||
|
if (!ids.length) return alert('Select at least one entry');
|
||||||
|
|
||||||
|
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||||
|
const draftId = x.draft_id || x.order_id;
|
||||||
|
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||||
|
}).join('\n');
|
||||||
|
const skipped = (result.skipped_missing_customer || []);
|
||||||
|
const failedCustomers = (result.failed_customers || []);
|
||||||
|
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
|
||||||
|
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
|
||||||
|
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||||
|
const failedMsg = failedCustomers.length
|
||||||
|
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
|
||||||
|
await loadCustomers();
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', () => {
|
||||||
|
if (selectAll.checked) {
|
||||||
|
state.items.forEach((item) => state.selected.add(Number(item.id)));
|
||||||
|
} else {
|
||||||
|
state.items.forEach((item) => state.selected.delete(Number(item.id)));
|
||||||
|
}
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
|
||||||
|
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
|
||||||
|
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
|
||||||
|
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
|
||||||
|
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
|
||||||
|
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
|
||||||
|
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
|
||||||
|
quickFilterBtns.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
|
||||||
|
});
|
||||||
|
|
||||||
|
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
|
||||||
|
el.addEventListener('change', loadEntries);
|
||||||
|
});
|
||||||
|
filterQuery.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') loadEntries();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadCustomers();
|
||||||
|
loadPrepaidCards();
|
||||||
|
loadEntries();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
14
app/economy/frontend/views.py
Normal file
14
app/economy/frontend/views.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/economy/time-queue", response_class=HTMLResponse)
|
||||||
|
async def economy_time_queue_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"economy/frontend/time_queue.html",
|
||||||
|
{"request": request, "title": "Economy Time Queue"},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1287
app/emails/frontend/emails_v2.html
Normal file
1287
app/emails/frontend/emails_v2.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -20,5 +20,23 @@ async def emails_page(request: Request):
|
|||||||
"""Email management UI - 3-column modern email interface"""
|
"""Email management UI - 3-column modern email interface"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"emails/frontend/emails.html",
|
"emails/frontend/emails.html",
|
||||||
{"request": request}
|
{"request": request, "email_ui_version": "v1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/v1", response_class=HTMLResponse)
|
||||||
|
async def emails_page_v1(request: Request):
|
||||||
|
"""Email management UI v1 (legacy/stable)."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"emails/frontend/emails.html",
|
||||||
|
{"request": request, "email_ui_version": "v1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/v2", response_class=HTMLResponse)
|
||||||
|
async def emails_page_v2(request: Request):
|
||||||
|
"""Email management UI v2 (simplified workflow)."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"emails/frontend/emails_v2.html",
|
||||||
|
{"request": request, "email_ui_version": "v2"}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -243,7 +243,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
|
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/sag/{{ sag.id }}" class="btn btn-sm btn-outline-primary">
|
<a href="/sag/{{ sag.id }}/v3" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-eye"></i> Vis
|
<i class="bi bi-eye"></i> Vis
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@ -284,7 +284,7 @@
|
|||||||
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
|
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.sag_id %}
|
{% if entry.sag_id %}
|
||||||
<a href="/sag/{{ entry.sag_id }}">#{{ entry.sag_id }}</a>
|
<a href="/sag/{{ entry.sag_id }}/v3">#{{ entry.sag_id }}</a>
|
||||||
{% if entry.sag_titel %}
|
{% if entry.sag_titel %}
|
||||||
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
|
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
41
app/jobs/anydesk_local_sync.py
Normal file
41
app/jobs/anydesk_local_sync.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
AnyDesk local sessions sync job.
|
||||||
|
Polls local AnyDesk bridge endpoint and enriches local session rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.anydesk import AnyDeskService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
anydesk_service = AnyDeskService()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_anydesk_local_sessions():
|
||||||
|
"""Sync AnyDesk sessions from local endpoint every N minutes."""
|
||||||
|
if not settings.ANYDESK_LOCAL_SYNC_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("🔄 AnyDesk local sync started")
|
||||||
|
result = await anydesk_service.fetch_sessions_from_local_endpoint(
|
||||||
|
endpoint_url=settings.ANYDESK_LOCAL_SESSIONS_URL,
|
||||||
|
timeout_seconds=settings.ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS,
|
||||||
|
dry_run=settings.ANYDESK_LOCAL_SYNC_DRY_RUN,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
logger.error("❌ AnyDesk local sync failed: %s", result["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ AnyDesk local sync completed: total=%s imported=%s updated=%s matched=%s errors=%s",
|
||||||
|
result.get("total", 0),
|
||||||
|
result.get("imported", 0),
|
||||||
|
result.get("updated", 0),
|
||||||
|
result.get("matched", 0),
|
||||||
|
len(result.get("errors") or []),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Unexpected AnyDesk local sync error: %s", exc)
|
||||||
@ -77,7 +77,7 @@ async def _process_reminder_queue():
|
|||||||
# Get assigned user name
|
# Get assigned user name
|
||||||
assigned_user = None
|
assigned_user = None
|
||||||
if event['ansvarlig_bruger_id']:
|
if event['ansvarlig_bruger_id']:
|
||||||
user_query = "SELECT full_name FROM users WHERE id = %s"
|
user_query = "SELECT full_name FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
||||||
assigned_user = user[0]['full_name'] if user else None
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
@ -174,7 +174,7 @@ async def _process_time_based_reminders():
|
|||||||
# Get assigned user name
|
# Get assigned user name
|
||||||
assigned_user = None
|
assigned_user = None
|
||||||
if reminder['ansvarlig_bruger_id']:
|
if reminder['ansvarlig_bruger_id']:
|
||||||
user_query = "SELECT full_name FROM users WHERE id = %s"
|
user_query = "SELECT full_name FROM users WHERE user_id = %s"
|
||||||
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
||||||
assigned_user = user[0]['full_name'] if user else None
|
assigned_user = user[0]['full_name'] if user else None
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,35 @@ def _extract_full_name(payload: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_login_candidates(payload: Any) -> List[str]:
|
||||||
|
raw = _extract_first_str(
|
||||||
|
payload,
|
||||||
|
["userPrincipalName", "upn", "email", "mail", "loginName", "login", "userName", "lastLoggedInUser"]
|
||||||
|
)
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
|
||||||
|
def _add(value: str) -> None:
|
||||||
|
v = (value or "").strip().lower()
|
||||||
|
if v and v not in candidates:
|
||||||
|
candidates.append(v)
|
||||||
|
|
||||||
|
_add(raw)
|
||||||
|
# DOMAIN\\user or provider/user -> user
|
||||||
|
if "\\" in raw:
|
||||||
|
_add(raw.split("\\")[-1])
|
||||||
|
if "/" in raw:
|
||||||
|
_add(raw.split("/")[-1])
|
||||||
|
|
||||||
|
# email local-part fallback
|
||||||
|
if "@" in raw:
|
||||||
|
_add(raw.split("@", 1)[0])
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def _detect_asset_type(payload: Any) -> str:
|
def _detect_asset_type(payload: Any) -> str:
|
||||||
device_type = _extract_first_str(payload, ["deviceType", "type"])
|
device_type = _extract_first_str(payload, ["deviceType", "type"])
|
||||||
if device_type:
|
if device_type:
|
||||||
@ -104,6 +133,57 @@ def _match_contact(full_name: str, company: str) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _match_contact_by_login(login_candidate: str, company: Optional[str] = None) -> Optional[int]:
|
||||||
|
if not login_candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try scoped match first when company is known to reduce false positives.
|
||||||
|
if company:
|
||||||
|
scoped_query = """
|
||||||
|
SELECT id
|
||||||
|
FROM contacts
|
||||||
|
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
|
||||||
|
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
scoped = execute_query(scoped_query, (login_candidate, company))
|
||||||
|
if scoped:
|
||||||
|
return scoped[0]["id"]
|
||||||
|
|
||||||
|
scoped_local_part_query = """
|
||||||
|
SELECT id
|
||||||
|
FROM contacts
|
||||||
|
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
|
||||||
|
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
scoped_local_part = execute_query(scoped_local_part_query, (login_candidate, company))
|
||||||
|
if scoped_local_part:
|
||||||
|
return scoped_local_part[0]["id"]
|
||||||
|
|
||||||
|
email_query = """
|
||||||
|
SELECT id
|
||||||
|
FROM contacts
|
||||||
|
WHERE LOWER(COALESCE(email, '')) = LOWER(%s)
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
by_email = execute_query(email_query, (login_candidate,))
|
||||||
|
if by_email:
|
||||||
|
return by_email[0]["id"]
|
||||||
|
|
||||||
|
local_part_query = """
|
||||||
|
SELECT id
|
||||||
|
FROM contacts
|
||||||
|
WHERE LOWER(split_part(COALESCE(email, ''), '@', 1)) = LOWER(%s)
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
by_local_part = execute_query(local_part_query, (login_candidate,))
|
||||||
|
if by_local_part:
|
||||||
|
return by_local_part[0]["id"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_contact_customer(contact_id: int) -> Optional[int]:
|
def _get_contact_customer(contact_id: int) -> Optional[int]:
|
||||||
query = """
|
query = """
|
||||||
SELECT customer_id
|
SELECT customer_id
|
||||||
@ -213,7 +293,14 @@ async def sync_eset_hardware() -> None:
|
|||||||
|
|
||||||
full_name = _extract_full_name(details)
|
full_name = _extract_full_name(details)
|
||||||
company = _extract_company(details)
|
company = _extract_company(details)
|
||||||
|
login_candidates = _extract_login_candidates(details)
|
||||||
|
|
||||||
contact_id = _match_contact(full_name, company) if full_name and company else None
|
contact_id = _match_contact(full_name, company) if full_name and company else None
|
||||||
|
if not contact_id:
|
||||||
|
for login_candidate in login_candidates:
|
||||||
|
contact_id = _match_contact_by_login(login_candidate, company)
|
||||||
|
if contact_id:
|
||||||
|
break
|
||||||
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
|
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
|
||||||
@ -237,6 +324,16 @@ async def sync_eset_hardware() -> None:
|
|||||||
update_fields.append("brand = %s")
|
update_fields.append("brand = %s")
|
||||||
update_params.append(brand)
|
update_params.append(brand)
|
||||||
|
|
||||||
|
# Auto-created ESET devices are customer devices by default unless explicitly reassigned.
|
||||||
|
if customer_id:
|
||||||
|
update_fields.append("current_owner_type = %s")
|
||||||
|
update_params.append("customer")
|
||||||
|
update_fields.append("current_owner_customer_id = %s")
|
||||||
|
update_params.append(customer_id)
|
||||||
|
elif existing[0].get("notes") == "Auto-created from ESET" and existing[0].get("current_owner_type") != "customer":
|
||||||
|
update_fields.append("current_owner_type = %s")
|
||||||
|
update_params.append("customer")
|
||||||
|
|
||||||
update_params.append(hardware_id)
|
update_params.append(hardware_id)
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE hardware_assets
|
UPDATE hardware_assets
|
||||||
@ -245,7 +342,8 @@ async def sync_eset_hardware() -> None:
|
|||||||
"""
|
"""
|
||||||
execute_query(update_query, tuple(update_params))
|
execute_query(update_query, tuple(update_params))
|
||||||
else:
|
else:
|
||||||
owner_type = "customer" if customer_id else "bmc"
|
# ESET sync auto-creates customer endpoints; ownership can be refined later if needed.
|
||||||
|
owner_type = "customer"
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO hardware_assets (
|
INSERT INTO hardware_assets (
|
||||||
asset_type, brand, model, serial_number,
|
asset_type, brand, model, serial_number,
|
||||||
|
|||||||
@ -7,11 +7,9 @@ Runs daily at 04:00
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
|
||||||
import json
|
import json
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import execute_query, get_db_connection
|
from app.core.database import execute_query, get_db_connection
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -19,11 +17,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
async def process_subscriptions():
|
async def process_subscriptions():
|
||||||
"""
|
"""
|
||||||
Main job: Process subscriptions due for invoicing
|
Main job: Process subscriptions due for invoicing.
|
||||||
- Find active subscriptions where next_invoice_date <= TODAY
|
- Find active subscriptions where next_invoice_date <= today
|
||||||
- Create ordre draft with line items from subscription
|
- Skip subscriptions blocked for invoicing (missing asset/serial)
|
||||||
- Advance period_start and next_invoice_date based on billing_interval
|
- Aggregate eligible subscriptions into one ordre_draft per customer + merge key + due date + billing direction
|
||||||
- Log all actions for audit trail
|
- Advance period_start and next_invoice_date for processed subscriptions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -39,9 +37,14 @@ async def process_subscriptions():
|
|||||||
c.name AS customer_name,
|
c.name AS customer_name,
|
||||||
s.product_name,
|
s.product_name,
|
||||||
s.billing_interval,
|
s.billing_interval,
|
||||||
|
s.billing_direction,
|
||||||
|
s.advance_months,
|
||||||
s.price,
|
s.price,
|
||||||
s.next_invoice_date,
|
s.next_invoice_date,
|
||||||
s.period_start,
|
s.period_start,
|
||||||
|
s.invoice_merge_key,
|
||||||
|
s.billing_blocked,
|
||||||
|
s.billing_block_reason,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(
|
(
|
||||||
SELECT json_agg(
|
SELECT json_agg(
|
||||||
@ -51,7 +54,12 @@ async def process_subscriptions():
|
|||||||
'quantity', si.quantity,
|
'quantity', si.quantity,
|
||||||
'unit_price', si.unit_price,
|
'unit_price', si.unit_price,
|
||||||
'line_total', si.line_total,
|
'line_total', si.line_total,
|
||||||
'product_id', si.product_id
|
'product_id', si.product_id,
|
||||||
|
'asset_id', si.asset_id,
|
||||||
|
'billing_blocked', si.billing_blocked,
|
||||||
|
'billing_block_reason', si.billing_block_reason,
|
||||||
|
'period_from', si.period_from,
|
||||||
|
'period_to', si.period_to
|
||||||
) ORDER BY si.id
|
) ORDER BY si.id
|
||||||
)
|
)
|
||||||
FROM sag_subscription_items si
|
FROM sag_subscription_items si
|
||||||
@ -75,109 +83,185 @@ async def process_subscriptions():
|
|||||||
|
|
||||||
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
|
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
|
||||||
|
|
||||||
|
blocked_count = 0
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
|
grouped_subscriptions = {}
|
||||||
for sub in subscriptions:
|
for sub in subscriptions:
|
||||||
|
if sub.get('billing_blocked'):
|
||||||
|
blocked_count += 1
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Subscription %s skipped due to billing block: %s",
|
||||||
|
sub.get('id'),
|
||||||
|
sub.get('billing_block_reason') or 'unknown reason'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
group_key = (
|
||||||
|
int(sub['customer_id']),
|
||||||
|
str(sub.get('invoice_merge_key') or f"cust-{sub['customer_id']}"),
|
||||||
|
str(sub.get('next_invoice_date')),
|
||||||
|
str(sub.get('billing_direction') or 'forward'),
|
||||||
|
)
|
||||||
|
grouped_subscriptions.setdefault(group_key, []).append(sub)
|
||||||
|
|
||||||
|
for group in grouped_subscriptions.values():
|
||||||
try:
|
try:
|
||||||
await _process_single_subscription(sub)
|
count = await _process_subscription_group(group)
|
||||||
processed_count += 1
|
processed_count += count
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True)
|
logger.error("❌ Failed processing subscription group: %s", e, exc_info=True)
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|
||||||
logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors")
|
logger.info(
|
||||||
|
"✅ Subscription processing complete: %s processed, %s blocked, %s errors",
|
||||||
|
processed_count,
|
||||||
|
blocked_count,
|
||||||
|
error_count,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
|
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _process_single_subscription(sub: dict):
|
async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||||
"""Process a single subscription: create ordre draft and advance period"""
|
"""Create one aggregated ordre draft for a group of subscriptions and advance all periods."""
|
||||||
|
|
||||||
subscription_id = sub['id']
|
if not subscriptions:
|
||||||
logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}")
|
return 0
|
||||||
|
|
||||||
|
first = subscriptions[0]
|
||||||
|
customer_id = first['customer_id']
|
||||||
|
customer_name = first.get('customer_name') or f"Customer #{customer_id}"
|
||||||
|
billing_direction = first.get('billing_direction') or 'forward'
|
||||||
|
invoice_aggregate_key = first.get('invoice_merge_key') or f"cust-{customer_id}"
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert line_items from JSON to list
|
|
||||||
line_items = sub.get('line_items', [])
|
|
||||||
if isinstance(line_items, str):
|
|
||||||
line_items = json.loads(line_items)
|
|
||||||
|
|
||||||
# Build ordre draft lines_json
|
|
||||||
ordre_lines = []
|
ordre_lines = []
|
||||||
for item in line_items:
|
source_subscription_ids = []
|
||||||
product_number = str(item.get('product_id', 'SUB'))
|
coverage_start = None
|
||||||
ordre_lines.append({
|
coverage_end = None
|
||||||
"product": {
|
|
||||||
"productNumber": product_number,
|
|
||||||
"description": item.get('description', '')
|
|
||||||
},
|
|
||||||
"quantity": float(item.get('quantity', 1)),
|
|
||||||
"unitNetPrice": float(item.get('unit_price', 0)),
|
|
||||||
"totalNetAmount": float(item.get('line_total', 0)),
|
|
||||||
"discountPercentage": 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create ordre draft title with period information
|
for sub in subscriptions:
|
||||||
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
subscription_id = int(sub['id'])
|
||||||
next_period_start = _calculate_next_period_start(period_start, sub['billing_interval'])
|
source_subscription_ids.append(subscription_id)
|
||||||
|
|
||||||
title = f"Abonnement: {sub['product_name']}"
|
line_items = sub.get('line_items', [])
|
||||||
notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}"
|
if isinstance(line_items, str):
|
||||||
|
line_items = json.loads(line_items)
|
||||||
|
|
||||||
if sub.get('sag_id'):
|
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||||
notes += f"\nSag: {sub['sag_name']}"
|
period_end = _calculate_next_period_start(period_start, sub['billing_interval'])
|
||||||
|
if coverage_start is None or period_start < coverage_start:
|
||||||
|
coverage_start = period_start
|
||||||
|
if coverage_end is None or period_end > coverage_end:
|
||||||
|
coverage_end = period_end
|
||||||
|
|
||||||
|
for item in line_items:
|
||||||
|
if item.get('billing_blocked'):
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Skipping blocked subscription item %s on subscription %s",
|
||||||
|
item.get('id'),
|
||||||
|
subscription_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
product_number = str(item.get('product_id', 'SUB'))
|
||||||
|
ordre_lines.append({
|
||||||
|
"product": {
|
||||||
|
"productNumber": product_number,
|
||||||
|
"description": item.get('description', '')
|
||||||
|
},
|
||||||
|
"quantity": float(item.get('quantity', 1)),
|
||||||
|
"unitNetPrice": float(item.get('unit_price', 0)),
|
||||||
|
"totalNetAmount": float(item.get('line_total', 0)),
|
||||||
|
"discountPercentage": 0,
|
||||||
|
"metadata": {
|
||||||
|
"subscription_id": subscription_id,
|
||||||
|
"asset_id": item.get('asset_id'),
|
||||||
|
"period_from": str(item.get('period_from') or period_start),
|
||||||
|
"period_to": str(item.get('period_to') or period_end),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not ordre_lines:
|
||||||
|
logger.warning("⚠️ No invoiceable lines in subscription group for customer %s", customer_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
title = f"Abonnementer: {customer_name}"
|
||||||
|
notes = (
|
||||||
|
f"Aggregated abonnement faktura\n"
|
||||||
|
f"Kunde: {customer_name}\n"
|
||||||
|
f"Coverage: {coverage_start} til {coverage_end}\n"
|
||||||
|
f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Insert ordre draft
|
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO ordre_drafts (
|
INSERT INTO ordre_drafts (
|
||||||
title,
|
title,
|
||||||
customer_id,
|
customer_id,
|
||||||
lines_json,
|
lines_json,
|
||||||
notes,
|
notes,
|
||||||
|
coverage_start,
|
||||||
|
coverage_end,
|
||||||
|
billing_direction,
|
||||||
|
source_subscription_ids,
|
||||||
|
invoice_aggregate_key,
|
||||||
layout_number,
|
layout_number,
|
||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
|
sync_status,
|
||||||
export_status_json,
|
export_status_json,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cursor.execute(insert_query, (
|
cursor.execute(insert_query, (
|
||||||
title,
|
title,
|
||||||
sub['customer_id'],
|
customer_id,
|
||||||
json.dumps(ordre_lines, ensure_ascii=False),
|
json.dumps(ordre_lines, ensure_ascii=False),
|
||||||
notes,
|
notes,
|
||||||
|
coverage_start,
|
||||||
|
coverage_end,
|
||||||
|
billing_direction,
|
||||||
|
source_subscription_ids,
|
||||||
|
invoice_aggregate_key,
|
||||||
1, # Default layout
|
1, # Default layout
|
||||||
None, # System-created
|
None, # System-created
|
||||||
json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False)
|
'pending',
|
||||||
|
json.dumps({"source": "subscription", "subscription_ids": source_subscription_ids}, ensure_ascii=False)
|
||||||
))
|
))
|
||||||
|
|
||||||
ordre_id = cursor.fetchone()[0]
|
ordre_id = cursor.fetchone()[0]
|
||||||
logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}")
|
logger.info(
|
||||||
|
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
|
||||||
|
ordre_id,
|
||||||
|
len(source_subscription_ids),
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate new period dates
|
for sub in subscriptions:
|
||||||
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
subscription_id = int(sub['id'])
|
||||||
new_period_start = next_period_start
|
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||||
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
|
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
|
||||||
|
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
|
||||||
|
|
||||||
# Update subscription with new period dates
|
cursor.execute(
|
||||||
update_query = """
|
"""
|
||||||
UPDATE sag_subscriptions
|
UPDATE sag_subscriptions
|
||||||
SET period_start = %s,
|
SET period_start = %s,
|
||||||
next_invoice_date = %s,
|
next_invoice_date = %s,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
""",
|
||||||
|
(new_period_start, new_next_invoice_date, subscription_id)
|
||||||
cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id))
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}")
|
return len(source_subscription_ids)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|||||||
119
app/jobs/reconcile_ordre_drafts.py
Normal file
119
app/jobs/reconcile_ordre_drafts.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Reconcile ordre draft sync lifecycle.
|
||||||
|
|
||||||
|
Promotes sync_status based on known economic references on ordre_drafts:
|
||||||
|
- pending/failed + economic_order_number -> exported
|
||||||
|
- exported + economic_invoice_number -> posted
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from app.core.database import execute_query, get_db_connection, release_db_connection
|
||||||
|
from app.services.economic_service import get_economic_service
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_ordre_drafts_sync_status(apply_changes: bool = True) -> Dict[str, Any]:
|
||||||
|
"""Reconcile ordre_drafts sync statuses and optionally persist changes."""
|
||||||
|
|
||||||
|
drafts = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, sync_status, economic_order_number, economic_invoice_number
|
||||||
|
FROM ordre_drafts
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
changes: List[Dict[str, Any]] = []
|
||||||
|
invoice_status_cache: Dict[str, str] = {}
|
||||||
|
economic_service = get_economic_service()
|
||||||
|
|
||||||
|
for draft in drafts:
|
||||||
|
current = (draft.get("sync_status") or "pending").strip().lower()
|
||||||
|
target = current
|
||||||
|
|
||||||
|
if current in {"pending", "failed"} and draft.get("economic_order_number"):
|
||||||
|
target = "exported"
|
||||||
|
if target == "exported" and draft.get("economic_invoice_number"):
|
||||||
|
target = "posted"
|
||||||
|
|
||||||
|
invoice_number = str(draft.get("economic_invoice_number") or "").strip()
|
||||||
|
if invoice_number:
|
||||||
|
if invoice_number not in invoice_status_cache:
|
||||||
|
invoice_status_cache[invoice_number] = await economic_service.get_invoice_lifecycle_status(invoice_number)
|
||||||
|
lifecycle = invoice_status_cache[invoice_number]
|
||||||
|
if lifecycle == "paid":
|
||||||
|
target = "paid"
|
||||||
|
elif lifecycle in {"booked", "unpaid"} and target in {"pending", "failed", "exported"}:
|
||||||
|
target = "posted"
|
||||||
|
|
||||||
|
if target != current:
|
||||||
|
changes.append(
|
||||||
|
{
|
||||||
|
"draft_id": draft.get("id"),
|
||||||
|
"from": current,
|
||||||
|
"to": target,
|
||||||
|
"economic_invoice_number": invoice_number or None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply_changes and changes:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||||
|
for change in changes:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ordre_drafts
|
||||||
|
SET sync_status = %s,
|
||||||
|
last_sync_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
last_exported_at = CASE
|
||||||
|
WHEN %s IN ('exported', 'posted', 'paid') THEN CURRENT_TIMESTAMP
|
||||||
|
ELSE last_exported_at
|
||||||
|
END
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(change["to"], change["to"], change["draft_id"]),
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ordre_draft_sync_events (
|
||||||
|
draft_id,
|
||||||
|
event_type,
|
||||||
|
from_status,
|
||||||
|
to_status,
|
||||||
|
event_payload,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s::jsonb, NULL)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
change["draft_id"],
|
||||||
|
"sync_status_reconcile",
|
||||||
|
change["from"],
|
||||||
|
change["to"],
|
||||||
|
'{"source":"reconcile_job"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ Reconciled ordre draft sync status: %s changes (%s)",
|
||||||
|
len(changes),
|
||||||
|
"applied" if apply_changes else "preview",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "applied" if apply_changes else "preview",
|
||||||
|
"change_count": len(changes),
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
@ -35,6 +35,11 @@ class CustomerUpdate(BaseModel):
|
|||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
invoice_email: Optional[str] = None
|
invoice_email: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
standard_margin_percent: Optional[float] = None
|
||||||
|
standard_hourly_rate: Optional[float] = None
|
||||||
|
special_freight_price: Optional[float] = None
|
||||||
|
supplier_service_enrolled: Optional[bool] = None
|
||||||
|
invoice_fee_amount: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class Customer(CustomerBase):
|
class Customer(CustomerBase):
|
||||||
@ -280,6 +285,7 @@ class TodoStepCreate(TodoStepBase):
|
|||||||
class TodoStepUpdate(BaseModel):
|
class TodoStepUpdate(BaseModel):
|
||||||
"""Schema for updating a todo step"""
|
"""Schema for updating a todo step"""
|
||||||
is_done: Optional[bool] = None
|
is_done: Optional[bool] = None
|
||||||
|
is_next: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class TodoStep(TodoStepBase):
|
class TodoStep(TodoStepBase):
|
||||||
@ -287,6 +293,7 @@ class TodoStep(TodoStepBase):
|
|||||||
id: int
|
id: int
|
||||||
sag_id: int
|
sag_id: int
|
||||||
is_done: bool
|
is_done: bool
|
||||||
|
is_next: bool = False
|
||||||
created_by_user_id: Optional[int] = None
|
created_by_user_id: Optional[int] = None
|
||||||
created_by_name: Optional[str] = None
|
created_by_name: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
0
app/modules/bottom_bar/__init__.py
Normal file
0
app/modules/bottom_bar/__init__.py
Normal file
0
app/modules/bottom_bar/backend/__init__.py
Normal file
0
app/modules/bottom_bar/backend/__init__.py
Normal file
121
app/modules/bottom_bar/backend/public_router.py
Normal file
121
app/modules/bottom_bar/backend/public_router.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from .service import get_active_timer, get_dashboard_status, get_notifications
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
if user_id is not None:
|
||||||
|
try:
|
||||||
|
return int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id_param = request.query_params.get("user_id")
|
||||||
|
if user_id_param:
|
||||||
|
try:
|
||||||
|
return int(user_id_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ws_payload(websocket: WebSocket) -> Optional[dict]:
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
||||||
|
if not token and auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
if not payload:
|
||||||
|
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/dashboard/status")
|
||||||
|
async def get_dashboard_status_endpoint() -> dict:
|
||||||
|
return get_dashboard_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/timer/active")
|
||||||
|
async def get_active_timer_endpoint(request: Request) -> dict:
|
||||||
|
user_id = _resolve_user_id_from_request(request)
|
||||||
|
return get_active_timer(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/notifications")
|
||||||
|
async def get_notifications_endpoint(request: Request, limit: int = 20) -> dict:
|
||||||
|
user_id = _resolve_user_id_from_request(request)
|
||||||
|
return get_notifications(user_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/bottom-bar/ws")
|
||||||
|
async def bottom_bar_ws(websocket: WebSocket):
|
||||||
|
payload = _resolve_ws_payload(websocket)
|
||||||
|
if not payload:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(payload.get("sub")) if payload.get("sub") is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
initial_status = get_dashboard_status()
|
||||||
|
initial_notifications = get_notifications(user_id, limit=20)
|
||||||
|
await websocket.send_json({"event": "status_delta", "data": initial_status})
|
||||||
|
await websocket.send_json({"event": "notification_delta", "data": initial_notifications})
|
||||||
|
|
||||||
|
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
|
||||||
|
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
|
||||||
|
last_timer_elapsed = -1
|
||||||
|
status_tick = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
timer = get_active_timer(user_id)
|
||||||
|
elapsed = int(timer.get("elapsed") or 0)
|
||||||
|
if elapsed != last_timer_elapsed:
|
||||||
|
await websocket.send_json({"event": "timer_tick", "data": timer})
|
||||||
|
last_timer_elapsed = elapsed
|
||||||
|
|
||||||
|
status_tick += 1
|
||||||
|
if status_tick >= 5:
|
||||||
|
status = get_dashboard_status()
|
||||||
|
notifications = get_notifications(user_id, limit=20)
|
||||||
|
|
||||||
|
status_json = json.dumps(status, sort_keys=True, default=str)
|
||||||
|
if status_json != last_status_json:
|
||||||
|
await websocket.send_json({"event": "status_delta", "data": status})
|
||||||
|
last_status_json = status_json
|
||||||
|
|
||||||
|
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
|
||||||
|
if notifications_json != last_notifications_json:
|
||||||
|
await websocket.send_json({"event": "notification_delta", "data": notifications})
|
||||||
|
last_notifications_json = notifications_json
|
||||||
|
|
||||||
|
status_tick = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("Bottom bar websocket disconnected user_id=%s", user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Bottom bar websocket error user_id=%s error=%s", user_id, exc)
|
||||||
762
app/modules/bottom_bar/backend/router.py
Normal file
762
app/modules/bottom_bar/backend/router.py
Normal file
@ -0,0 +1,762 @@
|
|||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.auth_dependencies import get_current_user
|
||||||
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
|
|
||||||
|
from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_USER_NOTES_SCHEMA_READY = False
|
||||||
|
|
||||||
|
|
||||||
|
class BossAssignPayload(BaseModel):
|
||||||
|
case_id: int
|
||||||
|
assignee_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BossAssignNextPayload(BaseModel):
|
||||||
|
assignee_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class UserNoteCreatePayload(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: str
|
||||||
|
is_pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserNoteUpdatePayload(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
is_pinned: Optional[bool] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteToCaseCommentPayload(BaseModel):
|
||||||
|
sag_id: int
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteToContactPayload(BaseModel):
|
||||||
|
contact_id: int
|
||||||
|
field: str
|
||||||
|
value: Optional[str] = None
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
mode: str = "append"
|
||||||
|
|
||||||
|
|
||||||
|
class NoteToCustomerPayload(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
field: str
|
||||||
|
value: Optional[str] = None
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
mode: str = "append"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user_notes_schema() -> None:
|
||||||
|
global _USER_NOTES_SCHEMA_READY
|
||||||
|
if _USER_NOTES_SCHEMA_READY:
|
||||||
|
return
|
||||||
|
|
||||||
|
exists = execute_query_single("SELECT to_regclass('public.user_notes') AS table_name") or {}
|
||||||
|
if exists.get("table_name"):
|
||||||
|
_USER_NOTES_SCHEMA_READY = True
|
||||||
|
return
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notes_user_active
|
||||||
|
ON user_notes (user_id, is_archived, is_pinned, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated
|
||||||
|
ON user_notes (user_id, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION update_user_notes_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
execute_query("DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes")
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_user_notes_updated_at
|
||||||
|
BEFORE UPDATE ON user_notes
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_user_notes_updated_at()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_USER_NOTES_SCHEMA_READY = True
|
||||||
|
logger.warning("⚠️ user_notes table was missing and has been created automatically")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_current_user_display_name(current_user: dict) -> str:
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
return "System"
|
||||||
|
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT full_name, username
|
||||||
|
FROM users
|
||||||
|
WHERE user_id = %s
|
||||||
|
""",
|
||||||
|
(int(current_user_id),),
|
||||||
|
) or {}
|
||||||
|
return str(row.get("full_name") or row.get("username") or f"Bruger #{current_user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_owned_note_or_404(note_id: int, user_id: int) -> dict:
|
||||||
|
_ensure_user_notes_schema()
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, title, content, is_pinned, is_archived, created_at, updated_at
|
||||||
|
FROM user_notes
|
||||||
|
WHERE id = %s
|
||||||
|
AND user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(int(note_id), int(user_id)),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Note ikke fundet")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_note_text(value: Optional[str]) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_merge_value(current_value: Optional[str], incoming_value: str, mode: str) -> str:
|
||||||
|
incoming = _normalize_note_text(incoming_value)
|
||||||
|
if not incoming:
|
||||||
|
return str(current_value or "")
|
||||||
|
|
||||||
|
current = str(current_value or "").strip()
|
||||||
|
normalized_mode = str(mode or "append").strip().lower()
|
||||||
|
if normalized_mode == "replace":
|
||||||
|
return incoming
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
return incoming
|
||||||
|
if incoming in current:
|
||||||
|
return current
|
||||||
|
return f"{current}\n{incoming}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notes")
|
||||||
|
@router.get("/notes/")
|
||||||
|
async def list_user_notes(
|
||||||
|
include_archived: bool = Query(default=False),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
_ensure_user_notes_schema()
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, title, content, is_pinned, is_archived, created_at, updated_at
|
||||||
|
FROM user_notes
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (%s = TRUE OR is_archived = FALSE)
|
||||||
|
ORDER BY is_pinned DESC, updated_at DESC, id DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
(int(current_user_id), bool(include_archived), int(limit), int(offset)),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
total_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM user_notes
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (%s = TRUE OR is_archived = FALSE)
|
||||||
|
""",
|
||||||
|
(int(current_user_id), bool(include_archived)),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": rows,
|
||||||
|
"count": int(total_row.get("count") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notes")
|
||||||
|
@router.post("/notes/")
|
||||||
|
async def create_user_note(payload: UserNoteCreatePayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
_ensure_user_notes_schema()
|
||||||
|
|
||||||
|
content = _normalize_note_text(payload.content)
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
|
||||||
|
|
||||||
|
title = _normalize_note_text(payload.title)
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_notes (user_id, title, content, is_pinned)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(int(current_user_id), title, content, bool(payload.is_pinned)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return row or {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/notes/{note_id}")
|
||||||
|
@router.patch("/notes/{note_id}/")
|
||||||
|
async def update_user_note(note_id: int, payload: UserNoteUpdatePayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
_ensure_user_notes_schema()
|
||||||
|
|
||||||
|
_get_owned_note_or_404(note_id, int(current_user_id))
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if payload.title is not None:
|
||||||
|
sets.append("title = %s")
|
||||||
|
params.append(_normalize_note_text(payload.title))
|
||||||
|
if payload.content is not None:
|
||||||
|
content = _normalize_note_text(payload.content)
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt")
|
||||||
|
sets.append("content = %s")
|
||||||
|
params.append(content)
|
||||||
|
if payload.is_pinned is not None:
|
||||||
|
sets.append("is_pinned = %s")
|
||||||
|
params.append(bool(payload.is_pinned))
|
||||||
|
if payload.is_archived is not None:
|
||||||
|
sets.append("is_archived = %s")
|
||||||
|
params.append(bool(payload.is_archived))
|
||||||
|
|
||||||
|
if not sets:
|
||||||
|
return _get_owned_note_or_404(note_id, int(current_user_id))
|
||||||
|
|
||||||
|
params.extend([int(note_id), int(current_user_id)])
|
||||||
|
row = execute_query_single(
|
||||||
|
f"""
|
||||||
|
UPDATE user_notes
|
||||||
|
SET {', '.join(sets)}
|
||||||
|
WHERE id = %s
|
||||||
|
AND user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Note ikke fundet")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/notes/{note_id}")
|
||||||
|
@router.delete("/notes/{note_id}/")
|
||||||
|
async def delete_user_note(note_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
_ensure_user_notes_schema()
|
||||||
|
|
||||||
|
deleted = execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE user_notes
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
AND user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(int(note_id), int(current_user_id)),
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Note ikke fundet")
|
||||||
|
return {"status": "deleted", "note_id": int(note_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notes/{note_id}/actions/sag-comment")
|
||||||
|
@router.post("/notes/{note_id}/actions/sag-comment/")
|
||||||
|
async def note_to_case_comment(note_id: int, payload: NoteToCaseCommentPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
note = _get_owned_note_or_404(note_id, int(current_user_id))
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(int(payload.sag_id),),
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Sag ikke fundet")
|
||||||
|
|
||||||
|
text = _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
|
||||||
|
|
||||||
|
author = _resolve_current_user_display_name(current_user)
|
||||||
|
created = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, indhold, forfatter)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id, sag_id, indhold, forfatter, created_at
|
||||||
|
""",
|
||||||
|
(int(payload.sag_id), text, author),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "inserted",
|
||||||
|
"target": "sag_comment",
|
||||||
|
"note_id": int(note_id),
|
||||||
|
"sag_id": int(payload.sag_id),
|
||||||
|
"comment": created,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notes/{note_id}/actions/contact-update")
|
||||||
|
@router.post("/notes/{note_id}/actions/contact-update/")
|
||||||
|
async def note_to_contact_update(note_id: int, payload: NoteToContactPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
note = _get_owned_note_or_404(note_id, int(current_user_id))
|
||||||
|
allowed_fields = {"phone", "mobile", "email", "title", "department"}
|
||||||
|
field = str(payload.field or "").strip().lower()
|
||||||
|
if field not in allowed_fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldigt kontaktfelt")
|
||||||
|
|
||||||
|
contact = execute_query_single(
|
||||||
|
f"SELECT id, {field} FROM contacts WHERE id = %s",
|
||||||
|
(int(payload.contact_id),),
|
||||||
|
)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Kontakt ikke fundet")
|
||||||
|
|
||||||
|
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
|
||||||
|
if not incoming:
|
||||||
|
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
|
||||||
|
|
||||||
|
merged = _build_merge_value(contact.get(field), incoming, payload.mode)
|
||||||
|
updated = execute_query_single(
|
||||||
|
f"""
|
||||||
|
UPDATE contacts
|
||||||
|
SET {field} = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, {field}
|
||||||
|
""",
|
||||||
|
(merged, int(payload.contact_id)),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"target": "contact",
|
||||||
|
"note_id": int(note_id),
|
||||||
|
"contact_id": int(payload.contact_id),
|
||||||
|
"field": field,
|
||||||
|
"value": updated.get(field),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notes/{note_id}/actions/customer-update")
|
||||||
|
@router.post("/notes/{note_id}/actions/customer-update/")
|
||||||
|
async def note_to_customer_update(note_id: int, payload: NoteToCustomerPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
note = _get_owned_note_or_404(note_id, int(current_user_id))
|
||||||
|
field = str(payload.field or "").strip().lower()
|
||||||
|
allowed_fields = {"phone", "mobile_phone", "email", "address", "invoice_email", "note"}
|
||||||
|
if field not in allowed_fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Ugyldigt firmafelt")
|
||||||
|
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id FROM customers WHERE id = %s",
|
||||||
|
(int(payload.customer_id),),
|
||||||
|
)
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Firma ikke fundet")
|
||||||
|
|
||||||
|
incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content"))
|
||||||
|
if not incoming:
|
||||||
|
raise HTTPException(status_code=400, detail="Ingen tekst at indsætte")
|
||||||
|
|
||||||
|
if field == "note":
|
||||||
|
author = _resolve_current_user_display_name(current_user)
|
||||||
|
created = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO customer_notes (customer_id, note_type, note, created_by)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, customer_id, note_type, note, created_by, created_at
|
||||||
|
""",
|
||||||
|
(int(payload.customer_id), "general", incoming, author),
|
||||||
|
) or {}
|
||||||
|
return {
|
||||||
|
"status": "inserted",
|
||||||
|
"target": "customer_note",
|
||||||
|
"note_id": int(note_id),
|
||||||
|
"customer_id": int(payload.customer_id),
|
||||||
|
"record": created,
|
||||||
|
}
|
||||||
|
|
||||||
|
current = execute_query_single(
|
||||||
|
f"SELECT {field} FROM customers WHERE id = %s",
|
||||||
|
(int(payload.customer_id),),
|
||||||
|
) or {}
|
||||||
|
merged = _build_merge_value(current.get(field), incoming, payload.mode)
|
||||||
|
updated = execute_query_single(
|
||||||
|
f"""
|
||||||
|
UPDATE customers
|
||||||
|
SET {field} = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, {field}
|
||||||
|
""",
|
||||||
|
(merged, int(payload.customer_id)),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "updated",
|
||||||
|
"target": "customer",
|
||||||
|
"note_id": int(note_id),
|
||||||
|
"customer_id": int(payload.customer_id),
|
||||||
|
"field": field,
|
||||||
|
"value": updated.get(field),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id_from_request(request: Request) -> Optional[int]:
|
||||||
|
state_user_id = getattr(request.state, "user_id", None)
|
||||||
|
if state_user_id is not None:
|
||||||
|
try:
|
||||||
|
return int(state_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
user_id_param = request.query_params.get("user_id")
|
||||||
|
if user_id_param:
|
||||||
|
try:
|
||||||
|
return int(user_id_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
token = (request.cookies.get("access_token") or "").strip() or None
|
||||||
|
payload = AuthService.verify_token(token) if token else None
|
||||||
|
sub_claim = payload.get("sub") if payload else None
|
||||||
|
if sub_claim is not None:
|
||||||
|
try:
|
||||||
|
return int(sub_claim)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
user_id = int(current_user_id)
|
||||||
|
force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin"))
|
||||||
|
context_path = request.query_params.get("context") or ""
|
||||||
|
return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timers/own")
|
||||||
|
async def get_own_timers(
|
||||||
|
paused_limit: int = Query(default=10, ge=1, le=25),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boss/unassigned-cases")
|
||||||
|
async def list_unassigned_open_cases(
|
||||||
|
limit: int = Query(default=25, ge=1, le=100),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
return get_unassigned_open_cases(limit=limit)
|
||||||
|
|
||||||
|
from app.services.task_routing import TaskRouter
|
||||||
|
from app.services.m365_calendar import M365CalendarService
|
||||||
|
|
||||||
|
|
||||||
|
def _has_boss_access(current_user: dict) -> bool:
|
||||||
|
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
if current_user_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT LOWER(g.name) AS name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
""",
|
||||||
|
(int(current_user_id),),
|
||||||
|
) or []
|
||||||
|
names = [str(r.get("name") or "") for r in rows]
|
||||||
|
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
|
||||||
|
return any(any(token in name for token in tokens) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user_exists(user_id: int) -> None:
|
||||||
|
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Bruger ikke fundet")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_next_unassigned_case() -> Optional[dict]:
|
||||||
|
return execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, titel, priority
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND ansvarlig_bruger_id IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
||||||
|
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
COALESCE(updated_at, created_at) ASC,
|
||||||
|
id ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/next_task")
|
||||||
|
async def assign_next_task(
|
||||||
|
request: Request,
|
||||||
|
user_id: int | None = Query(default=None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Prefer authenticated user context; allow explicit user_id for controlled testing.
|
||||||
|
current_user_id = current_user.get("id")
|
||||||
|
resolved_user_id = user_id
|
||||||
|
if resolved_user_id is None and current_user_id is not None:
|
||||||
|
resolved_user_id = int(current_user_id)
|
||||||
|
if resolved_user_id is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required for task assignment")
|
||||||
|
|
||||||
|
# Kombinerer de nye services
|
||||||
|
router_svc = TaskRouter()
|
||||||
|
cal = M365CalendarService()
|
||||||
|
|
||||||
|
# Henter hvor meget fri tid medarbejderen har lige nu
|
||||||
|
free_mins = await cal.get_user_free_time("now", 2)
|
||||||
|
|
||||||
|
# Bed the engine allocate the next best task
|
||||||
|
task = await router_svc.get_next_best_task(resolved_user_id)
|
||||||
|
task = task or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"task": task,
|
||||||
|
"free_time_calculated": free_mins,
|
||||||
|
"message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). "
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/auto-assign-next")
|
||||||
|
async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
next_case = _get_next_unassigned_case()
|
||||||
|
if not next_case:
|
||||||
|
return {
|
||||||
|
"status": "noop",
|
||||||
|
"message": "Ingen ufordelte åbne sager at fordele.",
|
||||||
|
}
|
||||||
|
|
||||||
|
assignee = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
|
||||||
|
FROM users u
|
||||||
|
JOIN user_groups ug ON ug.user_id = u.user_id
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%'])
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if not assignee:
|
||||||
|
raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling")
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(assignee["user_id"]), int(next_case["id"])),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Sagen blev auto-fordelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee": {
|
||||||
|
"user_id": assignee.get("user_id"),
|
||||||
|
"name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/assign-case")
|
||||||
|
async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
_ensure_user_exists(int(payload.assignee_user_id))
|
||||||
|
|
||||||
|
case_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id, titel, priority
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
""",
|
||||||
|
(int(payload.case_id),),
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet")
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(payload.assignee_user_id), int(payload.case_id)),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Sagen blev tildelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee_user_id": int(payload.assignee_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/boss/assign-next-to-user")
|
||||||
|
async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)):
|
||||||
|
if not _has_boss_access(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions for boss action")
|
||||||
|
|
||||||
|
_ensure_user_exists(int(payload.assignee_user_id))
|
||||||
|
|
||||||
|
next_case = _get_next_unassigned_case()
|
||||||
|
if not next_case:
|
||||||
|
return {
|
||||||
|
"status": "noop",
|
||||||
|
"message": "Ingen ufordelte åbne sager at tildele.",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
"""
|
||||||
|
UPDATE sag_sager
|
||||||
|
SET ansvarlig_bruger_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, titel, priority, ansvarlig_bruger_id
|
||||||
|
""",
|
||||||
|
(int(payload.assignee_user_id), int(next_case["id"])),
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "assigned",
|
||||||
|
"message": "Næste ufordelte sag blev tildelt.",
|
||||||
|
"case": {
|
||||||
|
"id": updated.get("id"),
|
||||||
|
"title": updated.get("titel") or f"Sag #{updated.get('id')}",
|
||||||
|
"priority": updated.get("priority") or "normal",
|
||||||
|
},
|
||||||
|
"assignee_user_id": int(payload.assignee_user_id),
|
||||||
|
}
|
||||||
970
app/modules/bottom_bar/backend/service.py
Normal file
970
app/modules/bottom_bar/backend/service.py
Normal file
@ -0,0 +1,970 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
|
||||||
|
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_count(row: Optional[dict], key: str = "count") -> int:
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row.get(key) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _format_elapsed(seconds: int) -> str:
|
||||||
|
total = max(0, int(seconds or 0))
|
||||||
|
hours = total // 3600
|
||||||
|
minutes = (total % 3600) // 60
|
||||||
|
secs = total % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _priority_rank(priority: str) -> int:
|
||||||
|
normalized = str(priority or "").strip().lower()
|
||||||
|
if normalized in {"urgent", "critical", "kritisk"}:
|
||||||
|
return 3
|
||||||
|
if normalized in {"high", "høj"}:
|
||||||
|
return 2
|
||||||
|
if normalized in {"normal", "medium", "middel"}:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
) AS exists
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
return bool((row or {}).get("exists"))
|
||||||
|
|
||||||
|
|
||||||
|
def _table_columns(table_name: str) -> List[str]:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
) or []
|
||||||
|
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
||||||
|
if user_id is None:
|
||||||
|
return []
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT LOWER(g.name) AS name
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
) or []
|
||||||
|
return [str(r.get("name") or "").strip() for r in rows if r.get("name")]
|
||||||
|
|
||||||
|
|
||||||
|
def _can_view_boss_tab(user_id: Optional[int]) -> bool:
|
||||||
|
if user_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
group_names = _get_user_group_names(user_id)
|
||||||
|
if not group_names:
|
||||||
|
# Fail-open for authenticated users if group mapping is missing.
|
||||||
|
return True
|
||||||
|
|
||||||
|
leadership_tokens = (
|
||||||
|
"admin",
|
||||||
|
"manager",
|
||||||
|
"leder",
|
||||||
|
"chef",
|
||||||
|
"teknik",
|
||||||
|
"technician",
|
||||||
|
"support",
|
||||||
|
"drift",
|
||||||
|
"it",
|
||||||
|
)
|
||||||
|
return any(
|
||||||
|
any(token in group for token in leadership_tokens)
|
||||||
|
for group in group_names
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
||||||
|
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
||||||
|
setting_value = str((setting or {}).get("value") or "").strip().lower()
|
||||||
|
if setting_value not in {"1", "true", "yes", "on"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
pref = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT enabled
|
||||||
|
FROM user_module_preferences
|
||||||
|
WHERE user_id = %s AND module_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, "bottom_bar"),
|
||||||
|
)
|
||||||
|
if pref and pref.get("enabled") is not None:
|
||||||
|
return bool(pref.get("enabled"))
|
||||||
|
|
||||||
|
role = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT mrs.enabled
|
||||||
|
FROM module_role_settings mrs
|
||||||
|
JOIN user_groups ug ON ug.group_id = mrs.group_id
|
||||||
|
WHERE ug.user_id = %s
|
||||||
|
AND mrs.module_name = %s
|
||||||
|
ORDER BY mrs.enabled DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, "bottom_bar"),
|
||||||
|
)
|
||||||
|
if role and role.get("enabled") is not None:
|
||||||
|
return bool(role.get("enabled"))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_status() -> Dict[str, int]:
|
||||||
|
mails_unread = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM email_messages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND COALESCE(is_read, FALSE) = FALSE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_open = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_urgent = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sager_unassigned = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND ansvarlig_bruger_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mails_unread": mails_unread,
|
||||||
|
"sager_open": sager_open,
|
||||||
|
"sager_urgent": sager_urgent,
|
||||||
|
"sager_unassigned": sager_unassigned,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
if user_id is None:
|
||||||
|
return {
|
||||||
|
"active": False,
|
||||||
|
"sag_id": None,
|
||||||
|
"sag_navn": None,
|
||||||
|
"start_tid": None,
|
||||||
|
"elapsed": 0,
|
||||||
|
"elapsed_hhmmss": "00:00:00",
|
||||||
|
"time_entry_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.sag_id,
|
||||||
|
s.titel AS sag_navn,
|
||||||
|
t.start_tid,
|
||||||
|
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE t.medarbejder_id = %s
|
||||||
|
AND t.aktiv_timer = TRUE
|
||||||
|
AND t.slut_tid IS NULL
|
||||||
|
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not timer:
|
||||||
|
return {
|
||||||
|
"active": False,
|
||||||
|
"sag_id": None,
|
||||||
|
"sag_navn": None,
|
||||||
|
"start_tid": None,
|
||||||
|
"elapsed": 0,
|
||||||
|
"elapsed_hhmmss": "00:00:00",
|
||||||
|
"time_entry_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed = int(timer.get("elapsed") or 0)
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"sag_id": timer.get("sag_id"),
|
||||||
|
"sag_navn": timer.get("sag_navn"),
|
||||||
|
"start_tid": timer.get("start_tid"),
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"elapsed_hhmmss": _format_elapsed(elapsed),
|
||||||
|
"time_entry_id": timer.get("id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Dict[str, Any]:
|
||||||
|
active = get_active_timer(user_id)
|
||||||
|
if user_id is None:
|
||||||
|
return {
|
||||||
|
"active": active,
|
||||||
|
"paused": [],
|
||||||
|
"counts": {"active": 0, "paused": 0, "total": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
paused_limit_safe = max(1, min(int(paused_limit or 10), 25))
|
||||||
|
paused_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.sag_id,
|
||||||
|
s.titel AS sag_navn,
|
||||||
|
t.start_tid,
|
||||||
|
t.slut_tid,
|
||||||
|
GREATEST(
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
|
||||||
|
0
|
||||||
|
) AS elapsed_seconds,
|
||||||
|
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
|
||||||
|
FROM tmodule_times t
|
||||||
|
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE t.medarbejder_id = %s
|
||||||
|
AND t.aktiv_timer = FALSE
|
||||||
|
AND t.paused_at IS NOT NULL
|
||||||
|
AND t.slut_tid IS NULL
|
||||||
|
ORDER BY COALESCE(t.paused_at, t.updated_at, t.created_at) DESC, t.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, paused_limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
paused_count_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM tmodule_times t
|
||||||
|
WHERE t.medarbejder_id = %s
|
||||||
|
AND t.aktiv_timer = FALSE
|
||||||
|
AND t.paused_at IS NOT NULL
|
||||||
|
AND t.slut_tid IS NULL
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
paused_count = _safe_count(paused_count_row)
|
||||||
|
active_count = 1 if active.get("active") else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active": active,
|
||||||
|
"paused": [
|
||||||
|
{
|
||||||
|
"time_entry_id": row.get("id"),
|
||||||
|
"sag_id": row.get("sag_id"),
|
||||||
|
"sag_navn": row.get("sag_navn") or f"Sag #{row.get('sag_id')}",
|
||||||
|
"start_tid": row.get("start_tid"),
|
||||||
|
"slut_tid": row.get("slut_tid"),
|
||||||
|
"faktisk_tid_min": 0,
|
||||||
|
"elapsed_hhmmss": _format_elapsed(
|
||||||
|
max(0, int(row.get("elapsed_seconds") or 0) - int(row.get("pause_total_seconds") or 0))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for row in paused_rows
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"active": active_count,
|
||||||
|
"paused": paused_count,
|
||||||
|
"total": active_count + paused_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
|
||||||
|
limit_safe = max(1, min(int(limit or 25), 100))
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND s.ansvarlig_bruger_id IS NULL
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit_safe,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
count_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND s.ansvarlig_bruger_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"count": _safe_count(count_row),
|
||||||
|
"filter_meta": {
|
||||||
|
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
|
||||||
|
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
|
||||||
|
"sql_guarantee": [
|
||||||
|
"s.deleted_at IS NULL",
|
||||||
|
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
|
||||||
|
"s.ansvarlig_bruger_id IS NULL",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_cases(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
|
||||||
|
limit_safe = max(1, min(int(limit or 10), 20))
|
||||||
|
|
||||||
|
source = "direct_query"
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if _table_exists("sag_recent_cases"):
|
||||||
|
columns = set(_table_columns("sag_recent_cases"))
|
||||||
|
has_required = {"sag_id", "user_id"}.issubset(columns)
|
||||||
|
if has_required:
|
||||||
|
order_column = "viewed_at" if "viewed_at" in columns else "opened_at" if "opened_at" in columns else "updated_at" if "updated_at" in columns else "created_at"
|
||||||
|
if order_column:
|
||||||
|
source = "sag_recent_cases"
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.status,
|
||||||
|
rc.{order_column} AS recent_at
|
||||||
|
FROM sag_recent_cases rc
|
||||||
|
JOIN sag_sager s ON s.id = rc.sag_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND rc.user_id = %s
|
||||||
|
ORDER BY rc.{order_column} DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
if not rows and user_id is not None:
|
||||||
|
source = "direct_query_user_timers"
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.status,
|
||||||
|
MAX(COALESCE(t.start_tid, t.updated_at, t.created_at)) AS recent_at
|
||||||
|
FROM tmodule_times t
|
||||||
|
JOIN sag_sager s ON s.id = t.sag_id
|
||||||
|
WHERE t.medarbejder_id = %s
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
GROUP BY s.id, s.titel, s.priority, s.status
|
||||||
|
ORDER BY recent_at DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
source = "direct_query_global"
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.status,
|
||||||
|
COALESCE(s.updated_at, s.created_at) AS recent_at
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit_safe,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
"status": row.get("status"),
|
||||||
|
"recent_at": row.get("recent_at"),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
|
||||||
|
if user_id is None:
|
||||||
|
return {"items": [], "count": 0}
|
||||||
|
|
||||||
|
limit_safe = max(1, min(int(limit or 20), 100))
|
||||||
|
|
||||||
|
reminders = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.sag_id,
|
||||||
|
r.title,
|
||||||
|
r.message,
|
||||||
|
r.priority,
|
||||||
|
r.event_type,
|
||||||
|
r.next_check_at,
|
||||||
|
s.titel AS case_title,
|
||||||
|
c.name AS customer_name
|
||||||
|
FROM sag_reminders r
|
||||||
|
JOIN sag_sager s ON r.sag_id = s.id
|
||||||
|
JOIN customers c ON s.customer_id = c.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, snoozed_until, status, triggered_at
|
||||||
|
FROM sag_reminder_logs
|
||||||
|
WHERE reminder_id = r.id AND user_id = %s
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) l ON true
|
||||||
|
WHERE r.is_active = TRUE
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.next_check_at <= CURRENT_TIMESTAMP
|
||||||
|
AND %s = ANY(r.recipient_user_ids)
|
||||||
|
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
||||||
|
AND (l.status IS NULL OR l.status != 'dismissed')
|
||||||
|
ORDER BY
|
||||||
|
CASE LOWER(COALESCE(r.priority::text, 'normal'))
|
||||||
|
WHEN 'urgent' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'normal' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
r.next_check_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, user_id, limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
unread_mail_count = _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM email_messages em
|
||||||
|
WHERE em.deleted_at IS NULL
|
||||||
|
AND COALESCE(em.is_read, FALSE) = FALSE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if unread_mail_count > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": f"mail-unread-{unread_mail_count}",
|
||||||
|
"type": "mail",
|
||||||
|
"severity": "medium" if unread_mail_count < 10 else "high",
|
||||||
|
"title": f"{unread_mail_count} ulæste mails",
|
||||||
|
"message": "Der er ulæste mails i indbakken",
|
||||||
|
"action": "/emails",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in reminders:
|
||||||
|
priority = str(row.get("priority") or "normal").lower()
|
||||||
|
severity = "low"
|
||||||
|
if priority in {"high", "høj"}:
|
||||||
|
severity = "medium"
|
||||||
|
if priority in {"urgent", "critical", "kritisk"}:
|
||||||
|
severity = "high"
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": f"reminder-{row.get('id')}",
|
||||||
|
"type": row.get("event_type") or "reminder",
|
||||||
|
"severity": severity,
|
||||||
|
"title": row.get("title") or "Påmindelse",
|
||||||
|
"message": row.get("message") or row.get("case_title") or "",
|
||||||
|
"sag_id": row.get("sag_id"),
|
||||||
|
"case_title": row.get("case_title"),
|
||||||
|
"customer_name": row.get("customer_name"),
|
||||||
|
"action": f"/sag/{row.get('sag_id')}/v3" if row.get("sag_id") else "/sag",
|
||||||
|
"created_at": row.get("next_check_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
items.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
{"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3),
|
||||||
|
str(item.get("created_at") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": items[:limit_safe], "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
|
||||||
|
normalized = str(context_path or "").strip().lower()
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"context_key": "global",
|
||||||
|
"global": [
|
||||||
|
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
|
||||||
|
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
|
||||||
|
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
|
||||||
|
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
|
||||||
|
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
|
||||||
|
],
|
||||||
|
"context": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.startswith("/sag"):
|
||||||
|
payload["context_key"] = "sag"
|
||||||
|
payload["context"] = [
|
||||||
|
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
|
||||||
|
{"id": "case_mail", "label": "Mail", "action": "/emails"},
|
||||||
|
{"id": "case_relation", "label": "Relation", "action": "/customers"},
|
||||||
|
{"id": "case_tag", "label": "Tag", "action": "/tags"},
|
||||||
|
]
|
||||||
|
elif normalized.startswith("/hardware"):
|
||||||
|
payload["context_key"] = "hardware"
|
||||||
|
payload["context"] = [
|
||||||
|
{"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"},
|
||||||
|
{"id": "hardware_history", "label": "Historik", "action": "/hardware"},
|
||||||
|
{"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"},
|
||||||
|
]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_notes_summary(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
|
||||||
|
if user_id is None:
|
||||||
|
return {"count": 0, "list": []}
|
||||||
|
|
||||||
|
limit_safe = max(1, min(int(limit or 10), 50))
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
is_pinned,
|
||||||
|
is_archived,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM user_notes
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND is_archived = FALSE
|
||||||
|
ORDER BY is_pinned DESC, updated_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, limit_safe),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
total_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM user_notes
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND is_archived = FALSE
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": _safe_count(total_row),
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("title") or "",
|
||||||
|
"content": row.get("content") or "",
|
||||||
|
"is_pinned": bool(row.get("is_pinned")),
|
||||||
|
"is_archived": bool(row.get("is_archived")),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_bottom_bar_state(
|
||||||
|
user_id: Optional[int],
|
||||||
|
context_path: str = "",
|
||||||
|
force_boss_access: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
enabled = is_bottom_bar_enabled(user_id)
|
||||||
|
if not enabled:
|
||||||
|
return {"enabled": False, "sections": {}}
|
||||||
|
|
||||||
|
status = get_dashboard_status()
|
||||||
|
timer = get_active_timer(user_id)
|
||||||
|
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
||||||
|
notifications = get_notifications(user_id, limit=10)
|
||||||
|
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||||
|
recent_cases = _get_recent_cases(user_id, limit=10)
|
||||||
|
notes_summary = get_user_notes_summary(user_id, limit=10)
|
||||||
|
|
||||||
|
urgent_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, titel
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
open_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, titel
|
||||||
|
FROM sag_sager
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
timer_list: List[Dict[str, Any]] = []
|
||||||
|
if timer.get("active"):
|
||||||
|
timer_list.append(
|
||||||
|
{
|
||||||
|
"id": timer.get("time_entry_id"),
|
||||||
|
"sag_id": timer.get("sag_id"),
|
||||||
|
"desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}",
|
||||||
|
"elapsed": timer.get("elapsed"),
|
||||||
|
"elapsed_hhmmss": timer.get("elapsed_hhmmss"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"from": "System",
|
||||||
|
"text": f"{notifications.get('count', 0)} aktive notifikationer",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for n in (notifications.get("items") or [])[:5]:
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"title": n.get("title") or "Notifikation",
|
||||||
|
"deadline": n.get("severity") or "info",
|
||||||
|
"action": n.get("action") or "/",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
context_actions = _context_actions_for_path(context_path)
|
||||||
|
can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id)
|
||||||
|
|
||||||
|
team_workload: List[Dict[str, Any]] = []
|
||||||
|
technicians_today: List[Dict[str, Any]] = []
|
||||||
|
escalation_cases: List[Dict[str, Any]] = []
|
||||||
|
unassigned_cases: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if can_view_boss:
|
||||||
|
team_workload = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
HAVING COUNT(s.id) > 0
|
||||||
|
ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
technicians_today = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||||
|
COUNT(s.id)::int AS open_cases,
|
||||||
|
COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', t.id,
|
||||||
|
'title', t.titel,
|
||||||
|
'priority', COALESCE(t.priority::text, 'normal'),
|
||||||
|
'deadline', t.deadline
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(t.deadline, t.updated_at, t.created_at) ASC,
|
||||||
|
t.id ASC
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at
|
||||||
|
FROM sag_sager s2
|
||||||
|
WHERE s2.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s2.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND (
|
||||||
|
s2.deadline::date = CURRENT_DATE
|
||||||
|
OR s2.created_at::date = CURRENT_DATE
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC,
|
||||||
|
s2.id ASC
|
||||||
|
LIMIT 6
|
||||||
|
) t
|
||||||
|
),
|
||||||
|
'[]'::json
|
||||||
|
) AS today_tasks
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN sag_sager s
|
||||||
|
ON s.ansvarlig_bruger_id = u.user_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = u.user_id
|
||||||
|
AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%'])
|
||||||
|
)
|
||||||
|
GROUP BY u.user_id, u.full_name, u.username
|
||||||
|
ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
escalation_cases = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.priority,
|
||||||
|
s.updated_at,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||||
|
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||||
|
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
|
||||||
|
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
|
||||||
|
LIMIT 8
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
unassigned_cases = [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"titel": row.get("title"),
|
||||||
|
"priority": row.get("priority"),
|
||||||
|
}
|
||||||
|
for row in (unassigned_open_cases.get("items") or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
sections = {
|
||||||
|
"mail": {
|
||||||
|
"unread": status.get("mails_unread", 0),
|
||||||
|
"customer_reply_needed": status.get("mails_unread", 0),
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"open": status.get("sager_open", 0),
|
||||||
|
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases],
|
||||||
|
},
|
||||||
|
"urgent": {
|
||||||
|
"count": status.get("sager_urgent", 0),
|
||||||
|
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
|
||||||
|
},
|
||||||
|
"unassigned": {
|
||||||
|
"count": status.get("sager_unassigned", 0),
|
||||||
|
"list": unassigned_open_cases.get("items") or [],
|
||||||
|
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
|
||||||
|
},
|
||||||
|
"timer": {
|
||||||
|
"active_count": 1 if timer.get("active") else 0,
|
||||||
|
"list": timer_list,
|
||||||
|
"active": timer,
|
||||||
|
"own": own_timers,
|
||||||
|
"switch_case_hooks": {
|
||||||
|
"fetch_own_active_paused_timers": {
|
||||||
|
"route": "/api/v1/bottom-bar/timers/own",
|
||||||
|
"method": "GET",
|
||||||
|
"query": {"paused_limit": 10},
|
||||||
|
},
|
||||||
|
"switch_case_start_timer": {
|
||||||
|
"route": "/api/v1/timetracking/time/start",
|
||||||
|
"method": "POST",
|
||||||
|
"payload": {
|
||||||
|
"sag_id": "required:int",
|
||||||
|
"medarbejder_id": "optional:int",
|
||||||
|
"beskrivelse": "optional:string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kuma": {
|
||||||
|
"down": 0,
|
||||||
|
"list": [],
|
||||||
|
},
|
||||||
|
"eset": {
|
||||||
|
"incidents": 0,
|
||||||
|
"list": [],
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"count": len(messages),
|
||||||
|
"list": messages,
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"count": len(tasks),
|
||||||
|
"list": tasks,
|
||||||
|
},
|
||||||
|
"recent_cases": recent_cases,
|
||||||
|
"notes": notes_summary,
|
||||||
|
"boss": {
|
||||||
|
"can_view": can_view_boss,
|
||||||
|
"stats": {
|
||||||
|
"unassigned": status.get("sager_unassigned", 0),
|
||||||
|
"active_employees": _safe_count(
|
||||||
|
execute_query_single(
|
||||||
|
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"open_cases": status.get("sager_open", 0),
|
||||||
|
"urgent_cases": status.get("sager_urgent", 0),
|
||||||
|
"stale_urgent_cases": len(escalation_cases),
|
||||||
|
}
|
||||||
|
,
|
||||||
|
"team_workload": [
|
||||||
|
{
|
||||||
|
"user_id": row.get("user_id"),
|
||||||
|
"owner_name": row.get("owner_name"),
|
||||||
|
"open_cases": int(row.get("open_cases") or 0),
|
||||||
|
"urgent_cases": int(row.get("urgent_cases") or 0),
|
||||||
|
}
|
||||||
|
for row in team_workload
|
||||||
|
],
|
||||||
|
"technicians_today": [
|
||||||
|
{
|
||||||
|
"user_id": row.get("user_id"),
|
||||||
|
"owner_name": row.get("owner_name"),
|
||||||
|
"open_cases": int(row.get("open_cases") or 0),
|
||||||
|
"due_today_cases": int(row.get("due_today_cases") or 0),
|
||||||
|
"today_tasks": row.get("today_tasks") or [],
|
||||||
|
}
|
||||||
|
for row in technicians_today
|
||||||
|
],
|
||||||
|
"escalations": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
"owner_name": row.get("owner_name") or "Ikke tildelt",
|
||||||
|
"age_seconds": int(row.get("age_seconds") or 0),
|
||||||
|
}
|
||||||
|
for row in escalation_cases
|
||||||
|
],
|
||||||
|
"unassigned_cases": [
|
||||||
|
{
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": row.get("titel") or f"Sag #{row.get('id')}",
|
||||||
|
"priority": row.get("priority") or "normal",
|
||||||
|
}
|
||||||
|
for row in unassigned_cases
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"context_actions": context_actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"sections": sections,
|
||||||
|
"status": status,
|
||||||
|
"active_timer": timer,
|
||||||
|
"own_timers": own_timers,
|
||||||
|
"recent_cases": recent_cases,
|
||||||
|
"notifications": notifications,
|
||||||
|
}
|
||||||
11
app/modules/bottom_bar/module.json
Normal file
11
app/modules/bottom_bar/module.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "bottom_bar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Global activity bottom bar module",
|
||||||
|
"author": "BMC Networks",
|
||||||
|
"enabled": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"table_prefix": "bottom_bar_",
|
||||||
|
"api_prefix": "/api/v1",
|
||||||
|
"tags": ["Bottom Bar"]
|
||||||
|
}
|
||||||
@ -127,7 +127,7 @@ def _get_calendar_events(
|
|||||||
"case_deadline",
|
"case_deadline",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('id')}",
|
f"/sag/{row.get('id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "case",
|
"reference_type": "case",
|
||||||
@ -170,7 +170,7 @@ def _get_calendar_events(
|
|||||||
"case_deferred",
|
"case_deferred",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('id')}",
|
f"/sag/{row.get('id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "case",
|
"reference_type": "case",
|
||||||
@ -224,7 +224,7 @@ def _get_calendar_events(
|
|||||||
"case_reminder",
|
"case_reminder",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('sag_id')}",
|
f"/sag/{row.get('sag_id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "reminder",
|
"reference_type": "reminder",
|
||||||
|
|||||||
0
app/modules/fedex/__init__.py
Normal file
0
app/modules/fedex/__init__.py
Normal file
0
app/modules/fedex/backend/__init__.py
Normal file
0
app/modules/fedex/backend/__init__.py
Normal file
87
app/modules/fedex/backend/api_client.py
Normal file
87
app/modules/fedex/backend/api_client.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FedExApiClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = (settings.FEDEX_BASE_URL or "").rstrip("/")
|
||||||
|
self.timeout_seconds = max(5, int(settings.FEDEX_TIMEOUT_SECONDS or 20))
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-KEY": settings.FEDEX_API_KEY or "",
|
||||||
|
"X-API-SECRET": settings.FEDEX_API_SECRET or "",
|
||||||
|
"X-FEDEX-ACCOUNT": settings.FEDEX_ACCOUNT_NUMBER or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("FedEx base URL is not configured")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/shipments"
|
||||||
|
logger.info("🚀 FedEx create shipment request sent")
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload, headers=self._headers()) as response:
|
||||||
|
body = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
logger.error("❌ FedEx create shipment failed (%s): %s", response.status, body)
|
||||||
|
raise RuntimeError(f"FedEx create shipment failed: HTTP {response.status}")
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
async def get_tracking(self, tracking_number: str) -> Dict[str, Any]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("FedEx base URL is not configured")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/tracking/{tracking_number}"
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url, headers=self._headers()) as response:
|
||||||
|
body = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
logger.error("❌ FedEx tracking failed (%s): %s", response.status, body)
|
||||||
|
raise RuntimeError(f"FedEx tracking failed: HTTP {response.status}")
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
async def cancel_shipment(self, tracking_number: str) -> Dict[str, Any]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("FedEx base URL is not configured")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/shipments/{tracking_number}/cancel"
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, headers=self._headers()) as response:
|
||||||
|
body = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
logger.error("❌ FedEx cancel failed (%s): %s", response.status, body)
|
||||||
|
raise RuntimeError(f"FedEx cancel failed: HTTP {response.status}")
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tracking_events(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
raw_events = payload.get("events") or []
|
||||||
|
if not isinstance(raw_events, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
for event in raw_events:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"status": str(event.get("status") or "unknown"),
|
||||||
|
"description": event.get("description"),
|
||||||
|
"event_timestamp": event.get("event_timestamp") or event.get("timestamp"),
|
||||||
|
"location_city": event.get("location_city") or event.get("city"),
|
||||||
|
"location_country": event.get("location_country") or event.get("country"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
66
app/modules/fedex/backend/router.py
Normal file
66
app/modules/fedex/backend/router.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Request
|
||||||
|
|
||||||
|
from app.modules.fedex.backend.service import fedex_service
|
||||||
|
from app.modules.fedex.models.schemas import (
|
||||||
|
FedExBookingCreate,
|
||||||
|
FedExBookingListResponse,
|
||||||
|
FedExBookingResponse,
|
||||||
|
FedExBookingSubmitResponse,
|
||||||
|
FedExCancelRequest,
|
||||||
|
FedExCancelResponse,
|
||||||
|
FedExTrackingResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id_from_request(request: Request) -> Optional[int]:
|
||||||
|
raw_user_id = getattr(request.state, "user_id", None)
|
||||||
|
if raw_user_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw_user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fedex/config")
|
||||||
|
async def fedex_config() -> dict:
|
||||||
|
return {
|
||||||
|
"enabled": fedex_service.enabled,
|
||||||
|
"read_only": fedex_service.read_only,
|
||||||
|
"dry_run": fedex_service.dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fedex/bookings", response_model=FedExBookingResponse)
|
||||||
|
async def create_booking(payload: FedExBookingCreate, request: Request):
|
||||||
|
booking = fedex_service.create_booking_draft(payload, _user_id_from_request(request))
|
||||||
|
return booking
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fedex/bookings", response_model=FedExBookingListResponse)
|
||||||
|
async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
|
||||||
|
return {"items": fedex_service.list_bookings(case_id=case_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fedex/bookings/{booking_ref}", response_model=FedExBookingResponse)
|
||||||
|
async def get_booking(booking_ref: str):
|
||||||
|
return fedex_service.get_booking(booking_ref)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fedex/bookings/{booking_ref}/submit", response_model=FedExBookingSubmitResponse)
|
||||||
|
async def submit_booking(booking_ref: str, request: Request):
|
||||||
|
return await fedex_service.submit_booking(booking_ref, _user_id_from_request(request))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fedex/bookings/{booking_ref}/tracking", response_model=FedExTrackingResponse)
|
||||||
|
async def get_tracking(booking_ref: str):
|
||||||
|
return await fedex_service.get_tracking(booking_ref)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fedex/bookings/{booking_ref}/cancel", response_model=FedExCancelResponse)
|
||||||
|
async def cancel_booking(booking_ref: str, payload: FedExCancelRequest, request: Request):
|
||||||
|
return await fedex_service.cancel_booking(booking_ref, payload.reason, _user_id_from_request(request))
|
||||||
675
app/modules/fedex/backend/service.py
Normal file
675
app/modules/fedex/backend/service.py
Normal file
@ -0,0 +1,675 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||||
|
from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events
|
||||||
|
from app.modules.fedex.models.schemas import FedExBookingCreate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> Any:
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_price_info(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
direct_amount = _to_float(
|
||||||
|
payload.get("total_amount")
|
||||||
|
or payload.get("totalAmount")
|
||||||
|
or payload.get("total_cost")
|
||||||
|
or payload.get("totalCost")
|
||||||
|
or payload.get("price")
|
||||||
|
or payload.get("amount")
|
||||||
|
)
|
||||||
|
direct_currency = (
|
||||||
|
payload.get("currency")
|
||||||
|
or payload.get("currencyCode")
|
||||||
|
or payload.get("total_cost_currency")
|
||||||
|
)
|
||||||
|
if direct_amount is not None:
|
||||||
|
return direct_amount, str(direct_currency or "").upper() or None
|
||||||
|
|
||||||
|
stack: List[Any] = [payload]
|
||||||
|
visited: set[int] = set()
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
node_id = id(node)
|
||||||
|
if node_id in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node_id)
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
amount = _to_float(node.get("amount") or node.get("value"))
|
||||||
|
currency = node.get("currency") or node.get("currencyCode")
|
||||||
|
if amount is not None and currency:
|
||||||
|
return amount, str(currency).upper()
|
||||||
|
|
||||||
|
# Prioritize common FedEx charge keys if present.
|
||||||
|
for key in (
|
||||||
|
"totalNetCharge",
|
||||||
|
"totalNetFedExCharge",
|
||||||
|
"totalBaseCharge",
|
||||||
|
"totalSurcharges",
|
||||||
|
"netCharge",
|
||||||
|
):
|
||||||
|
nested = node.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
nested_amount = _to_float(nested.get("amount") or nested.get("value"))
|
||||||
|
nested_currency = nested.get("currency") or nested.get("currencyCode")
|
||||||
|
if nested_amount is not None:
|
||||||
|
return nested_amount, str(nested_currency or "").upper() or None
|
||||||
|
|
||||||
|
stack.extend(node.values())
|
||||||
|
elif isinstance(node, list):
|
||||||
|
stack.extend(node)
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_label_url(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
direct = payload.get("label_url") or payload.get("labelUrl") or payload.get("label")
|
||||||
|
if isinstance(direct, str) and direct.strip().lower().startswith(("http://", "https://")):
|
||||||
|
return direct.strip()
|
||||||
|
|
||||||
|
stack: List[Any] = [payload]
|
||||||
|
visited: set[int] = set()
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
node_id = id(node)
|
||||||
|
if node_id in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node_id)
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
key_lower = str(key).lower()
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip()
|
||||||
|
if v.lower().startswith(("http://", "https://")) and (
|
||||||
|
"label" in key_lower or "document" in key_lower or "url" in key_lower
|
||||||
|
):
|
||||||
|
return v
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
stack.extend(node)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
direct = (
|
||||||
|
payload.get("tracking_number")
|
||||||
|
or payload.get("trackingNumber")
|
||||||
|
or payload.get("masterTrackingNumber")
|
||||||
|
)
|
||||||
|
if direct is not None:
|
||||||
|
value = str(direct).strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
stack: List[Any] = [payload]
|
||||||
|
visited: set[int] = set()
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
node_id = id(node)
|
||||||
|
if node_id in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node_id)
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
key_lower = str(key).lower()
|
||||||
|
if "tracking" in key_lower and value is not None and not isinstance(value, (dict, list)):
|
||||||
|
candidate = str(value).strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
stack.extend(node)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tracking_url(tracking_number: Optional[str]) -> Optional[str]:
|
||||||
|
if not tracking_number:
|
||||||
|
return None
|
||||||
|
return f"https://www.fedex.com/fedextrack/?trknbr={tracking_number}"
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_dry_run_price(payload: Dict[str, Any]) -> tuple[float, str]:
|
||||||
|
packages = payload.get("packages") if isinstance(payload, dict) else []
|
||||||
|
if not isinstance(packages, list) or not packages:
|
||||||
|
return 99.0, "DKK"
|
||||||
|
|
||||||
|
total_weight = 0.0
|
||||||
|
for p in packages:
|
||||||
|
if isinstance(p, dict):
|
||||||
|
total_weight += _to_float(p.get("weight_kg")) or 0.0
|
||||||
|
|
||||||
|
estimated = round(79.0 + (total_weight * 8.5), 2)
|
||||||
|
return max(estimated, 79.0), "DKK"
|
||||||
|
|
||||||
|
|
||||||
|
class FedExService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client = FedExApiClient()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(settings.FEDEX_ENABLED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def read_only(self) -> bool:
|
||||||
|
return bool(settings.FEDEX_READ_ONLY)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dry_run(self) -> bool:
|
||||||
|
return bool(settings.FEDEX_DRY_RUN)
|
||||||
|
|
||||||
|
def _assert_enabled(self) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
raise HTTPException(status_code=503, detail="FedEx integration is disabled")
|
||||||
|
|
||||||
|
def _booking_ref(self) -> str:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
|
return f"FDX-{stamp}-{uuid4().hex[:8].upper()}"
|
||||||
|
|
||||||
|
def _validate_relations(self, payload: FedExBookingCreate) -> None:
|
||||||
|
case_exists = execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,))
|
||||||
|
if not case_exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
|
if payload.customer_id:
|
||||||
|
customer_exists = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
|
||||||
|
if not customer_exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
if payload.contact_id:
|
||||||
|
contact_exists = execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,))
|
||||||
|
if not contact_exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
def _fetch_packages(self, shipment_id: int) -> List[Dict[str, Any]]:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT weight_kg, length_cm, width_cm, height_cm, description
|
||||||
|
FROM fedex_shipment_packages
|
||||||
|
WHERE shipment_id = %s
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(shipment_id,),
|
||||||
|
) or []
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
mapped = dict(row)
|
||||||
|
mapped["packages"] = self._fetch_packages(int(row["id"]))
|
||||||
|
|
||||||
|
api_response = mapped.get("api_response")
|
||||||
|
if isinstance(api_response, str):
|
||||||
|
try:
|
||||||
|
api_response = json.loads(api_response)
|
||||||
|
except Exception:
|
||||||
|
api_response = None
|
||||||
|
|
||||||
|
if isinstance(api_response, dict):
|
||||||
|
if not mapped.get("tracking_number"):
|
||||||
|
mapped["tracking_number"] = _extract_tracking_number(api_response)
|
||||||
|
if not mapped.get("label_url"):
|
||||||
|
mapped["label_url"] = _extract_label_url(api_response)
|
||||||
|
|
||||||
|
if mapped.get("total_amount") is None:
|
||||||
|
fallback_amount, fallback_currency = _extract_price_info(api_response)
|
||||||
|
if fallback_amount is not None:
|
||||||
|
mapped["total_amount"] = fallback_amount
|
||||||
|
if not mapped.get("currency") and fallback_currency:
|
||||||
|
mapped["currency"] = fallback_currency
|
||||||
|
|
||||||
|
mapped["tracking_url"] = _build_tracking_url(mapped.get("tracking_number"))
|
||||||
|
|
||||||
|
# Ensure older dry-run rows still expose useful test outputs in UI.
|
||||||
|
if mapped.get("dry_run") and mapped.get("shipment_status") in {"submitted", "booked"}:
|
||||||
|
if not mapped.get("label_url") and mapped.get("tracking_url"):
|
||||||
|
mapped["label_url"] = mapped["tracking_url"]
|
||||||
|
if mapped.get("total_amount") is None:
|
||||||
|
estimated_amount, estimated_currency = _estimate_dry_run_price({"packages": mapped.get("packages") or []})
|
||||||
|
mapped["total_amount"] = estimated_amount
|
||||||
|
if not mapped.get("currency"):
|
||||||
|
mapped["currency"] = estimated_currency
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
self._assert_enabled()
|
||||||
|
self._validate_relations(payload)
|
||||||
|
|
||||||
|
if payload.pickup_window_end <= payload.pickup_window_start:
|
||||||
|
raise HTTPException(status_code=400, detail="pickup_window_end must be after pickup_window_start")
|
||||||
|
|
||||||
|
booking_ref = self._booking_ref()
|
||||||
|
shipment_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO fedex_shipments (
|
||||||
|
booking_ref,
|
||||||
|
case_id,
|
||||||
|
customer_id,
|
||||||
|
contact_id,
|
||||||
|
service_type,
|
||||||
|
shipment_status,
|
||||||
|
pickup_window_start,
|
||||||
|
pickup_window_end,
|
||||||
|
recipient_name,
|
||||||
|
company_name,
|
||||||
|
address_line1,
|
||||||
|
address_line2,
|
||||||
|
postal_code,
|
||||||
|
city,
|
||||||
|
country_code,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
dry_run,
|
||||||
|
created_by_user_id,
|
||||||
|
updated_by_user_id,
|
||||||
|
api_payload,
|
||||||
|
api_response
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, 'draft',
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s::jsonb, %s::jsonb
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
booking_ref,
|
||||||
|
payload.case_id,
|
||||||
|
payload.customer_id,
|
||||||
|
payload.contact_id,
|
||||||
|
payload.service_type,
|
||||||
|
payload.pickup_window_start,
|
||||||
|
payload.pickup_window_end,
|
||||||
|
payload.address.recipient_name,
|
||||||
|
payload.address.company_name,
|
||||||
|
payload.address.address_line1,
|
||||||
|
payload.address.address_line2,
|
||||||
|
payload.address.postal_code,
|
||||||
|
payload.address.city,
|
||||||
|
payload.address.country_code.upper(),
|
||||||
|
payload.address.phone,
|
||||||
|
payload.address.email,
|
||||||
|
self.dry_run,
|
||||||
|
created_by_user_id,
|
||||||
|
created_by_user_id,
|
||||||
|
_json_dumps(payload.model_dump(mode="json")),
|
||||||
|
_json_dumps({"status": "draft_created"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not shipment_row:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create booking draft")
|
||||||
|
|
||||||
|
shipment_id = int(shipment_row["id"])
|
||||||
|
for package in payload.packages:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO fedex_shipment_packages (
|
||||||
|
shipment_id, weight_kg, length_cm, width_cm, height_cm, description
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
shipment_id,
|
||||||
|
package.weight_kg,
|
||||||
|
package.length_cm,
|
||||||
|
package.width_cm,
|
||||||
|
package.height_cm,
|
||||||
|
package.description,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ FedEx draft created: %s (case=%s)", booking_ref, payload.case_id)
|
||||||
|
return self._shipment_row_to_dict(dict(shipment_row))
|
||||||
|
|
||||||
|
def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
params: List[Any] = []
|
||||||
|
where_sql = "WHERE deleted_at IS NULL"
|
||||||
|
if case_id is not None:
|
||||||
|
where_sql += " AND case_id = %s"
|
||||||
|
params.append(case_id)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM fedex_shipments
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
tuple(params),
|
||||||
|
) or []
|
||||||
|
return [self._shipment_row_to_dict(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
def get_booking(self, booking_ref: str) -> Dict[str, Any]:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM fedex_shipments
|
||||||
|
WHERE booking_ref = %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(booking_ref,),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
return self._shipment_row_to_dict(dict(row))
|
||||||
|
|
||||||
|
async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
self._assert_enabled()
|
||||||
|
shipment = self.get_booking(booking_ref)
|
||||||
|
|
||||||
|
if shipment["shipment_status"] not in {"draft", "failed"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Only draft/failed bookings can be submitted")
|
||||||
|
|
||||||
|
if self.read_only:
|
||||||
|
raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_ref": shipment["booking_ref"],
|
||||||
|
"service_type": shipment["service_type"],
|
||||||
|
"pickup_window_start": shipment["pickup_window_start"].isoformat() if shipment.get("pickup_window_start") else None,
|
||||||
|
"pickup_window_end": shipment["pickup_window_end"].isoformat() if shipment.get("pickup_window_end") else None,
|
||||||
|
"recipient": {
|
||||||
|
"recipient_name": shipment["recipient_name"],
|
||||||
|
"company_name": shipment.get("company_name"),
|
||||||
|
"address_line1": shipment["address_line1"],
|
||||||
|
"address_line2": shipment.get("address_line2"),
|
||||||
|
"postal_code": shipment["postal_code"],
|
||||||
|
"city": shipment["city"],
|
||||||
|
"country_code": shipment["country_code"],
|
||||||
|
"phone": shipment.get("phone"),
|
||||||
|
"email": shipment.get("email"),
|
||||||
|
},
|
||||||
|
"packages": shipment["packages"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}"
|
||||||
|
label_url = _build_tracking_url(tracking_number)
|
||||||
|
total_amount, currency = _estimate_dry_run_price(payload)
|
||||||
|
api_response = {
|
||||||
|
"dry_run": True,
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"label_url": label_url,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"currency": currency,
|
||||||
|
}
|
||||||
|
new_status = "submitted"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
api_response = await self.client.create_shipment(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE fedex_shipments
|
||||||
|
SET shipment_status = 'failed',
|
||||||
|
api_response = %s::jsonb,
|
||||||
|
updated_by_user_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE booking_ref = %s
|
||||||
|
""",
|
||||||
|
(_json_dumps({"error": str(exc)}), user_id, booking_ref),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="FedEx booking failed") from exc
|
||||||
|
|
||||||
|
tracking_number = _extract_tracking_number(api_response)
|
||||||
|
label_url = _extract_label_url(api_response)
|
||||||
|
new_status = "booked"
|
||||||
|
total_amount, currency = _extract_price_info(api_response)
|
||||||
|
|
||||||
|
has_total_amount = table_has_column("fedex_shipments", "total_amount")
|
||||||
|
has_currency = table_has_column("fedex_shipments", "currency")
|
||||||
|
|
||||||
|
set_clauses = [
|
||||||
|
"shipment_status = %s",
|
||||||
|
"tracking_number = COALESCE(%s, tracking_number)",
|
||||||
|
"label_url = COALESCE(%s, label_url)",
|
||||||
|
]
|
||||||
|
update_params: List[Any] = [
|
||||||
|
new_status,
|
||||||
|
tracking_number,
|
||||||
|
label_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
if has_total_amount:
|
||||||
|
set_clauses.append("total_amount = COALESCE(%s, total_amount)")
|
||||||
|
update_params.append(total_amount)
|
||||||
|
if has_currency:
|
||||||
|
set_clauses.append("currency = COALESCE(%s, currency)")
|
||||||
|
update_params.append(currency)
|
||||||
|
|
||||||
|
set_clauses.extend([
|
||||||
|
"submitted_at = CURRENT_TIMESTAMP",
|
||||||
|
"api_payload = %s::jsonb",
|
||||||
|
"api_response = %s::jsonb",
|
||||||
|
"updated_by_user_id = %s",
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
])
|
||||||
|
update_params.extend([
|
||||||
|
_json_dumps(payload),
|
||||||
|
_json_dumps(api_response),
|
||||||
|
user_id,
|
||||||
|
booking_ref,
|
||||||
|
])
|
||||||
|
|
||||||
|
updated = execute_query_single(
|
||||||
|
f"""
|
||||||
|
UPDATE fedex_shipments
|
||||||
|
SET {', '.join(set_clauses)}
|
||||||
|
WHERE booking_ref = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
tuple(update_params),
|
||||||
|
)
|
||||||
|
|
||||||
|
if tracking_number:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO fedex_tracking_events (
|
||||||
|
shipment_id, status, description, event_timestamp
|
||||||
|
) VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
updated["id"],
|
||||||
|
"submitted" if self.dry_run else "booked",
|
||||||
|
"Shipment submitted from BMC Hub",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"booking_ref": booking_ref,
|
||||||
|
"status": new_status,
|
||||||
|
"dry_run": self.dry_run,
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"tracking_url": _build_tracking_url(tracking_number),
|
||||||
|
"label_url": label_url,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"currency": currency,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_tracking(self, booking_ref: str) -> Dict[str, Any]:
|
||||||
|
self._assert_enabled()
|
||||||
|
shipment = self.get_booking(booking_ref)
|
||||||
|
|
||||||
|
tracking_number = shipment.get("tracking_number")
|
||||||
|
if not tracking_number:
|
||||||
|
events = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT status, event_timestamp, description, location_city, location_country
|
||||||
|
FROM fedex_tracking_events
|
||||||
|
WHERE shipment_id = %s
|
||||||
|
ORDER BY event_timestamp DESC
|
||||||
|
""",
|
||||||
|
(shipment["id"],),
|
||||||
|
) or []
|
||||||
|
return {
|
||||||
|
"booking_ref": booking_ref,
|
||||||
|
"shipment_status": shipment["shipment_status"],
|
||||||
|
"tracking_number": None,
|
||||||
|
"events": [dict(row) for row in events],
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
events = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT status, event_timestamp, description, location_city, location_country
|
||||||
|
FROM fedex_tracking_events
|
||||||
|
WHERE shipment_id = %s
|
||||||
|
ORDER BY event_timestamp DESC
|
||||||
|
""",
|
||||||
|
(shipment["id"],),
|
||||||
|
) or []
|
||||||
|
return {
|
||||||
|
"booking_ref": booking_ref,
|
||||||
|
"shipment_status": shipment["shipment_status"],
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"events": [dict(row) for row in events],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider_payload = await self.client.get_tracking(tracking_number)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to fetch FedEx tracking") from exc
|
||||||
|
|
||||||
|
events = parse_tracking_events(provider_payload)
|
||||||
|
if events:
|
||||||
|
execute_query(
|
||||||
|
"DELETE FROM fedex_tracking_events WHERE shipment_id = %s",
|
||||||
|
(shipment["id"],),
|
||||||
|
)
|
||||||
|
for event in events:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO fedex_tracking_events (
|
||||||
|
shipment_id,
|
||||||
|
status,
|
||||||
|
description,
|
||||||
|
event_timestamp,
|
||||||
|
location_city,
|
||||||
|
location_country
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s,
|
||||||
|
COALESCE(%s::timestamp, CURRENT_TIMESTAMP),
|
||||||
|
%s, %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
shipment["id"],
|
||||||
|
event.get("status") or "unknown",
|
||||||
|
event.get("description"),
|
||||||
|
event.get("event_timestamp"),
|
||||||
|
event.get("location_city"),
|
||||||
|
event.get("location_country"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
status = str(provider_payload.get("shipment_status") or shipment["shipment_status"])
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE fedex_shipments
|
||||||
|
SET shipment_status = %s,
|
||||||
|
api_response = %s::jsonb,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, _json_dumps(provider_payload), shipment["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_events = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT status, event_timestamp, description, location_city, location_country
|
||||||
|
FROM fedex_tracking_events
|
||||||
|
WHERE shipment_id = %s
|
||||||
|
ORDER BY event_timestamp DESC
|
||||||
|
""",
|
||||||
|
(shipment["id"],),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"booking_ref": booking_ref,
|
||||||
|
"shipment_status": status,
|
||||||
|
"tracking_number": tracking_number,
|
||||||
|
"events": [dict(row) for row in current_events],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def cancel_booking(self, booking_ref: str, reason: Optional[str], user_id: Optional[int]) -> Dict[str, Any]:
|
||||||
|
self._assert_enabled()
|
||||||
|
if self.read_only:
|
||||||
|
raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)")
|
||||||
|
|
||||||
|
shipment = self.get_booking(booking_ref)
|
||||||
|
if shipment["shipment_status"] == "cancelled":
|
||||||
|
return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
|
||||||
|
|
||||||
|
if not self.dry_run and shipment.get("tracking_number"):
|
||||||
|
try:
|
||||||
|
await self.client.cancel_shipment(str(shipment["tracking_number"]))
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to cancel shipment at FedEx") from exc
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE fedex_shipments
|
||||||
|
SET shipment_status = 'cancelled',
|
||||||
|
cancel_reason = %s,
|
||||||
|
updated_by_user_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE booking_ref = %s
|
||||||
|
""",
|
||||||
|
(reason, user_id, booking_ref),
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO fedex_tracking_events (shipment_id, status, description, event_timestamp)
|
||||||
|
VALUES (%s, 'cancelled', %s, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(shipment["id"], reason or "Cancelled from BMC Hub"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True}
|
||||||
|
|
||||||
|
|
||||||
|
fedex_service = FedExService()
|
||||||
0
app/modules/fedex/frontend/__init__.py
Normal file
0
app/modules/fedex/frontend/__init__.py
Normal file
351
app/modules/fedex/frontend/fedex_overview.html
Normal file
351
app/modules/fedex/frontend/fedex_overview.html
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}FedEx Overblik - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.fedex-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-hero {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
background: linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(26, 117, 159, 0.08));
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpis {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi .label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi .value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-filter-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table-wrap {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table tbody td {
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-status {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
|
||||||
|
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
|
||||||
|
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
|
||||||
|
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
|
||||||
|
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
|
||||||
|
|
||||||
|
.fedex-row-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-row-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.fedex-kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fedex-kpis {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="fedex-shell">
|
||||||
|
<div class="fedex-hero">
|
||||||
|
<div>
|
||||||
|
<h2 class="fw-bold mb-1">FedEx Overblik</h2>
|
||||||
|
<div class="text-muted">Samlet visning af alle FedEx bestillinger og deres status.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary" id="refreshFedexBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fedex-kpis">
|
||||||
|
<div class="fedex-kpi"><div class="label">Total</div><div class="value" id="kpiTotal">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Aktive</div><div class="value" id="kpiActive">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Leveret</div><div class="value" id="kpiDelivered">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Fejl/Annulleret</div><div class="value" id="kpiFailed">0</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card fedex-filter-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<label class="form-label small text-muted" for="fedexSearchInput">Søg</label>
|
||||||
|
<input id="fedexSearchInput" class="form-control" placeholder="Booking ref, tracking, modtager, by, case id...">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="fedexStatusFilter">Status</label>
|
||||||
|
<select id="fedexStatusFilter" class="form-select">
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="booked">Booked</option>
|
||||||
|
<option value="in_transit">In transit</option>
|
||||||
|
<option value="delivered">Delivered</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="fedexSortSelect">Sortering</label>
|
||||||
|
<select id="fedexSortSelect" class="form-select">
|
||||||
|
<option value="newest">Nyeste først</option>
|
||||||
|
<option value="oldest">Ældste først</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 d-flex align-items-end">
|
||||||
|
<button class="btn btn-light border w-100" id="fedexClearBtn">Ryd filtre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fedex-table-wrap">
|
||||||
|
<table class="table table-hover fedex-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bestilling</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tracking</th>
|
||||||
|
<th>Case</th>
|
||||||
|
<th>Afhentning</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fedexTableBody">
|
||||||
|
<tr><td colspan="7" class="text-center py-4 text-muted">Henter FedEx bestillinger...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
bookings: [],
|
||||||
|
filtered: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = value ?? '';
|
||||||
|
return span.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const dt = new Date(value);
|
||||||
|
if (Number.isNaN(dt.getTime())) return '-';
|
||||||
|
return dt.toLocaleString('da-DK');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount, currency) {
|
||||||
|
if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '-';
|
||||||
|
return `${Number(amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency || 'DKK'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
const s = String(status || 'draft').toLowerCase();
|
||||||
|
return `<span class="fedex-status ${escapeHtml(s)}">${escapeHtml(s.replaceAll('_', ' '))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const q = (document.getElementById('fedexSearchInput')?.value || '').trim().toLowerCase();
|
||||||
|
const status = (document.getElementById('fedexStatusFilter')?.value || 'all').toLowerCase();
|
||||||
|
const sortBy = (document.getElementById('fedexSortSelect')?.value || 'newest').toLowerCase();
|
||||||
|
|
||||||
|
const rows = state.bookings.filter((item) => {
|
||||||
|
const itemStatus = String(item.shipment_status || '').toLowerCase();
|
||||||
|
if (status !== 'all' && itemStatus !== status) return false;
|
||||||
|
|
||||||
|
if (!q) return true;
|
||||||
|
const haystack = [
|
||||||
|
item.booking_ref,
|
||||||
|
item.tracking_number,
|
||||||
|
item.recipient_name,
|
||||||
|
item.city,
|
||||||
|
item.country_code,
|
||||||
|
item.service_type,
|
||||||
|
item.case_id,
|
||||||
|
].map((v) => String(v || '').toLowerCase()).join(' ');
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (sortBy === 'status') {
|
||||||
|
return String(a.shipment_status || '').localeCompare(String(b.shipment_status || ''), 'da');
|
||||||
|
}
|
||||||
|
const ta = new Date(a.created_at || 0).getTime() || 0;
|
||||||
|
const tb = new Date(b.created_at || 0).getTime() || 0;
|
||||||
|
return sortBy === 'oldest' ? ta - tb : tb - ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
state.filtered = rows;
|
||||||
|
renderTable();
|
||||||
|
renderKpis();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpis() {
|
||||||
|
const total = state.filtered.length;
|
||||||
|
const delivered = state.filtered.filter((item) => item.shipment_status === 'delivered').length;
|
||||||
|
const failed = state.filtered.filter((item) => ['failed', 'cancelled'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
||||||
|
const active = state.filtered.filter((item) => ['draft', 'submitted', 'booked', 'in_transit'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
||||||
|
|
||||||
|
document.getElementById('kpiTotal').textContent = String(total);
|
||||||
|
document.getElementById('kpiActive').textContent = String(active);
|
||||||
|
document.getElementById('kpiDelivered').textContent = String(delivered);
|
||||||
|
document.getElementById('kpiFailed').textContent = String(failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.getElementById('fedexTableBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!state.filtered.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Ingen FedEx bestillinger matcher filteret.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = state.filtered.map((item) => {
|
||||||
|
const trackingNumber = String(item.tracking_number || '').trim();
|
||||||
|
const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim();
|
||||||
|
const labelUrl = String(item.label_url || '').trim();
|
||||||
|
const openCaseUrl = Number(item.case_id) > 0 ? `/sag/${Number(item.case_id)}/v3` : '/sag';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fedex-row-title">${escapeHtml(item.booking_ref || '-')}</div>
|
||||||
|
<div class="fedex-row-meta">${escapeHtml(item.recipient_name || '-')} • ${escapeHtml(item.city || '-')} (${escapeHtml(item.country_code || '-')})</div>
|
||||||
|
</td>
|
||||||
|
<td>${statusBadge(item.shipment_status)}</td>
|
||||||
|
<td>
|
||||||
|
${trackingNumber ? `<span class="small fw-semibold">${escapeHtml(trackingNumber)}</span>` : '<span class="text-muted">-</span>'}
|
||||||
|
</td>
|
||||||
|
<td><a href="${openCaseUrl}" class="text-decoration-none">#${Number(item.case_id || 0)}</a></td>
|
||||||
|
<td>${escapeHtml(formatDate(item.pickup_window_start))}</td>
|
||||||
|
<td>${escapeHtml(formatMoney(item.total_amount, item.currency))}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
${trackingUrl ? `<a href="${trackingUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
|
||||||
|
${labelUrl ? `<a href="${labelUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary ms-1"><i class="bi bi-file-earmark-text"></i></a>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBookings() {
|
||||||
|
const tbody = document.getElementById('fedexTableBody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter FedEx bestillinger...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/fedex/bookings', { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
state.bookings = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
|
applyFilters();
|
||||||
|
} catch (error) {
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">Kunne ikke hente FedEx bestillinger: ${escapeHtml(error.message || 'ukendt fejl')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
document.getElementById('fedexSearchInput')?.addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('fedexStatusFilter')?.addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('fedexSortSelect')?.addEventListener('change', applyFilters);
|
||||||
|
|
||||||
|
document.getElementById('fedexClearBtn')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('fedexSearchInput').value = '';
|
||||||
|
document.getElementById('fedexStatusFilter').value = 'all';
|
||||||
|
document.getElementById('fedexSortSelect').value = 'newest';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('refreshFedexBtn')?.addEventListener('click', loadBookings);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
bindEvents();
|
||||||
|
loadBookings();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user