Compare commits

..

97 Commits

Author SHA1 Message Date
Christian
70a01db422 Default API workers to 1 to keep telefoni websocket events reliable 2026-06-11 12:53:52 +02:00
Christian
9dfa7ca936 Restore VOIP popup by allowing cookie-auth websocket fallback 2026-06-11 12:38:40 +02:00
Christian
96f4a36724 Handle missing supplier_invoices.sag_id in supplier invoice listing 2026-06-11 09:45:11 +02:00
Christian
1e84ba267c Fix sag status constraint to support active case statuses 2026-06-11 09:37:41 +02:00
Christian
f4bc2828e8 Fix case status save on v3 detail by binding inline onchange fallback 2026-06-11 09:34:02 +02:00
Christian
fd8f4d6d88 Fix case status update: use Body() to properly parse JSON request body for PATCH endpoints 2026-06-11 01:38:55 +02:00
Christian
5f2452f222 Fix email routing and contact search 2026-06-11 01:12:25 +02:00
Christian
592ed8640d feat(telefoni): enhance call handling with external number normalization and case linking 2026-06-09 00:05:28 +02:00
Christian
c019a0367b fix(mission): show project-level todo tasks in mission control detail 2026-05-18 07:26:09 +02:00
Christian
071d926781 fix(telefoni): skip initial auto-refresh when SSR rows exist 2026-05-18 07:07:51 +02:00
Christian
94f6735ed5 fix(telefoni): preserve SSR call rows when client refresh fails 2026-05-18 07:00:52 +02:00
Christian
ef8e68fc16 fix(telefoni): remove raw_payload dependency from call list queries 2026-05-17 23:16:31 +02:00
Christian
468814ca8d feat(deploy): add fast code-only update script with guardrails and docs 2026-05-17 23:05:22 +02:00
Christian
08f40977f9 fix(telefoni): keep SSR call rows when initial API refresh is empty 2026-05-17 09:44:28 +02:00
Christian
e162ee3fe1 fix(telefoni): render initial calls server-side on log page 2026-05-17 09:36:09 +02:00
Christian
e0c4e138d6 fix(settings): update SQL console access to require any permission instead of superadmin 2026-05-16 19:46:31 +02:00
Christian
1b6b37e96e feat(settings): add read-only SQL console with diagnostic presets 2026-05-16 13:40:58 +02:00
Christian
c5478b7e29 fix(telefoni): show legacy call history when telefoni_opkald is empty 2026-05-16 13:23:56 +02:00
Christian
6a68aecafa fix(telefoni): accept callbacks via db whitelist and internal fallback 2026-05-16 13:16:35 +02:00
Christian
8e5b3cf3d2 fix(telefoni): restore corrupted log template to resolve blank page 2026-05-16 12:18:59 +02:00
Christian
d0ec639de0 fix(telefoni): restore template and add clear load/error status banner 2026-05-16 12:10:59 +02:00
Christian
3bc4472525 fix(telefoni): add explicit load/error status and stronger empty-state fallback 2026-05-16 11:06:48 +02:00
Christian
97a4a2435c fix(telefoni): add SSR fallback rows for non-empty log page 2026-05-16 10:52:36 +02:00
Christian
0ed450451d fix(contacts): stabilize contacts pagination and company enrichment 2026-05-16 10:28:05 +02:00
Christian
aa87285cab docs: add release notes for v2.3 2026-05-16 10:05:12 +02:00
Christian
a36e3e716f feat: Add Service Contract Report page with customer and contract selection
- Implemented a new HTML page for generating service contract reports.
- Added CSS styles for report layout and components.
- Developed JavaScript functionality for loading customers and contracts, fetching report data, and rendering metrics and cases.
- Included buttons for downloading reports in PDF and Excel formats.

docs: Create Route Auth Audit for route access control

- Generated an audit report detailing route access requirements.
- Classified routes based on authentication needs and documented them in a markdown file.

feat: Introduce buzzwords and mission projects tables in the database

- Created `buzzwords` and `sag_buzzwords` tables for managing keywords related to SAG cases.
- Established `mission_projects`, `mission_project_milestones`, and `mission_project_blockers` tables for project management.
- Updated `sag_sager` table to link with mission projects and milestones, including necessary foreign key constraints.
2026-05-12 08:41:13 +02:00
Christian
770f822fc6 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching optional files.
- Integrated automatic screenshot capture functionality when the bug report modal is opened.
- Created a new API endpoint for submitting bug reports, including validation and rate limiting.
- Added database migration for tracking bug report submissions.
- Updated frontend scripts to handle bug report submissions and display status messages.
- Enhanced contact search functionality with improved error handling and backward compatibility.
- Introduced a new button in the UI for accessing the bug report modal.
2026-05-06 07:01:43 +02:00
Christian
71f6372496 feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching files.
- Implemented backend API for creating bug reports, including rate limiting and metadata logging.
- Introduced a new database table to track bug report submissions for auditing purposes.
- Enhanced the frontend to capture screenshots automatically and allow manual file uploads.
- Added error handling and user feedback for the bug reporting process.
- Updated existing templates and scripts to integrate the new bug reporting functionality.
2026-05-05 19:13:54 +02:00
Christian
1a44baba62 hotfix: fix economy time-queue order link path to /ordre 2026-05-05 07:42:46 +02:00
Christian
03a1b79737 hotfix: robust local order creation with customer mapping fallback 2026-05-05 07:40:58 +02:00
Christian
e878336537 hotfix: replace legacy missing economic customer number error message 2026-05-05 07:37:16 +02:00
Christian
a5866132ab hotfix: skip economic export when customer number missing (local-only) 2026-05-05 07:33:01 +02:00
Christian
ebdb13168d fix: allow local order creation without economic dependency 2026-05-05 07:30:09 +02:00
Christian
4b5e154dc1 fix: enforce local-order-only flow in economy time queue 2026-05-05 07:24:45 +02:00
Christian
f6b78f93eb fix: show sanitized phone details in sag contact search results 2026-05-05 07:14:51 +02:00
Christian
1fe0611453 fix: show phone and mobile in sag v3 add-contact search results 2026-05-05 07:03:03 +02:00
Christian
0dcc6c4fdb ui: make telefoni row action buttons icon-only 2026-05-05 06:50:20 +02:00
Christian
86b3b3be15 feat: add direct Ny kontakt / Søg / Firma buttons on telefoni rows 2026-05-05 00:57:28 +02:00
Christian
31fa771626 fix: strip local phone suffix from overly long caller numbers (Yealink URL misconfiguration) 2026-05-05 00:29:13 +02:00
Christian
e4e35a1285 fix: skip auto-loadCalls when SSR already rendered telefoni rows 2026-05-05 00:22:02 +02:00
Christian
aa2aea555d hotfix: ignore restored telephony filters on first load 2026-05-05 00:10:42 +02:00
Christian
415abb058a hotfix: keep initial telephony rows on first empty refresh 2026-05-04 23:46:41 +02:00
Christian
b1a4342a9a hotfix: server-render initial telephony calls 2026-05-04 22:46:31 +02:00
Christian
93da2866dc hotfix: always run compose up after build 2026-05-04 22:34:16 +02:00
Christian
a37e0a89fa hotfix: safe .env parsing in deploy script 2026-05-04 22:30:19 +02:00
Christian
988450919b hotfix: prevent STACK_NAME env crash in production deploy 2026-05-04 19:59:47 +02:00
Christian
25530c7c94 release: v2.2.81 contacts visibility and telephony/date/deploy fixes 2026-05-04 19:20:55 +02:00
Christian
8ec9400b15 Release v2.2.80 2026-05-04 16:57:48 +02:00
Christian
6f8a0b7b8e fix(contacts): adjust overflow properties for contacts table wrap 2026-05-04 16:56:05 +02:00
Christian
90a6496c48 Release v2.2.79: economy queue, contact-company backfill, and production fixes 2026-05-04 16:24:38 +02:00
Christian
2cef28ff3b Fix backup pg_dump resolution across environments 2026-05-02 11:13:18 +02:00
Christian
5ee962fdb3 Release: mission day workflow, telefoni contact modal, fedex support overview, and economic sync dry-run 2026-05-02 11:02:29 +02:00
Christian
f2c8af4680 feat(task-templates): implement task template MVP with modal selector and tag actions
- Added task template and task template items tables to the database.
- Introduced case template runs and run items tables for tracking template executions.
- Created a new JavaScript module for task template selection with a modal interface.
- Integrated tag actions to open the task template selector modal upon tag addition.
- Updated backend to resolve tag actions and return them in the response when adding tags.
- Enhanced the tag picker to handle actions and trigger the appropriate modal.
- Added permissions and group permissions for managing task templates.
2026-05-01 20:58:13 +02:00
Christian
785a2d3ffe feat: Enhance FedEx service with pricing information and update UI for shipping address selection 2026-05-01 07:08:28 +02:00
Christian
bd44771738 feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
Christian
ec2c8fe784 feat: Implement legacy case details redirection and enhance contact info UI 2026-04-30 22:20:44 +02:00
Christian
6133823ade Fix tag addition error handling and add legacy support for case tags
- Improved error handling when adding tags by parsing JSON response safely.
- Added support for legacy tag addition via the /sag/{id}/tags endpoint for case context.
- Enhanced user feedback for tag addition errors and success notifications.
2026-04-27 01:12:33 +02:00
Christian
5bd54a27dc Refactor code structure for improved readability and maintainability 2026-04-26 13:14:53 +02:00
Christian
dee82af2ea Refactor UI components and layouts for improved user experience
- Removed outdated design_forslag_top3_ny_side.html file.
- Updated bottom-bar.js to add back button functionality for better navigation.
- Introduced new sidebar layout in design_forslag_1_sidebar.html for enhanced information display.
- Created design_forslag_2_kompakt.html featuring a compact action ribbon for streamlined interactions.
- Developed design_forslag_3_kort.html implementing a widget cards dashboard for a cleaner overview of case details.
2026-04-24 23:12:51 +02:00
Christian
3452472ba9 Add migrations for recent cases, time tracking pause/resume, and user notes
- Created `sag_recent_cases` table to persist recently opened cases per user for quick access in the bottom bar.
- Added pause/resume support in `tmodule_times` by introducing `paused_at` and `pause_total_seconds` columns.
- Established `user_notes` table for personal user notes with indexing for active and updated notes, along with a trigger to update the `updated_at` timestamp on modifications.

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 11:28:12 +02:00
Christian
ca6640c33c feat: Enhance case detail view with tab count badges and importance bubbles 2026-04-23 23:42:31 +02:00
Christian
fcc7192015 feat: Add rental statistics and pricing tabs to hardware detail view 2026-04-21 18:59:30 +02:00
Christian
4a52bdb5d6 feat: Implement quick-rent functionality for hardware assets
- Added QuickRentCreateInput model to handle quick-rent requests.
- Introduced quick_rent_preview endpoint to check existing subscriptions.
- Created quick_rent_hardware endpoint to manage rental subscriptions, asset bindings, and startup order drafts.
- Updated SQL queries to ensure proper data retrieval and handling.
- Added default rental price columns to hardware_assets table via migration.
- Enhanced UI in sag templates for better user experience and accessibility.
- Refactored existing code for improved readability and maintainability.
2026-04-21 01:34:40 +02:00
Christian
8e8616c835 feat: Enhance vendor and customer linking functionality
- Added endpoints to link and unlink customers to vendors, including validation for relationship types.
- Implemented a UI for managing linked customers in the vendor detail view.
- Introduced a search feature for customers when linking to vendors.
- Updated database schema to support customer-vendor relationships with necessary constraints and indices.
- Added migration scripts for new tables and fields related to supplier invoices and customer-vendor links.
- Modified bottom bar visibility in the frontend for improved user experience.
2026-04-15 09:34:26 +02:00
Christian
13dc1736b4 feat: Implement supplier invoice case traceability and purchase line classification 2026-04-12 09:26:35 +02:00
Christian
ceb560e2f2 feat: Add bottom bar functionality with real-time updates and manual endpoint tests
- Implemented a new bottom bar feature in `bottom-bar.js` that fetches and displays various notifications and statuses in real-time.
- Added functions for handling visibility, state updates, and user interactions within the bottom bar.
- Introduced WebSocket connection for real-time updates and fallback polling mechanism.
- Created a manual testing script `test_manual.py` to validate API endpoints for the manual module.
- Included tests for various paths to ensure expected responses from the server.
2026-04-12 02:27:01 +02:00
Christian
270af0e277 feat(anydesk): Implement multi-ID support for AnyDesk cases
- Added endpoints to list, upsert, and delete AnyDesk IDs associated with cases.
- Introduced normalization for AnyDesk IDs and ensured case existence checks.
- Enhanced session management with quick-connect functionality and local session synchronization.
- Created a new job for syncing AnyDesk sessions from a local endpoint.
- Added database migration for the new `sag_anydesk_ids` table to store AnyDesk IDs per case.
2026-04-06 12:46:04 +02:00
Christian
ee8c517acc feat(manual): add admin interface for creating and editing manuals
- Implemented admin page for manual articles with fields for title, module, difficulty, tags, summary, content, steps, and relations.
- Added preview functionality for markdown content.
- Created list view for recent manuals with edit and view options.
- Developed detail view for individual manuals displaying content, steps, and related guides.
- Established database schema for manual articles, steps, and relations with appropriate indexing.
- Seeded initial manual articles and steps for core functionalities.
- Normalized newline characters in existing manual content.
- Added additional manuals and steps for enhanced user guidance.
2026-04-05 21:48:59 +02:00
Christian
807c68679e feat: Enhance case listing and detail views with improved filtering and relation handling
- Added filtering for cases based on start date in `sager_liste`.
- Improved fallback relation tree rendering in `sag_detaljer` when tree builder fails.
- Normalized relation types in `RelationService` for consistency.
- Updated relation type display in templates with new styles and improved semantics.
- Enhanced customer handling in detail view with edit functionality.
- Updated various labels for clarity in the UI.
- Added new buttons for deferred status shortcuts in the detail view.
- Improved tag picker resilience by decoupling from optional tag group API.
2026-04-04 02:46:37 +02:00
Christian
1f834160ca Add ability to change case customer from case detail 2026-04-03 01:24:20 +02:00
Christian
fb2243f0d4 Preserve email body in auto-created cases and backfill missing content 2026-04-03 00:50:34 +02:00
Christian
267f7e716c Add idempotent migration to repair SAG email threading schema 2026-04-02 23:01:31 +02:00
Christian
73c477bcea Add caseTypeKey fallback to prevent module load cascade on parse errors 2026-04-02 22:06:37 +02:00
Christian
ae6217b976 Fix case tabs fallback and harden sag email-links loading 2026-04-02 21:44:56 +02:00
Christian
9be8b57303 Fix email case auto-create guard and CreateSagFromEmailRequest fields 2026-04-02 09:40:23 +02:00
Christian
0edb78f2ea fix: harden case files endpoints when sag_files table/schema is missing 2026-04-02 00:22:05 +02:00
Christian
c99790a710 fix: expose migrations execute API route and preserve real migration errors in UI fallback 2026-04-01 23:55:20 +02:00
Christian
ba601e38b1 Merge branch 'feature/sag-tidsforbrug-v1' 2026-04-01 21:36:23 +02:00
Christian
30d1be61eb feat: Add global search functionality and email results section
- Introduced a global search button and modal for enhanced user experience.
- Added a new section for displaying email results in the global search modal.
- Implemented functionality to fetch and display emails based on user queries.
- Updated the UI to include a reminders button and improved accessibility features.

fix: Update docker-compose to allow reload configuration

- Changed ENABLE_RELOAD environment variable to default to true for easier development.

chore: Update requirements for new dependencies

- Added brother_ql, pyzbar, and pypdfium2 to requirements for label printing and PDF processing.

feat: Implement Brother label printing service

- Created a new service for printing labels using Brother QL printers.
- Supports direct printing of case hardware labels with customizable layouts.

feat: Add Vaultwarden service for credential management

- Implemented a service to interact with Vaultwarden for secure credential storage and retrieval.

sql: Add migrations for email thread keys and document tokens

- Created migrations to backfill email thread keys and manage document tokens for work orders.
- Introduced new tables and updated existing structures to support token-based linking of scanned documents.

sql: Import links into the database

- Added a script to import a predefined set of links into the database with associated categories.
2026-04-01 21:34:58 +02:00
Christian
bc504b9257 feat: Add subscription management functionality and AnyDesk API integration
- Implemented subscription creation, updating, and rendering in script_9.js.
- Added functions for handling subscription line items, product selection, and total calculations.
- Integrated AnyDesk API for session management in test_anydesk.py.
- Created REST client test requests for API endpoints in api.http.
- Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
2026-03-30 07:50:15 +02:00
Christian
5b24c5d978 fix: stabilize call->case prefill and migration status routing 2026-03-26 00:32:54 +01:00
Christian
9f563941e6 feat: add migration validation script and enhance migration status UI 2026-03-25 22:49:33 +01:00
Christian
205c0dab07 feat(timetracking): start sag tidsforbrug v1 backend+ui 2026-03-25 16:33:49 +01:00
Christian
43fd651723 Release v2.2.67: mission touch UX, camera/webhook, env temperature feed 2026-03-25 13:46:03 +01:00
Christian
daf2f29471 feat: improve billing, sag, orders, and email workflows 2026-03-23 20:35:15 +01:00
Christian
a8eaf6e2a9 feat: enhance tag management and search functionality
- Updated the index.html template to include a new column for "Næste todo" in the sag table.
- Added new JavaScript functions to load and manage case statuses in settings.html, including normalization and rendering of statuses.
- Introduced a new tag search feature in tags_admin.html, allowing users to filter tags by name, type, and module with pagination support.
- Enhanced the backend router.py to include a new endpoint for listing tag usage across modules with server-side filtering and pagination.
- Improved the overall UI and UX of the tag administration page, including responsive design adjustments and better error handling.
2026-03-20 18:43:45 +01:00
Christian
92b888b78f Add migrations for seeding tags and enhancing todo steps
- Created migration 146 to seed case type tags with various categories and keywords.
- Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types.
- Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection.
- Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling.
2026-03-20 00:24:58 +01:00
Christian
dcae962481 release: v2.2.65 fix AI prompt tests and case email threading 2026-03-18 13:49:33 +01:00
Christian
243e4375e0 Add QuickCreate heuristic fallback when AI unavailable 2026-03-18 10:29:45 +01:00
Christian
153eb728e2 Fix QuickCreate AI request payload 2026-03-18 10:25:47 +01:00
Christian
73803f894b Fix SAG detail right column nesting 2026-03-18 09:58:31 +01:00
Christian
60d692c085 Fix SAG tab pane top rendering fallback 2026-03-18 09:46:33 +01:00
Christian
beaea0288c release: v2.2.60 enforce active sag tab top view 2026-03-18 09:29:57 +01:00
Christian
e07932f2cc release: v2.2.59 robust sag tab content scrolling 2026-03-18 08:57:29 +01:00
Christian
7a95623094 release: v2.2.58 sag tab top-position UX 2026-03-18 08:36:54 +01:00
Christian
9a3ada380f release: v2.2.57 email+sag tab stability 2026-03-18 07:33:32 +01:00
Christian
eb5e14e2a1 release: v2.2.56 email layout + supplier invoice stabilization 2026-03-18 07:14:28 +01:00
371 changed files with 101729 additions and 3205 deletions

View File

@ -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

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -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 \

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`

View File

@ -1 +1 @@
2.2.52 2.2.99

142
add_css.py Normal file
View 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!')

View 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"},
)

File diff suppressed because it is too large Load Diff

View File

@ -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"}

View File

@ -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,
}
) )

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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;

View 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 %}

View File

@ -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"
})

View File

View File

View 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

View 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",
)

View File

@ -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")

View File

@ -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

View File

@ -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,

View File

@ -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,)

View File

@ -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

View File

@ -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)

View File

@ -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}")

View File

@ -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,
}) })

View File

@ -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
}; };

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 =

View File

@ -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) {

View File

@ -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

View File

@ -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:

View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

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

View File

View 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")

View File

View 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, '&lt;').replace(/>/g, '&gt;');
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 %}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"}
) )

View File

@ -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 %}

View 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)

View File

@ -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

View File

@ -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,

View File

@ -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()

View 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,
}

View File

@ -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

View File

View 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)

View 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),
}

View 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,
}

View 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"]
}

View File

@ -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",

View File

View File

View 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

View 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))

View 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()

View File

View 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