Compare commits

...

16 Commits

Author SHA1 Message Date
Christian
bef5c20c83 feat: Implement AI-powered Case Analysis Service and QuickCreate Modal
- Added CaseAnalysisService for analyzing case text using Ollama LLM.
- Integrated AI analysis into the QuickCreate modal for automatic case creation.
- Created HTML structure for QuickCreate modal with dynamic fields for title, description, customer, priority, technician, and tags.
- Implemented customer search functionality with debounce for efficient querying.
- Added priority field to sag_sager table with migration for consistency in case management.
- Introduced caching mechanism in CaseAnalysisService to optimize repeated analyses.
- Enhanced error handling and user feedback in the QuickCreate modal.
2026-02-20 07:10:06 +01:00
Christian
e6b4d8fb47 feat: add alert notes functionality with inline and modal display
- Implemented alert notes JavaScript module for loading and displaying alerts for customers and contacts.
- Created HTML template for alert boxes to display alerts inline on detail pages.
- Developed modal for creating and editing alert notes with form validation and user restrictions.
- Added modal for displaying alerts with acknowledgment functionality.
- Enhanced user experience with toast notifications for successful operations.
2026-02-17 12:49:11 +01:00
Christian
3cddb71cec feat: Add Technician Dashboard V1, V2, and V3 with enhanced UI and functionality
- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview.
- Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities.
- Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management.
- Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions.
- Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths.
- Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management.
- Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql).
- Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql).
- Added a script to move time tracking section in detail view (move_time_section.py).
- Created a test script for subscription processing (test_subscription_processing.py).
2026-02-17 08:29:05 +01:00
Christian
891180f3f0 Refactor opportunities and settings management
- Removed opportunity detail page route from views.py.
- Deleted opportunity_service.py as it is no longer needed.
- Updated router.py to seed new setting for case_type_module_defaults.
- Enhanced settings.html to include standard modules per case type with UI for selection.
- Implemented JavaScript functions to manage case type module defaults.
- Added RelationService for handling case relations with a tree structure.
- Created migration scripts (128 and 129) for new pipeline fields and descriptions.
- Added script to fix relation types in the database.
2026-02-15 11:12:58 +01:00
Christian
0831715d3a feat: add SMS service and frontend integration
- Implement SmsService class for sending SMS via CPSMS API.
- Add SMS sending functionality in the frontend with validation and user feedback.
- Create database migrations for SMS message storage and telephony features.
- Introduce telephony settings and user-specific configurations for click-to-call functionality.
- Enhance user experience with toast notifications for incoming calls and actions.
2026-02-14 02:26:29 +01:00
Christian
7eda0ce58b feat(dashboard): enhance dashboard stats and add upcoming reminders feature
- Updated dashboard stats to include new customer counts and trends, ticket counts, hardware counts, and revenue growth percentages.
- Added a new endpoint for fetching upcoming reminders for the dashboard calendar widget.
- Improved recent activity fetching to include recent tickets and cases.
- Enhanced frontend with modern styling for dashboard components, including stat cards and activity feed.
- Implemented loading states and error handling for stats, activity, and reminders in the frontend.
- Refactored HTML structure for better organization and responsiveness.

feat(hardware): support for new hardware_assets table in contact hardware listing

- Modified the endpoint to list hardware by contact to support both new hardware_assets and legacy hardware tables.
- Merged results from both tables, prioritizing the new hardware_assets table for better data accuracy.

style(eset_import): improve device display options in ESET import template

- Added toggle functionality for switching between tablet view and table view for device listings.
- Enhanced the layout and visibility of device cards and tables for better user experience.
2026-02-12 07:03:18 +01:00
Christian
489f81a1e3 feat: Enhance hardware detail view with ESET data synchronization and specifications
- Added a button to sync ESET data in the hardware detail view.
- Introduced a new tab for ESET specifications, displaying relevant device information.
- Included ESET UUID and group details in the hardware information section.
- Implemented a JavaScript function to handle ESET data synchronization via API.
- Updated the ESET import template to improve device listing and inline contact selection.
- Enhanced the Nextcloud and locations routers to support customer ID resolution from contacts.
- Added utility functions for retrieving customer IDs linked to contacts.
- Removed debug information from the service contract wizard for cleaner output.
2026-02-11 23:51:21 +01:00
Christian
297a8ef2d6 feat: Implement ESET integration for hardware management
- Added ESET sync functionality to periodically fetch devices and incidents.
- Created new ESET service for API interactions, including authentication and data retrieval.
- Introduced new database tables for storing ESET incidents and hardware contacts.
- Updated hardware assets schema to include ESET-specific fields (UUID, specs, group).
- Developed frontend templates for ESET overview, import, and testing.
- Enhanced existing hardware creation form to auto-generate AnyDesk links.
- Added global logout functionality to clear user session data.
- Improved error handling and logging for ESET API interactions.
2026-02-11 13:23:32 +01:00
Christian
3d7fb1aa48 feat(migrations): add AnyDesk session management and customer wiki slug updates
- Created migration scripts for AnyDesk sessions and hardware assets.
- Implemented apply_migration_115.py to execute migration for AnyDesk sessions.
- Added set_customer_wiki_slugs.py script to update customer wiki slugs based on a predefined folder list.
- Developed run_migration.py to apply AnyDesk migration schema.
- Added tests for Service Contract Wizard to ensure functionality and dry-run mode.
2026-02-10 14:40:38 +01:00
Christian
693ac4cfd6 feat: Add case types to settings if not found
feat: Update frontend navigation and links for support and CRM sections

fix: Modify subscription listing and stats endpoints to support 'all' status

feat: Implement subscription status filter in the subscriptions list view

feat: Redirect ticket routes to the new sag path

feat: Integrate devportal routes into the main application

feat: Create a wizard for location creation with nested floors and rooms

feat: Add product suppliers table to track multiple suppliers per product

feat: Implement product audit log to track changes in products

feat: Extend location types to include kantine and moedelokale

feat: Add last_2fa_at column to users table for 2FA grace period tracking
2026-02-09 15:30:07 +01:00
Christian
6320809f17 feat: Add subscriptions and products management
- Implemented frontend views for products and subscriptions using FastAPI and Jinja2 templates.
- Created API endpoints for managing subscriptions, including creation, listing, and status updates.
- Added HTML templates for displaying active subscriptions and their statistics.
- Established database migrations for sag_subscriptions, sag_subscription_items, and products, including necessary indexes and triggers for automatic subscription number generation.
- Introduced product price history tracking to monitor changes in product pricing.
2026-02-08 12:42:19 +01:00
Christian
e4b9091a1b feat: Implement fixed-price agreements frontend views and related templates
- Added views for listing fixed-price agreements, displaying agreement details, and a reporting dashboard.
- Created HTML templates for listing, detailing, and reporting on fixed-price agreements.
- Introduced API endpoint to fetch active customers for agreement creation.
- Added migration scripts for creating necessary database tables and views for fixed-price agreements, billing periods, and reporting.
- Implemented triggers for auto-generating agreement numbers and updating timestamps.
- Enhanced ticket management with archived ticket views and filtering capabilities.
2026-02-08 01:45:00 +01:00
Christian
b43e9f797d feat: Add reminder system for sag cases with user preferences and notification channels
- Implemented user notification preferences table for managing default notification settings.
- Created sag_reminders table to define reminder rules with various trigger types and recipient configurations.
- Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times.
- Added sag_reminder_logs to track reminder notifications and user interactions.
- Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders.
- Created email template for sending reminders with case details and action links.
- Implemented rate limiting for user notifications to prevent spamming.
- Added triggers and functions for automatic updates and reminder processing.
2026-02-06 10:47:14 +01:00
Christian
b06ff693df feat: Enhance contact management and user/group functionalities
- Added ContactCompanyLink model for linking contacts to companies with primary role handling.
- Implemented endpoint to link contacts to companies, including conflict resolution for existing links.
- Updated auth service to support additional password hashing schemes.
- Improved sag creation and update processes with new fields and validation for status.
- Enhanced UI for user and group management, including modals for group assignment and permissions.
- Introduced new product catalog and improved sales item structure for better billing and aggregation.
- Added recursive aggregation logic for financial calculations in cases.
- Implemented strict status lifecycle for billing items to prevent double-billing.
2026-02-03 15:37:16 +01:00
Christian
56d6d45aa2 feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
Christian
d5dd958bf9 Refactor Sager module templates and functionality
- Updated index.html to extend base template and improve structure.
- Added new styles and search/filter functionality in the Sager list view.
- Created a backup of the old index.html as index_old.html.
- Updated navigation links in base.html for consistency.
- Included new dashboard API router in main.py.
- Added test scripts for customer and sag queries to validate database interactions.
2026-02-01 11:58:44 +01:00
271 changed files with 58910 additions and 5401 deletions

View File

@ -0,0 +1,459 @@
# GitHub Copilot Instructions - BMC Webshop (Frontend)
## Project Overview
BMC Webshop er en kunde-styret webshop løsning, hvor **BMC Hub** ejer indholdet, **API Gateway** (`apigateway.bmcnetworks.dk`) styrer logikken, og **Webshoppen** (dette projekt) kun viser og indsamler input.
**Tech Stack**: React/Next.js/Vue.js (vælg én), TypeScript, Tailwind CSS eller Bootstrap 5
---
## 3-Lags Arkitektur
```
┌─────────────────────────────────────────────────────────┐
│ TIER 1: BMC HUB (Admin System) │
│ - Administrerer webshop-opsætning │
│ - Pusher data til Gateway │
│ - Poller Gateway for nye ordrer │
│ https://hub.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▼ (Push config)
┌─────────────────────────────────────────────────────────┐
│ TIER 2: API GATEWAY (Forretningslogik + Database) │
│ - Modtager og gemmer webshop-config fra Hub │
│ - Ejer PostgreSQL database med produkter, priser, ordrer│
│ - Håndterer email/OTP login │
│ - Beregner priser og filtrerer varer │
│ - Leverer sikre API'er til Webshoppen │
│ https://apigateway.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▲ (API calls)
┌─────────────────────────────────────────────────────────┐
│ TIER 3: WEBSHOP (Dette projekt - Kun Frontend) │
│ - Viser logo, tekster, produkter, priser │
│ - Shopping cart (kun i frontend state) │
│ - Sender ordre som payload til Gateway │
│ - INGEN forretningslogik eller datapersistering │
└─────────────────────────────────────────────────────────┘
```
---
## Webshoppens Ansvar
### ✅ Hvad Webshoppen GØR
- Viser kundens logo, header-tekst, intro-tekst (fra Gateway)
- Viser produktkatalog med navn, beskrivelse, pris (fra Gateway)
- Samler kurv i browser state (localStorage/React state)
- Sender ordre til Gateway ved checkout
- Email/OTP login flow (kalder Gateway's auth-endpoint)
### ❌ Hvad Webshoppen IKKE GØR
- Gemmer INGEN data (hverken kurv, produkter, eller ordrer)
- Beregner INGEN priser eller avance
- Håndterer INGEN produkt-filtrering (Gateway leverer klar liste)
- Snakker IKKE direkte med Hub eller e-conomic
- Håndterer IKKE betalingsgateway (Gateway's ansvar)
---
## API Gateway Kontrakt
Base URL: `https://apigateway.bmcnetworks.dk`
### 1. Login med Email + Engangskode
**Step 1: Anmod om engangskode**
```http
POST /webshop/auth/request-code
Content-Type: application/json
{
"email": "kunde@firma.dk"
}
Response 200:
{
"success": true,
"message": "Engangskode sendt til kunde@firma.dk"
}
```
**Step 2: Verificer kode og få JWT token**
```http
POST /webshop/auth/verify-code
Content-Type: application/json
{
"email": "kunde@firma.dk",
"code": "123456"
}
Response 200:
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"customer_id": 42,
"expires_at": "2026-01-13T15:00:00Z"
}
```
### 2. Hent Webshop Context (Komplet Webshop-Data)
```http
GET /webshop/{customer_id}/context
Authorization: Bearer {jwt_token}
Response 200:
{
"customer_id": 42,
"company_name": "Advokatfirma A/S",
"config_version": "2026-01-13T12:00:00Z",
"branding": {
"logo_url": "https://apigateway.bmcnetworks.dk/assets/logos/42.png",
"header_text": "Velkommen til vores webshop",
"intro_text": "Bestil nemt og hurtigt direkte her.",
"primary_color": "#0f4c75",
"accent_color": "#3282b8"
},
"products": [
{
"id": 101,
"ean": "5711045071324",
"product_number": "FIRE-001",
"name": "Cisco Firewall ASA 5506-X",
"description": "Next-generation firewall med 8 porte",
"unit": "stk",
"base_price": 8500.00,
"calculated_price": 9350.00,
"margin_percent": 10.0,
"currency": "DKK",
"stock_available": true,
"category": "Network Security"
},
{
"id": 102,
"ean": "5704174801740",
"product_number": "SW-024",
"name": "TP-Link 24-Port Gigabit Switch",
"description": "Managed switch med VLAN support",
"unit": "stk",
"base_price": 2100.00,
"calculated_price": 2310.00,
"margin_percent": 10.0,
"currency": "DKK",
"stock_available": true,
"category": "Switches"
}
],
"allowed_payment_methods": ["invoice", "card"],
"min_order_amount": 500.00,
"shipping_cost": 0.00
}
```
### 3. Opret Ordre
```http
POST /webshop/orders
Authorization: Bearer {jwt_token}
Content-Type: application/json
{
"customer_id": 42,
"order_items": [
{
"product_id": 101,
"quantity": 2,
"unit_price": 9350.00
},
{
"product_id": 102,
"quantity": 5,
"unit_price": 2310.00
}
],
"shipping_address": {
"company_name": "Advokatfirma A/S",
"street": "Hovedgaden 1",
"postal_code": "1000",
"city": "København K",
"country": "DK"
},
"delivery_note": "Levering til bagsiden, ring på døren",
"total_amount": 30250.00
}
Response 201:
{
"success": true,
"order_id": "ORD-2026-00123",
"status": "pending",
"total_amount": 30250.00,
"created_at": "2026-01-13T14:30:00Z",
"message": "Ordre modtaget. Du vil modtage en bekræftelse på email."
}
```
### 4. Hent Mine Ordrer (Optional)
```http
GET /webshop/orders?customer_id=42
Authorization: Bearer {jwt_token}
Response 200:
{
"orders": [
{
"order_id": "ORD-2026-00123",
"created_at": "2026-01-13T14:30:00Z",
"status": "pending",
"total_amount": 30250.00,
"item_count": 7
}
]
}
```
---
## Frontend Krav
### Mandatory Features
1. **Responsive Design**
- Mobile-first approach
- Breakpoints: 576px (mobile), 768px (tablet), 992px (desktop)
- Brug CSS Grid/Flexbox eller framework grid system
2. **Dark Mode Support**
- Toggle mellem light/dark theme
- Gem præference i localStorage
- CSS Variables for farver
3. **Shopping Cart**
- Gem kurv i localStorage (persist ved page reload)
- Vis antal varer i header badge
- Real-time opdatering af total pris
- Slet/rediger varer i kurv
4. **Login Flow**
- Email input → Send kode
- Vis countdown timer (5 minutter)
- Verificer kode → Få JWT token
- Gem token i localStorage
- Auto-logout ved token expiry
5. **Product Catalog**
- Vis produkter i grid layout (3-4 kolonner på desktop)
- Filtrer produkter efter kategori (hvis Gateway leverer kategorier)
- Søgning i produktnavn/beskrivelse
- "Tilføj til kurv" knap med antal-vælger
6. **Checkout Flow**
- Vis kurv-oversigt
- Leveringsadresse (kan være pre-udfyldt fra Gateway)
- Leveringsnotat (textarea)
- "Bekræft ordre" knap
- Loading state under ordre-oprettelse
- Success/error feedback
### Design Guidelines
**Stil**: Minimalistisk, clean, "Nordic" æstetik (inspireret af BMC Hub's Nordic Top design)
**Farver** (kan overskrives af Gateway's branding config):
- Primary: `#0f4c75` (Deep Blue)
- Accent: `#3282b8` (Bright Blue)
- Success: `#27ae60`
- Warning: `#f39c12`
- Danger: `#e74c3c`
**Typografi**:
- Font: System font stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...`)
- Headings: 500-600 weight
- Body: 400 weight
**Components**:
- Cards med subtil shadow/border
- Buttons med hover states
- Input fields med focus outline
- Loading spinners (ikke lange tekst-beskeder)
---
## State Management
### Local Storage Keys
```javascript
// Authentication
webshop_jwt_token // JWT token fra Gateway
webshop_customer_id // Customer ID
webshop_token_expires_at // ISO timestamp
// Shopping Cart
webshop_cart // JSON array af cart items
webshop_theme // "light" eller "dark"
// Cache (optional)
webshop_context // Cached webshop context (TTL: 5 minutter)
```
### Cart Item Format
```javascript
{
product_id: 101,
ean: "5711045071324",
name: "Cisco Firewall ASA 5506-X",
unit_price: 9350.00,
quantity: 2,
total: 18700.00
}
```
---
## Error Handling
### Gateway API Errors
```javascript
// Eksempel på error response fra Gateway
{
"success": false,
"error": "invalid_code",
"message": "Ugyldig engangskode. Prøv igen."
}
```
**Error Codes** (forventet fra Gateway):
- `invalid_email` - Email ikke fundet eller ikke whitelisted
- `invalid_code` - Forkert engangskode
- `code_expired` - Engangskode udløbet (>5 min)
- `token_expired` - JWT token udløbet
- `unauthorized` - Manglende/ugyldig Authorization header
- `product_not_found` - Produkt ID findes ikke
- `min_order_not_met` - Ordre under minimum beløb
- `out_of_stock` - Produkt ikke på lager
**Handling**:
- Vis brugervenlig fejlbesked i UI (ikke tekniske detaljer)
- Log tekniske fejl til console (kun i development)
- Redirect til login ved `token_expired` eller `unauthorized`
---
## Security
1. **HTTPS Only**
- Al kommunikation med Gateway over HTTPS
- Ingen hardcoded credentials
2. **JWT Token**
- Gem i localStorage (ikke cookie)
- Send i `Authorization: Bearer {token}` header
- Check expiry før hver API call
- Auto-logout ved expiry
3. **Input Validation**
- Validér email format (client-side)
- Validér antal > 0 ved "Tilføj til kurv"
- Validér leveringsadresse udfyldt ved checkout
- Sanitize input (brug library som DOMPurify hvis nødvendigt)
4. **CORS**
- Gateway skal have `Access-Control-Allow-Origin` header
- Webshoppen kalder altid Gateway (ikke Hub direkte)
---
## Deployment
### Environment Variables
```bash
# .env.production
NEXT_PUBLIC_API_GATEWAY_URL=https://apigateway.bmcnetworks.dk
NEXT_PUBLIC_WEBSHOP_NAME="BMC Networks Webshop"
```
### Build Process
```bash
# Development
npm run dev
# Production build
npm run build
npm run start
# Docker (optional)
docker build -t bmc-webshop .
docker run -p 3000:3000 bmc-webshop
```
### Static Hosting (Anbefalet)
- Vercel, Netlify, eller Cloudflare Pages
- Deploy fra Git repository
- Automatisk HTTPS og CDN
- Environment variables i hosting provider UI
---
## Testing
### Manual Testing Checklist
- [ ] Login med email/OTP virker
- [ ] Token gemmes og bruges i efterfølgende API calls
- [ ] Webshop context hentes og vises korrekt
- [ ] Produkter vises i grid
- [ ] "Tilføj til kurv" opdaterer cart badge
- [ ] Cart viser korrekte varer og total pris
- [ ] Checkout sender korrekt payload til Gateway
- [ ] Success message vises ved succesfuld ordre
- [ ] Error handling virker (test med ugyldig kode, udløbet token)
- [ ] Dark mode toggle virker
- [ ] Responsive design på mobil/tablet/desktop
---
## Common Pitfalls to Avoid
1. **Gem IKKE data i Webshoppen** - alt kommer fra Gateway
2. **Beregn IKKE priser selv** - Gateway leverer `calculated_price`
3. **Snakker IKKE direkte med Hub** - kun via Gateway
4. **Gem IKKE kurv i database** - kun localStorage
5. **Hardcode IKKE customer_id** - hent fra JWT token
6. **Valider IKKE produkter selv** - Gateway filtrerer allerede
7. **Implementer IKKE betalingsgateway** - Gateway's ansvar
---
## Quick Reference
### API Endpoints
```
POST /webshop/auth/request-code # Anmod engangskode
POST /webshop/auth/verify-code # Verificer kode → JWT
GET /webshop/{customer_id}/context # Hent webshop data
POST /webshop/orders # Opret ordre
GET /webshop/orders?customer_id={id} # Hent mine ordrer
```
### Typical Flow
```
1. User indtaster email → POST /auth/request-code
2. User indtaster kode → POST /auth/verify-code → Gem JWT token
3. App henter webshop context → GET /context (med JWT header)
4. User browser produkter, tilføjer til kurv (localStorage)
5. User går til checkout → POST /orders (med cart data)
6. Gateway behandler ordre → Success message vises
```
---
## Support & Documentation
**Hub Repository**: `/Users/christianthomas/DEV/bmc_hub_dev`
**Hub API Docs**: `https://hub.bmcnetworks.dk/api/docs`
**Gateway API Docs**: `https://apigateway.bmcnetworks.dk/docs` (når implementeret)
**Kontakt**: ct@bmcnetworks.dk

View File

@ -22,6 +22,20 @@ ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Dock
SECRET_KEY=change-this-in-production-use-random-string
CORS_ORIGINS=http://localhost:8000,http://localhost:3000
# Telefoni (Yealink) callbacks security (MUST set at least one)
# Option A: Shared secret token (recommended)
TELEFONI_SHARED_SECRET=
# Option B: IP whitelist (LAN only) - supports IPs and CIDRs
TELEFONI_IP_WHITELIST=127.0.0.1
# Shadow Admin (Emergency Access)
SHADOW_ADMIN_ENABLED=false
SHADOW_ADMIN_USERNAME=shadowadmin
SHADOW_ADMIN_PASSWORD=
SHADOW_ADMIN_TOTP_SECRET=
SHADOW_ADMIN_EMAIL=shadowadmin@bmcnetworks.dk
SHADOW_ADMIN_FULL_NAME=Shadow Administrator
# =====================================================
# LOGGING
# =====================================================
@ -45,6 +59,16 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# =====================================================
# Nextcloud Integration (Optional)
# =====================================================
NEXTCLOUD_READ_ONLY=true
NEXTCLOUD_DRY_RUN=true
NEXTCLOUD_TIMEOUT_SECONDS=15
NEXTCLOUD_CACHE_TTL_SECONDS=300
# Generate a Fernet key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
NEXTCLOUD_ENCRYPTION_KEY=
# =====================================================
# vTiger Cloud Integration (Required for Subscriptions)
# =====================================================

View File

@ -49,6 +49,10 @@ API_RELOAD=false
# Brug: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=CHANGEME_GENERATE_RANDOM_SECRET_KEY
# Telefoni (Yealink) callbacks security (MUST set at least one)
TELEFONI_SHARED_SECRET=
TELEFONI_IP_WHITELIST=
# CORS origins - IP adresse med port
CORS_ORIGINS=http://172.16.31.183:8001

View File

@ -1,5 +1,28 @@
---
description: 'Describe what this custom agent does and when to use it.'
tools: []
name: hub-sales-and-aggregation-agent
description: "Planlægger og specificerer varekøb og salg i BMC Hub som en simpel sag-baseret funktion, inklusiv aggregering af varer og tid op gennem sagstræet."
scope:
- Sag-modul
- Vare- og ydelsessalg
- Aggregering i sagstræ
constraints:
- Ingen ERP-kompleksitet
- Ingen lagerstyring
- Ingen selvstændig ordre-entitet i v1
- Alt salg er knyttet til en Sag
- Aggregering er læsevisning, ikke datakopiering
inputs:
- Eksisterende Sag-model med parent/child-relationer
- Eksisterende Tidsmodul
- Varekatalog (internt og leverandørvarer)
outputs:
- Datamodelforslag
- UI-struktur for Varer-fanen
- Aggregeringslogik
- Faktureringsforberedelse
---
Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.

241
NEXTCLOUD_MODULE_PLAN.md Normal file
View File

@ -0,0 +1,241 @@
# Nextcloud-modul BMC Hub
## 1. Formål og rolle i Hubben
Nextcloud-modulet gør det muligt at sælge, administrere og supportere kunders Nextcloudløsninger direkte i Hubben.
Hubben er styrende system. Nextcloud er et eksternt drifts og brugersystem, som Hubben taler med direkte (ingen gateway).
## 2. Aktivering af modulet
Modulet er kontekstbaseret og aktiveres via tag:
- Når Firma, Kontakt eller Sag har tagget `nextcloud`, vises en Nextcloudfane i UI.
- Uden tag vises ingen Nextcloudfunktioner.
## 3. Kunde → Nextcloudfane (overblik)
Fanen indeholder:
1. Drifts og systeminformation (readonly)
2. Handlinger relateret til brugere
3. Historik (hvad Hubben har gjort mod instansen)
Fanen må aldrig blokere kundevisningen, selv hvis Nextcloud er utilgængelig.
## 4. Systemstatus og driftsinformation
**Datakilde**: Nextcloud Serverinfo API
- `GET /ocs/v2.php/apps/serverinfo/api/v1/info`
- Direkte kald til Nextcloud
- Autentificeret
- Readonly
- Cached i DB med global TTL = 5 min
### 4.1 Overblik
Vises øverst i fanen:
- Instansstatus (Online / Offline / Ukendt)
- Sidst opdateret
- Nextcloudversion
- PHPversion
- Databasetype og version
### 4.2 Ressourceforbrug
Vises som simple værdier/badges:
- CPU
- Load average (1 / 5 / 15 min)
- Antal kerner
- RAM (total + brug i %)
- Disk (total + brug i % + fri plads)
Ved kritiske værdier vises advarsel.
### 4.3 Nextcloudnøgletal
Hvor API tillader det:
- Antal brugere
- Aktive brugere
- Antal filer
- Samlet datamængde
- Status på: database, cache/Redis, cron/background jobs
## 5. Handlinger i Nextcloudfanen
Knapper:
- Tilføj ny bruger
- Reset password
- Luk bruger
- Gensend guide
Alle handlinger:
- udføres direkte mod Nextcloud
- logges i Hub
- kan spores i historik
- kan knyttes til sag
## 6. Tilføj ny bruger (primær funktion)
### 6.1 Start af flow
- Ved “Tilføj ny bruger” oprettes automatisk en ny Sag
- Sagstype: **Nextcloud Brugeroprettelse**
- Ingen Nextcloudhandling udføres uden en sag
### 6.2 Sag felter og logik
**Firma**
- Vælg eksisterende firma
- Hub slår tilknyttet Nextcloudinstans op i DB og vælger automatisk
- Instans kan ikke ændres manuelt
**Kontaktperson**
- Vælg eksisterende kontakt eller opret ny
- Bruges til kommunikation, velkomstmail og ejerskab af sag
**Grupper**
- Multiselect
- Hentes live fra Nextcloud (OCS groups API)
- Kun gyldige grupper kan vælges
**Velkomstbrev**
- Checkbox: skal velkomstbrev sendes?
- Hvis ja: bruger oprettes, password genereres, guide + logininfo sendes
- Hvis nej: bruger oprettes uden mail, sag forbliver åben til manuel opfølgning
## 7. Øvrige handlinger
**Reset password**
- Vælg eksisterende Nextcloudbruger
- Nyt password genereres
- Valg: send mail til kontakt eller kun log i sag
**Luk bruger**
- Bruger deaktiveres i Nextcloud
- Data bevares
- Kræver eksplicit bekræftelse
- Logges i sag og historik
**Gensend guide**
- Gensender velkomstmail og guide
- Password ændres ikke
- Kan udføres uden ny sag, men logges
## 8. Arkitekturprincipper
- Hub ejer: firma, kontakt, sag, historik
- Nextcloud ejer: brugere, filer, rettigheder
- Integration er direkte (ingen gateway)
- Perinstans auth ligger krypteret i DB
- Global DBcache (5 min) for readonly statusdata
## 9. Logning og sporbarhed
For hver handling gemmes:
- tidspunkt
- handlingstype
- udførende bruger
- mål (bruger/instans)
- teknisk resultat (success/fejl)
Auditlog er **separat pr. kunde**, med **manuel retention** og **tidsbaseret partitionering**.
## 10. Afgrænsninger (v1)
Modulet indeholder ikke:
- ændring af serverkonfiguration
- håndtering af apps
- ændring af kvoter
- direkte adminlogin
## 11. Klar til udvidelse
Modulet er designet til senere udvidelser:
- overvågning → automatisk sag
- historiske grafer
- offboardingflows
- kvotestyring
- SLArapportering
## 12. Sikkerhed og drift
- Credentials krypteres med `settings.NEXTCLOUD_ENCRYPTION_KEY`
- Safety switches: `NEXTCLOUD_READ_ONLY` og `NEXTCLOUD_DRY_RUN` (default true)
- Ingen credentials i UI eller logs
- TLSonly base URLs
## 13. Backendstruktur (plan)
Placering: `app/modules/nextcloud/`
- `backend/router.py`
- `backend/service.py`
- `backend/models.py`
Alle eksterne kald går via servicelaget, som:
- loader instans fra DB
- dekrypterer credentials
- bruger global DBcache (5 min)
- skriver auditlog pr. kunde
## 14. Databasemodel (plan)
### `nextcloud_instances`
- `customer_id` FK
- `base_url`
- `auth_type`
- `username`
- `password_encrypted`
- `is_enabled`, `disabled_at`
- `created_at`, `updated_at`, `deleted_at`
### `nextcloud_cache`
- `cache_key` (PK)
- `payload` (JSONB)
- `expires_at`
- `created_at`
### `nextcloud_audit_log`
- `customer_id`, `instance_id`
- `event_type`
- `request_meta`, `response_meta`
- `actor_user_id`
- `created_at`
Partitionering: månedlig range på `created_at`. Retention er manuel via adminUI.
## 15. APIendpoints (v1)
### Instanser (admin)
- `GET /api/v1/nextcloud/instances`
- `POST /api/v1/nextcloud/instances`
- `PATCH /api/v1/nextcloud/instances/{id}`
- `POST /api/v1/nextcloud/instances/{id}/disable`
- `POST /api/v1/nextcloud/instances/{id}/enable`
- `POST /api/v1/nextcloud/instances/{id}/rotate-credentials`
### Status + grupper
- `GET /api/v1/nextcloud/instances/{id}/status`
- `GET /api/v1/nextcloud/instances/{id}/groups`
### Brugere (handlinger)
- `POST /api/v1/nextcloud/instances/{id}/users` (opret)
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/reset-password`
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/disable`
- `POST /api/v1/nextcloud/instances/{id}/users/{uid}/resend-guide`
Alle endpoints skal:
- validere `is_enabled = true`
- håndhæve kundeejerskab
- skrive auditlog
- respektere `READ_ONLY`/`DRY_RUN`
## 16. UIkrav (plan)
Nextcloudfanen i kundevisning skal vise:
- Systemstatus
- Nøgletal
- Handlinger
- Historik
AdminUI (Settings) skal give:
- Liste over instanser
- Enable/disable
- Rotation af credentials
- Retentionstyring af auditlog pr. kunde
## 17. Migrations (plan)
1. `migrations/0XX_nextcloud_instances.sql`
2. `migrations/0XX_nextcloud_cache.sql`
3. `migrations/0XX_nextcloud_audit_log.sql` (partitioneret)
## 18. Næste skridt
1. Opret migrationsfiler
2. Implementer kryptering helper
3. Implementer servicelag
4. Implementer routere og schemas
5. Implementer UIfanen + adminUI
6. Implementer auditlog viewer/export

View File

@ -0,0 +1,386 @@
# Reminder System Implementation - BMC Hub
## Overview
The Reminder System for BMC Hub's Sag (Case) module provides flexible, multi-channel notification delivery with support for:
- **Time-based reminders**: Scheduled at specific times or recurring (daily, weekly, monthly)
- **Status-change triggered reminders**: Automatically triggered when case status changes
- **Multi-channel delivery**: Mattermost, Email, Frontend popup notifications
- **User preferences**: Global defaults with per-reminder overrides
- **Rate limiting**: Max 5 notifications per user per hour (global)
- **Smart scheduling**: Database triggers for status changes, APScheduler for time-based
## Architecture
### Database Schema
**4 Main Tables** (created in `migrations/096_reminder_system.sql`):
1. **`user_notification_preferences`** - User default notification settings
- Default channels (mattermost, email, frontend)
- Quiet hours configuration
- Email override option
2. **`sag_reminders`** - Reminder rules/templates
- Trigger configuration (status_change, deadline_approaching, time_based)
- Recipient configuration (user IDs or email addresses)
- Recurrence setup (once, daily, weekly, monthly)
- Scheduling info (scheduled_at, next_check_at)
3. **`sag_reminder_queue`** - Event queue from database triggers
- Holds events generated by status-change trigger
- Processing status tracking (pending, processing, sent, failed, rate_limited)
- Batch processed by scheduler job
4. **`sag_reminder_logs`** - Execution log
- Every notification sent/failed is logged
- User interactions (snooze, dismiss, acknowledge)
- Used for rate limiting verification
**Database Triggers**:
- `sag_status_change_reminder_trigger()` - Fires on status UPDATE, queues relevant reminders
**Helper Functions**:
- `check_reminder_rate_limit(user_id)` - Verifies user hasn't exceeded 5 per hour
**Helper Views**:
- `v_pending_reminders` - Time-based reminders ready to send
- `v_pending_reminder_queue` - Queued events ready for processing
### Backend Services
**`app/services/reminder_notification_service.py`**:
- Unified notification delivery via Mattermost, Email, Frontend
- Merges user preferences with per-reminder overrides
- Rate limit checking
- Event logging
- Email template rendering (Jinja2)
**`app/services/email_service.py`** (extended):
- Added `send_email()` async method using `aiosmtplib`
- SMTP configuration from `.env`
- Supports plain text + HTML bodies
- Safety flag: `REMINDERS_DRY_RUN=true` logs without sending
### API Endpoints
**User Preferences** (in `app/modules/sag/backend/reminders.py`):
```
GET /api/v1/users/me/notification-preferences
PATCH /api/v1/users/me/notification-preferences
```
**Reminder CRUD**:
```
GET /api/v1/sag/{sag_id}/reminders - List reminders for case
POST /api/v1/sag/{sag_id}/reminders - Create new reminder
PATCH /api/v1/sag/reminders/{reminder_id} - Update reminder
DELETE /api/v1/sag/reminders/{reminder_id} - Soft-delete reminder
```
**Reminder Interactions**:
```
POST /api/v1/sag/reminders/{reminder_id}/snooze - Snooze for X minutes
POST /api/v1/sag/reminders/{reminder_id}/dismiss - Permanently dismiss
GET /api/v1/reminders/pending/me - Get pending (for polling)
```
### Scheduler Job
**`app/jobs/check_reminders.py`**:
- Processes time-based reminders (`next_check_at <= NOW()`)
- Processes queued status-change events
- Calculates next recurrence (`daily` +24h, `weekly` +7d, `monthly` +30d)
- Respects rate limiting
**Registration in `main.py`**:
```python
backup_scheduler.scheduler.add_job(
func=check_reminders,
trigger=IntervalTrigger(minutes=5),
id='check_reminders',
name='Check Reminders',
max_instances=1
)
```
Runs **every 5 minutes** (configurable via `REMINDERS_CHECK_INTERVAL_MINUTES`)
### Frontend Notifications
**`static/js/notifications.js`**:
- Bootstrap 5 Toast-based notification popups
- Polls `/api/v1/reminders/pending/me` every 30 seconds
- Snooze presets: 15min, 30min, 1h, 4h, 1day, custom
- Dismiss action
- Auto-removes when hidden
- Pauses polling when tab not visible
**Integration**:
- Loaded in `app/shared/frontend/base.html`
- Auto-initializes on page load if user authenticated
- User ID extracted from JWT token
## Configuration
### Environment Variables
```env
# Master switches (default: disabled for safety)
REMINDERS_ENABLED=false
REMINDERS_EMAIL_ENABLED=false
REMINDERS_MATTERMOST_ENABLED=false
REMINDERS_DRY_RUN=true # Log without sending if true
# Scheduler settings
REMINDERS_CHECK_INTERVAL_MINUTES=5 # Frequency of reminder checks
REMINDERS_MAX_PER_USER_PER_HOUR=5 # Rate limit
REMINDERS_QUEUE_BATCH_SIZE=10 # Batch size for queue processing
# SMTP Configuration (for email reminders)
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=noreply@bmcnetworks.dk
EMAIL_SMTP_PASSWORD=<secret>
EMAIL_SMTP_USE_TLS=true
EMAIL_SMTP_FROM_ADDRESS=noreply@bmcnetworks.dk
EMAIL_SMTP_FROM_NAME=BMC Hub
```
### Pydantic Configuration
Added to `app/core/config.py`:
```python
REMINDERS_ENABLED: bool = False
REMINDERS_EMAIL_ENABLED: bool = False
REMINDERS_MATTERMOST_ENABLED: bool = False
REMINDERS_DRY_RUN: bool = True
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
REMINDERS_QUEUE_BATCH_SIZE: int = 10
EMAIL_SMTP_HOST: str = ""
EMAIL_SMTP_PORT: int = 587
EMAIL_SMTP_USER: str = ""
EMAIL_SMTP_PASSWORD: str = ""
EMAIL_SMTP_USE_TLS: bool = True
EMAIL_SMTP_FROM_ADDRESS: str = "noreply@bmcnetworks.dk"
EMAIL_SMTP_FROM_NAME: str = "BMC Hub"
```
## Safety Features
### Rate Limiting
- **Global per user**: Max 5 notifications per hour
- Checked before sending via `check_reminder_rate_limit(user_id)`
- Queued events marked as `rate_limited` if limit exceeded
### Dry Run Mode
- `REMINDERS_DRY_RUN=true` (default)
- All operations logged to console/logs
- No emails actually sent
- No Mattermost webhooks fired
- Useful for testing
### Soft Delete
- Reminders never hard-deleted from DB
- `deleted_at` timestamp + `is_active=false`
- Full audit trail preserved
### Per-Reminder Override
- Reminder can override user's default channels
- `override_user_preferences` flag
- Useful for critical reminders (urgent priority)
## Usage Examples
### Create a Status-Change Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
-H "Content-Type: application/json" \
-d {
"title": "Case entered In Progress",
"message": "Case #123 has moved to 'i_gang' status",
"priority": "high",
"trigger_type": "status_change",
"trigger_config": {"target_status": "i_gang"},
"recipient_user_ids": [1, 2],
"notify_mattermost": true,
"notify_email": true,
"recurrence_type": "once"
}
```
### Create a Scheduled Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
-H "Content-Type: application/json" \
-d {
"title": "Follow up needed",
"message": "Check in with customer",
"priority": "normal",
"trigger_type": "time_based",
"trigger_config": {},
"scheduled_at": "2026-02-10T14:30:00",
"recipient_user_ids": [1],
"recurrence_type": "once"
}
```
### Create a Daily Recurring Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/123/reminders \
-H "Content-Type: application/json" \
-d {
"title": "Daily status check",
"priority": "low",
"trigger_type": "time_based",
"trigger_config": {},
"scheduled_at": "2026-02-04T09:00:00",
"recipient_user_ids": [1],
"recurrence_type": "daily"
}
```
### Update User Preferences
```bash
curl -X PATCH http://localhost:8000/api/v1/users/me/notification-preferences \
-H "Content-Type: application/json" \
-d {
"notify_mattermost": true,
"notify_email": false,
"notify_frontend": true,
"quiet_hours_enabled": true,
"quiet_hours_start": "18:00",
"quiet_hours_end": "08:00"
}
```
## Testing Checklist
### Database
- [ ] Run migration: `docker-compose exec -T postgres psql -U bmc_hub -d bmc_hub -f /migrations/096_reminder_system.sql`
- [ ] Verify tables created: `SELECT * FROM sag_reminders LIMIT 0;`
- [ ] Verify trigger exists: `SELECT * FROM information_schema.triggers WHERE trigger_name LIKE 'sag%reminder%';`
### API
- [ ] Test create reminder endpoint
- [ ] Test list reminders endpoint
- [ ] Test update reminder endpoint
- [ ] Test snooze endpoint
- [ ] Test dismiss endpoint
- [ ] Test user preferences endpoints
### Scheduler
- [ ] Enable `REMINDERS_ENABLED=true` in `.env`
- [ ] Restart container
- [ ] Check logs for "Reminder job scheduled" message
- [ ] Verify job runs every 5 minutes: `✅ Checking for pending reminders...`
### Status Change Trigger
- [ ] Create reminder with `trigger_type: status_change`
- [ ] Change case status
- [ ] Verify event inserted in `sag_reminder_queue`
- [ ] Wait for scheduler to process
- [ ] Verify log entry in `sag_reminder_logs`
### Email Sending
- [ ] Configure SMTP in `.env`
- [ ] Set `REMINDERS_EMAIL_ENABLED=true`
- [ ] Set `REMINDERS_DRY_RUN=false`
- [ ] Create reminder with `notify_email=true`
- [ ] Verify email sent or check logs
### Frontend Popup
- [ ] Ensure `static/js/notifications.js` included in base.html
- [ ] Open browser console
- [ ] Log in to system
- [ ] Should see "✅ Reminder system initialized"
- [ ] Create a pending reminder
- [ ] Should see popup toast within 30 seconds
- [ ] Test snooze dropdown
- [ ] Test dismiss button
### Rate Limiting
- [ ] Create 6 reminders for user with trigger_type=time_based
- [ ] Manually trigger scheduler job
- [ ] Verify only 5 sent, 1 marked `rate_limited`
- [ ] Check `sag_reminder_logs` for status
## Deployment Notes
### Local Development
- All safety switches OFF by default (`_ENABLED=false`, `DRY_RUN=true`)
- No SMTP configured - reminders won't send
- No Mattermost webhook - notifications go to logs only
- Test via dry-run mode
### Production Deployment
1. Configure SMTP credentials in `.env`
2. Set `REMINDERS_ENABLED=true`
3. Set `REMINDERS_EMAIL_ENABLED=true` if using email
4. Set `REMINDERS_MATTERMOST_ENABLED=true` if using Mattermost
5. Set `REMINDERS_DRY_RUN=false` to actually send
6. Deploy with `docker-compose -f docker-compose.prod.yml up -d --build`
7. Monitor logs for errors: `docker-compose logs -f api`
## Files Modified/Created
### New Files
- `migrations/096_reminder_system.sql` - Database schema
- `app/services/reminder_notification_service.py` - Notification service
- `app/jobs/check_reminders.py` - Scheduler job
- `app/modules/sag/backend/reminders.py` - API endpoints
- `static/js/notifications.js` - Frontend notification system
- `templates/emails/reminder.html` - Email template
### Modified Files
- `app/core/config.py` - Added reminder settings
- `app/services/email_service.py` - Added `send_email()` method
- `main.py` - Imported reminders router, registered scheduler job
- `app/shared/frontend/base.html` - Added notifications.js script tag
- `requirements.txt` - Added `aiosmtplib` dependency
- `.env` - Added reminder configuration
## Troubleshooting
### Reminders not sending
1. Check `REMINDERS_ENABLED=true` in `.env`
2. Check scheduler logs: "Reminder check complete"
3. Verify `next_check_at` <= NOW() for reminders
4. Check rate limit: count in `sag_reminder_logs` last hour
### Frontend popups not showing
1. Check browser console for errors
2. Verify JWT token contains `sub` (user_id)
3. Check `GET /api/v1/reminders/pending/me` returns data
4. Ensure `static/js/notifications.js` loaded
### Email not sending
1. Verify SMTP credentials in `.env`
2. Check `REMINDERS_EMAIL_ENABLED=true`
3. Check `REMINDERS_DRY_RUN=false`
4. Review application logs for SMTP errors
5. Test SMTP connection separately
### Database trigger not working
1. Verify migration applied successfully
2. Check `sag_status_change_reminder_trigger_exec` trigger exists
3. Update case status manually
4. Check `sag_reminder_queue` for new events
5. Review PostgreSQL logs if needed
## Future Enhancements
- [ ] Escalation rules (auto-escalate if not acknowledged)
- [ ] SMS/WhatsApp integration (Twilio)
- [ ] Calendar integration (iCal export)
- [ ] User notification history/statistics
- [ ] Webhook support for external services
- [ ] AI-powered reminder suggestions
- [ ] Mobile app push notifications

View File

@ -0,0 +1,285 @@
# Reminder System Quick Start
## 1. Apply Database Migration
```bash
# Connect to database and run migration
docker-compose exec -T postgres psql -U bmc_hub -d bmc_hub << EOF
$(cat migrations/096_reminder_system.sql)
EOF
```
Or via psql client:
```bash
psql -h localhost -U bmc_hub -d bmc_hub -f migrations/096_reminder_system.sql
```
Verify tables created:
```sql
\d sag_reminders
\d sag_reminder_logs
\d user_notification_preferences
\d sag_reminder_queue
```
## 2. Install Dependencies
```bash
pip install aiosmtplib==3.0.2
# Or
pip install -r requirements.txt
```
## 3. Configure Environment
Edit `.env`:
```env
# ✅ Keep these disabled for local development
REMINDERS_ENABLED=false
REMINDERS_EMAIL_ENABLED=false
REMINDERS_DRY_RUN=true
# 📧 SMTP Configuration (optional for local testing)
EMAIL_SMTP_HOST=smtp.gmail.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=your-email@gmail.com
EMAIL_SMTP_PASSWORD=your-app-password
# 💬 Mattermost (optional)
MATTERMOST_ENABLED=true
MATTERMOST_WEBHOOK_URL=https://mattermost.example.com/hooks/xxxxx
MATTERMOST_CHANNEL=reminders
```
## 4. Restart Application
```bash
docker-compose restart api
```
Check logs:
```bash
docker-compose logs -f api
```
Should see:
```
✅ Reminder job scheduled (every 5 minutes)
```
## 5. Test Frontend Notification System
1. Open http://localhost:8000/
2. Log in
3. Open browser console (F12)
4. Should see: `✅ Reminder system initialized`
5. Create a test reminder via database:
```sql
INSERT INTO sag_reminders (
sag_id, title, message, priority,
trigger_type, trigger_config,
recipient_user_ids, recipient_emails,
recurrence_type, is_active, created_by_user_id,
scheduled_at, next_check_at
) VALUES (
1, -- Replace with actual case ID
'Test Reminder',
'This is a test reminder',
'high',
'time_based',
'{}',
'{1}', -- Replace with actual user ID
'{}',
'once',
true,
1, -- Replace with your user ID
now(),
now()
);
```
6. Wait ~30 seconds or manually call: `GET /api/v1/reminders/pending/me?user_id=1`
7. Should see popup toast notification
## 6. Test API Endpoints
### Get User Preferences
```bash
curl -X GET http://localhost:8000/api/v1/users/me/notification-preferences?user_id=1
```
### Update User Preferences
```bash
curl -X PATCH http://localhost:8000/api/v1/users/me/notification-preferences?user_id=1 \
-H "Content-Type: application/json" \
-d {
"notify_frontend": true,
"notify_email": false,
"notify_mattermost": true
}
```
### Create Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/1/reminders?user_id=1 \
-H "Content-Type: application/json" \
-d {
"title": "Test Reminder",
"message": "This is a test",
"priority": "normal",
"trigger_type": "time_based",
"trigger_config": {},
"recipient_user_ids": [1],
"recurrence_type": "once"
}
```
### List Reminders
```bash
curl -X GET http://localhost:8000/api/v1/sag/1/reminders
```
### Snooze Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/reminders/1/snooze?user_id=1 \
-H "Content-Type: application/json" \
-d { "duration_minutes": 30 }
```
### Dismiss Reminder
```bash
curl -X POST http://localhost:8000/api/v1/sag/reminders/1/dismiss?user_id=1 \
-H "Content-Type: application/json" \
-d { "reason": "Already handled" }
```
## 7. Test Status Change Trigger
1. Create a reminder with status_change trigger:
```sql
INSERT INTO sag_reminders (
sag_id, title, message, priority,
trigger_type, trigger_config,
recipient_user_ids, recipient_emails,
recurrence_type, is_active, created_by_user_id
) VALUES (
1, -- Your test case
'Case entered In Progress',
'Case has been moved to "i_gang" status',
'high',
'status_change',
'{"target_status": "i_gang"}', -- Trigger when status changes to "i_gang"
'{1}',
'{}',
'once',
true,
1
);
```
2. Update case status:
```sql
UPDATE sag_sager SET status = 'i_gang' WHERE id = 1;
```
3. Check queue:
```sql
SELECT * FROM sag_reminder_queue WHERE status = 'pending';
```
4. Should see pending event. Wait for scheduler to process (next 5-min interval)
5. Check logs:
```sql
SELECT * FROM sag_reminder_logs ORDER BY triggered_at DESC LIMIT 5;
```
## 8. Enable Production Features (When Ready)
To actually send reminders:
```env
REMINDERS_ENABLED=true
REMINDERS_DRY_RUN=false
# Enable channels you want
REMINDERS_EMAIL_ENABLED=true
REMINDERS_MATTERMOST_ENABLED=true
```
Then restart and test again.
## 9. Monitor Reminder Execution
### View Pending Reminders
```sql
SELECT * FROM v_pending_reminders LIMIT 5;
```
### View Queue Status
```sql
SELECT id, reminder_id, status, error_message
FROM sag_reminder_queue
WHERE status IN ('pending', 'failed')
ORDER BY created_at DESC
LIMIT 10;
```
### View Notification Logs
```sql
SELECT id, reminder_id, sag_id, status, triggered_at, channels_used
FROM sag_reminder_logs
ORDER BY triggered_at DESC
LIMIT 20;
```
### Check Rate Limiting
```sql
SELECT user_id, COUNT(*) as count, MAX(triggered_at) as last_sent
FROM sag_reminder_logs
WHERE status = 'sent' AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
GROUP BY user_id
ORDER BY count DESC;
```
## Common Issues
### "Reminder system not initialized"
- User not authenticated
- Check that JWT token is valid
- Check browser console for auth errors
### Reminders not appearing
- Check `REMINDERS_ENABLED=true`
- Check `next_check_at <= NOW()`
- Check `recipient_user_ids` includes current user
- Verify polling API returns data: `GET /api/v1/reminders/pending/me`
### Email not sending
- Check `REMINDERS_EMAIL_ENABLED=true`
- Check SMTP credentials in `.env`
- Check `REMINDERS_DRY_RUN=false`
- Review application logs for SMTP errors
- Try sending with `REMINDERS_DRY_RUN=true` first (logs only)
### Status trigger not firing
- Verify case ID exists
- Check trigger_config matches: `{"target_status": "expected_status"}`
- Manually check: `UPDATE sag_sager SET status = 'target_status'`
- Query `sag_reminder_queue` for new events
## Next Steps
1. ✅ Database migration applied
2. ✅ Environment configured
3. ✅ Frontend notifications working
4. ✅ API endpoints tested
5. → Configure email/Mattermost credentials
6. → Enable production features
7. → Monitor logs and metrics
8. → Set up alerting for failures
See [REMINDER_SYSTEM_IMPLEMENTATION.md](REMINDER_SYSTEM_IMPLEMENTATION.md) for detailed documentation.

View File

@ -1,253 +1,5 @@
# Implementeringsplan: Sag-modulet (Case Module)
## Oversigt Hvad er “Sag”?
**Sag-modulet** er hjertet i BMC Hubs relation- og proces-styringssystem.
I stedet for separate systemer for tickets, opgaver og ordrer findes der én universel entitet: **en Sag**.
### Kerneidéen (meget vigtig!)
> **Der er kun én ting: en Sag.**
> Tickets, opgaver og ordrer er blot sager med forskellige relationer, tags og moduler.
---
## Eksempler (samme datatype forskellige relationer)
1. **Kunde ringer og skal have ny skærm**
- Dette er en Sag
- Tags: `support`, `urgent`
- Ansvarlig: Support
- Status: åben
2. **Indkøb af skærm hos leverandør**
- Dette er også en Sag
- Tags: `indkøb`
- Relation: afledt fra kundesagen
- Ansvarlig: Indkøb
3. **Ompakning og afsendelse**
- Dette er også en Sag
- Tags: `ompakning`
- Relation: afledt fra indkøbssagen
- Ansvarlig: Lager
- Deadline: i dag
Alle tre er samme datatype i databasen.
Forskellen er udelukkende:
- hvilke tags sagen har
- hvilke relationer den indgår i
- hvem der er ansvarlig
- hvilke moduler der er koblet på
---
## Hvad betyder det for systemet?
**Uden Sag-modulet**
- Separate tickets, tasks og ordrer
- Kompleks synkronisering
- Dubleret data
- Svær historik
**Med Sag-modulet**
- Ét API: `/api/v1/cases`
- Ét UI-område: Sager
- Relationer er førsteklasses data
- Tags styrer processer
- Sager kan vokse og forgrene sig
- Alt er søgbart på tværs
---
## Teknisk arkitektur
### Databasestruktur
Sag-modulet består af tre kerne-tabeller (prefix `sag_`).
---
### **sag_sager Hovedtabel**
```
id (primary key)
titel (VARCHAR)
beskrivelse (TEXT)
template_key (VARCHAR, NULL)
- Bruges kun ved oprettelse
- Har ingen forretningslogik efterfølgende
status (VARCHAR)
- Tilladte værdier: 'åben', 'lukket'
customer_id (foreign key, NULLABLE)
ansvarlig_bruger_id (foreign key, NULLABLE)
created_by_user_id (foreign key, NOT NULL)
deadline (TIMESTAMP, NULLABLE)
created_at (TIMESTAMP)
updated_at (TIMESTAMP)
deleted_at (TIMESTAMP) -- soft-delete
```
**Vigtige regler**
- status er binær (åben/lukket)
- Al proceslogik udtrykkes via tags
- `template_key` må aldrig bruges til business logic
---
### **sag_relationer Relationer mellem sager**
```
id (primary key)
kilde_sag_id (foreign key)
målsag_id (foreign key)
relationstype (VARCHAR)
- f.eks. 'derived', 'blocks', 'executes'
created_at (TIMESTAMP)
deleted_at (TIMESTAMP)
```
**Principper**
- Relationer er retningsbestemte
- Relationer er transitive
- Der oprettes kun én relation pr. sammenhæng
- Begreber som “forælder” og “barn” er UI-views, ikke data
**Eksempel (kæde med flere led)**
Sag A → Sag B → Sag C → Sag D
Databasen indeholder tre relationer intet mere.
---
### **sag_tags Proces og kategorisering**
```
id (primary key)
sag_id (foreign key)
tag_navn (VARCHAR)
state (VARCHAR DEFAULT 'open')
- 'open' = ikke færdigbehandlet
- 'closed' = færdigbehandlet
closed_at (TIMESTAMP, NULLABLE)
created_at (TIMESTAMP)
deleted_at (TIMESTAMP)
```
**Betydning**
- Tags repræsenterer arbejde der skal udføres
- Et tag slettes ikke, når det er færdigt det lukkes
- `deleted_at` bruges kun til teknisk fjernelse / rollback
---
## API-endpoints
**Cases**
- `GET /api/v1/cases`
- `POST /api/v1/cases`
- `GET /api/v1/cases/{id}`
- `PATCH /api/v1/cases/{id}`
- `DELETE /api/v1/cases/{id}` (soft-delete)
**Relationer**
- `GET /api/v1/cases/{id}/relations`
- `POST /api/v1/cases/{id}/relations`
- `DELETE /api/v1/cases/{id}/relations/{relation_id}`
**Tags**
- `GET /api/v1/cases/{id}/tags`
- `POST /api/v1/cases/{id}/tags`
- `PATCH /api/v1/cases/{id}/tags/{tag_id}` (luk tag)
- `DELETE /api/v1/cases/{id}/tags/{tag_id}` (soft-delete)
Alle SELECT-queries skal filtrere på:
```sql
WHERE deleted_at IS NULL
```
---
## UI-koncept
**Sag-liste** (`/cases`)
- Liste over alle relevante sager
- Filtre:
- mine sager
- åbne sager
- sager med tag
- Sortering:
- deadline
- oprettet dato
**Sag-detalje** (`/cases/{id}`)
- Titel, status, deadline
- Tags (åbne vises tydeligt)
- Relaterede sager (afledte, blokerende, udførende)
- Ansvarlig
- Klar navigation mellem sager
---
## Implementeringsprincipper (MÅ IKKE BRYDES)
1. Der findes kun én entitet: Sag
2. `template_key` bruges kun ved oprettelse
3. Status er binær proces styres via tags
4. Tags lukkes, de slettes ikke
5. Relationer er data, ikke implicit logik
6. Alle sletninger er soft-deletes
7. Hvis du tror, du mangler en ny tabel → brug en relation
---
## Tidsestimat
- Database + migration: 30 min
- Backend API: 12 timer
- Frontend (liste + detalje): 12 timer
- Test + dokumentation: 1 time
**Total: 46 timer**
---
## TL;DR for udvikler
- Alt er en sag
- Forskelle = tags + relationer
- Ingen tickets, ingen tasks, ingen orders
- Relationer danner kæder
- Tags styrer arbejdet
- Status er kun åben/lukket
---
Hvis du vil næste skridt, kan vi:
- lave SQL CTE-eksempler til traversal
- definere første reference-workflow
- skrive README “Architectural Laws”
- eller lave et diagram, der matcher præcis dette
Men modellen?
Den er nu færdig og sund.# Implementeringsplan: Sag-modulet (Case Module)
## Oversigt - Hvad er "Sag"?
**Sag-modulet** er hjertet i BMC Hub's nye relation- og proces-styringssystem. I stedet for at have separate systemer for "tickets", "opgaver" og "ordrer", har vi én universel entitet: **en Sag**.

View File

@ -0,0 +1,138 @@
# Sales and Aggregation Implementation Plan
## 1. Data Model Proposals
### 1.1 `sag_salgsvarer` Improvements
We will enhance the existing `sag_salgsvarer` table to support full billing requirements, margin calculation, and product linking.
**Current Fields:**
- `id`, `sag_id`, `type` (sale), `description`, `quantity`, `unit`, `unit_price`, `amount`, `currency`, `status`, `line_date`
**Proposed Additions:**
| Field | Type | Description |
|-------|------|-------------|
| `product_id` | INT (FK) | Link to new `products` catalog (nullable) |
| `cost_price` | DECIMAL | For calculating Gross Profit (DB) per line |
| `discount_percent` | DECIMAL | Discount given on standard price |
| `vat_rate` | DECIMAL | Default 25.00 for DK |
| `supplier_id` | INT (FK) | Reference to `vendors` table (if exists) or string |
| `billing_method` | VARCHAR | `invoice`, `prepaid`, `internal` (matches `tmodule_times`) |
| `is_subscription` | BOOLEAN | If true, pushes to subscription system instead of one-off invoice |
### 1.2 New `products` Table
A central catalog for standard items (Hardware, Licenses, Fees) to speed up entry and standardize reporting.
```sql
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(50), -- 'hardware', 'license', 'consulting'
cost_price DECIMAL(10,2),
sales_price DECIMAL(10,2), -- Suggested RRP
unit VARCHAR(20) DEFAULT 'stk',
supplier_id INTEGER,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 1.3 Aggregation Rules
The system will distinguish between **Direct** costs/revenue (on the case itself) and **Aggregated** (from sub-cases).
- **Direct Revenue** = (Sum of `sag_salgsvarer.amount`) + (Sum of `tmodule_times` where `billable=true` * `hourly_rate`)
- **Total Revenue** = Direct Revenue + Sum(Child Cases Total Revenue)
## 2. UI Structure for "Varer" (Items) Tab
The "Varer" tab on the Case Detail page will have a split entry/view design.
### 2.1 Top Section: Quick Add
A horizontal form to quickly add lines:
- **Product Lookup**: Searchable dropdown.
- **Manual Override**: Description field auto-filled but editable.
- **Numbers**: Qty, Unit, Price.
- **Result**: Total Price auto-calculated.
- **Action**: "Add Line" button.
### 2.2 Main List: Combined Billing View
A unified table showing everything billable on this case:
| Type | Date | Description | Qty | Price | Disc | Total | Status | Actions |
|------|------|-------------|-----|-------|------|-------|--------|---------|
| 🕒 Time | 02-02 | Konsulentbistand | 2.5 | 1200 | 0% | 3000 | `Approved` | [Edit Time] |
| 📦 Item | 02-02 | Ubiquiti Switch | 1 | 2500 | 10% | 2250 | `Draft` | [Edit] [Del] |
| 🔄 Sub | -- | *Sub-case: Installation i Aarhus* | -- | -- | -- | 5400 | `Calculated` | [Go to Case] |
### 2.3 Summary Footer (Sticky)
- **Materials**: Total of Items.
- **Labor**: Total of Time.
- **Sub-cases**: Total of Children.
- **Grand Total**: Ex VAT and Inc VAT.
- **Margin**: (Sales - Cost) / Sales %.
- **Action**: "Create Invoice Proposal" button.
## 3. Aggregation Logic (Recursive)
We will implement a `SalesAggregator` service that traverses the case tree.
**Algorithm:**
1. **Inputs**: `case_id`.
2. **Fetch Direct Items**: Query `sag_salgsvarer` for this case.
3. **Fetch Direct Time**: Query `tmodule_times` for this case. Calculate value using `hourly_rate`.
4. **Fetch Children**: Query `sag_relationer` (or `sag_sager` parent_id) to find children.
5. **Recursion**: For each child, recursively call `get_case_totals(child_id)`.
6. **Summation**: Return object with `own_total` and `sub_total`.
**Python Service Method:**
```python
def get_case_financials(case_id: int) -> CaseFinancials:
# 1. Own items
items = db.query(SagSalgsvarer).filter(sag_id=case_id).all()
item_total = sum(i.amount for i in items)
item_cost = sum(i.cost_price * i.quantity for i in items)
# 2. Own time
times = db.query(TmoduleTimes).filter(case_id=case_id, billable=True).all()
time_total = sum(t.original_hours * get_hourly_rate(case_id) for t in times)
# 3. Children
children = db.query(SagRelationer).filter(kilde_sag_id=case_id).all()
child_total = 0
child_cost = 0
for child in children:
child_fin = get_case_financials(child.malsag_id)
child_total += child_fin.total_revenue
child_cost += child_fin.total_cost
return CaseFinancials(
revenue=item_total + time_total + child_total,
cost=item_cost + child_cost,
# ... breakdown fields
)
```
## 4. Preparation for Billing (Status Flow)
We define a strict lifecycle for items to prevent double-billing.
### 4.1 Status Lifecycle for Items (`sag_salgsvarer`)
1. **`draft`**: Default. Editable. Included in Preliminary Total.
2. **`approved`**: Locked by Project Manager. Ready for Finance.
- *Action*: Lock for Billing.
- *Effect*: Rows become read-only.
3. **`billed`**: Processed by Finance (exported to e-conomic).
- *Action*: Integration Job runs.
- *Effect*: Linked to `invoice_id` (new column).
### 4.2 Billing Triggers
- **Partial Billing**: Checkbox select specific `approved` lines -> Create Invoice Draft.
- **Full Billing**: Bill All Approved -> Generates invoice for all `approved` items and time.
- **Aggregation Billing**:
- The invoicing engine must accept a `case_structure` to decide if it prints one line per sub-case or expands all lines. Default to **One line per sub-case** for cleanliness.
### 4.3 Validation
- Ensure all Approved items have a valid `cost_price` (warn if 0).
- Ensure Time Registrations are `approved` before they can be billed.

View File

@ -0,0 +1,201 @@
# Service Contract Migration Wizard - Implementation Summary
## ✅ What Was Built
A step-by-step wizard that migrates Vtiger service contracts to Hub systems:
- **Cases** → Archived to `tticket_archived_tickets`
- **Timelogs** → Transferred as klippekort top-ups (prepaid card hours)
Features:
- ✅ Dry-run toggle (preview mode without database writes)
- ✅ Step-by-step review of each case/timelog
- ✅ Manual klippekort selection per timelog
- ✅ Progress tracking and summary report
- ✅ Read-only from Vtiger (no writes back to Vtiger)
## 🎯 Files Created/Modified
### New Files:
1. **[app/timetracking/backend/service_contract_wizard.py](app/timetracking/backend/service_contract_wizard.py)** (275 lines)
- Core wizard service with all business logic
- Methods: `load_contract_detailed_data()`, `archive_case()`, `transfer_timelog_to_klippekort()`, `get_wizard_summary()`
- Dry-run support built into each method
2. **[app/timetracking/frontend/service_contract_wizard.html](app/timetracking/frontend/service_contract_wizard.html)** (650 lines)
- Complete wizard UI with Nordic design
- Contract dropdown selector
- Progress bar with live counters
- Current item display with conditional klippekort dropdown
- Summary report on completion
### Modified Files:
1. **[app/services/vtiger_service.py](app/services/vtiger_service.py)** (+65 lines)
- Added `get_service_contracts(account_id=None)` - Fetch active service contracts
- Added `get_service_contract_cases(contract_id)` - Fetch cases linked to contract
- Added `get_service_contract_timelogs(contract_id)` - Fetch timelogs linked to contract
2. **[app/timetracking/backend/models.py](app/timetracking/backend/models.py)** (+70 lines)
- `ServiceContractBase` - Base contract model
- `ServiceContractItem` - Single case/timelog item
- `ServiceContractWizardData` - Complete contract data for wizard
- `ServiceContractWizardAction` - Action result (archive/transfer)
- `ServiceContractWizardSummary` - Final summary
- `TimologTransferRequest` - Request model for timelog transfer
- `TimologTransferResult` - Transfer result
3. **[app/timetracking/backend/router.py](app/timetracking/backend/router.py)** (+180 lines)
- `GET /api/v1/timetracking/service-contracts` - List contracts dropdown
- `POST /api/v1/timetracking/service-contracts/wizard/load` - Load contract data
- `POST /api/v1/timetracking/service-contracts/wizard/archive-case` - Archive case
- `POST /api/v1/timetracking/service-contracts/wizard/transfer-timelog` - Transfer timelog
- `GET /api/v1/timetracking/service-contracts/wizard/customer-cards/{customer_id}` - Get klippekort
4. **[app/timetracking/frontend/views.py](app/timetracking/frontend/views.py)** (+5 lines)
- Added frontend route: `/timetracking/service-contract-wizard`
## 🚀 How to Test
### 1. Start the API
```bash
docker-compose up -d api
docker-compose logs -f api
```
### 2. Access the Wizard
```
http://localhost:8000/timetracking/service-contract-wizard
```
### 3. Dry-Run Mode (Recommended First)
1. Check the "Preview Mode" checkbox at top (enabled by default)
2. Select a service contract from dropdown
3. Review each case/timelog and click "Gem & Næste"
4. No data is written to database in dry-run mode
5. Review summary report to see what WOULD be changed
### 4. Live Mode
1. **Uncheck** "Preview Mode" checkbox
2. Select same or different contract
3. Process items - changes ARE committed to database
4. Cases are exported to `tticket_archived_tickets`
5. Timelogs are added to klippekort via top-up transaction
## 🔍 Database Changes
### Dryrun Mode:
- All operations are **logged** but **NOT committed**
- Queries are constructed but rolled back
- UI shows what WOULD happen
### Live Mode:
- Cases are inserted into `tticket_archived_tickets` with:
- `source_system = 'vtiger_service_contract'`
- `external_id = vtiger case ID`
- Full case data in `raw_data` JSONB field
- Timelogs create transactions in `tticket_prepaid_transactions` with:
- `transaction_type = 'top_up'`
- Hours added to klippekort `purchased_hours`
- Description references vTiger timelog
## 📊 Data Flow
```
Vtiger Service Contract
SelectContract (dropdown)
LoadContractData
├─ Cases → Archive to tticket_archived_tickets
└─ Timelogs → Transfer to klippekort (top-up)
WizardProgress (step-by-step review)
├─ [DRY RUN] Preview mode (no DB writes)
└─ [LIVE] Commit to database
Summary Report
├─ Cases archived: N
├─ Hours transferred: N
└─ Failed items: N
```
## 🔐 Safety Features
1. **Dry-run mode enabled by default** - Users see what WOULD happen first
2. **Customer linking** - Looks up Hub customer ID from vTiger account
3. **Klippekort validation** - Verifies card belongs to customer before transfer
4. **Read-only from Vtiger** - No writes back to Vtiger (only reads)
5. **Transaction handling** - Each operation is atomic
6. **Audit logging** - All actions logged with DRY RUN/COMMITTED markers
## 🛠️ Technical Details
### Wizard Service (`ServiceContractWizardService`)
- Stateless service class
- All methods are static
- Database operations via `execute_query()` helpers
- Klippekort transfers via `KlippekortService.top_up_card()`
### Frontend UI
- Vanilla JavaScript (no frameworks)
- Nordic Top design system (matches existing Hub UI)
- Responsive Bootstrap 5 grid
- Real-time progress updates
- Conditional klippekort dropdown (only for timelogs)
### API Endpoints
- RESTful architecture
- All endpoints support `dry_run` query parameter
- Request/response models use Pydantic validation
- Comprehensive error handling with HTTPException
## 📝 Logging Output
### Dry-Run Mode:
```
🔍 DRY RUN: Would archive case 1x123: 'Case Title'
🔍 DRY RUN: Would transfer 5h to card 42 from timelog 2x456
```
### Live Mode:
```
✅ Archived case 1x123 to tticket_archived_tickets (ID: 1234)
✅ Transferred 5h from timelog 2x456 to card 42
```
## 🐛 Troubleshooting
### Contracts dropdown is empty:
- Verify Vtiger integration is configured (VTIGER_URL, VTIGER_USERNAME, VTIGER_API_KEY in .env)
- Check vTiger has active ServiceContracts
- Check API user has access to ServiceContracts module
### Klippekort dropdown empty for customer:
- Customer may not have any active prepaid cards
- Or customer is not linked between Vtiger account and Hub customer
- Create a prepaid card for the customer first
### Dry-run mode not working:
- Ensure checkbox is checked
- Check browser console for JavaScript errors
- Verify `dry_run` parameter is passed to API endpoints
## 📋 Next Steps
1. **Test with sample data** - Create test service contract in Vtiger
2. **Verify database changes** - Query `tticket_archived_tickets` post-migration
3. **Monitor klippekort** - Check `tticket_prepaid_transactions` for top-up entries
4. **Adjust as needed** - Tweak timelog filtering or case mapping based on results
## 🔗 Related Components
- **Klippekort System**: [app/ticket/backend/klippekort_service.py](app/ticket/backend/klippekort_service.py)
- **Archive System**: Database table `tticket_archived_tickets`
- **Timetracking Module**: [app/timetracking/](app/timetracking/)
- **Vtiger Integration**: [app/services/vtiger_service.py](app/services/vtiger_service.py)
---
**Status**: ✅ Ready for testing and deployment
**Estimated Time to Test**: 15-20 minutes
**Database Dependency**: PostgreSQL (no migrations needed - uses existing tables)

View File

@ -0,0 +1 @@
"""Alert Notes Module"""

View File

@ -0,0 +1,4 @@
"""Alert Notes Backend Module"""
from app.alert_notes.backend.router import router
__all__ = ["router"]

View File

@ -0,0 +1,515 @@
"""
Alert Notes Router
API endpoints for contextual customer/contact alert system
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import List, Optional, Dict
import logging
from datetime import datetime
from app.core.database import execute_query, execute_update
from app.core.auth_dependencies import require_permission, get_current_user
from app.alert_notes.backend.schemas import (
AlertNoteCreate, AlertNoteUpdate, AlertNoteFull, AlertNoteCheck,
AlertNoteRestriction, AlertNoteAcknowledgement, EntityType, Severity
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _check_user_can_handle(alert_id: int, current_user: dict) -> bool:
"""
Check if current user is allowed to handle the entity based on restrictions.
Returns True if no restrictions exist OR user matches a restriction.
"""
# Superadmins bypass restrictions
if current_user.get("is_superadmin"):
return True
# Get restrictions for this alert
restrictions = execute_query(
"""
SELECT restriction_type, restriction_id
FROM alert_note_restrictions
WHERE alert_note_id = %s
""",
(alert_id,)
)
# No restrictions = everyone can handle
if not restrictions:
return True
user_id = current_user["id"]
# Get user's group IDs
user_groups = execute_query(
"SELECT group_id FROM user_groups WHERE user_id = %s",
(user_id,)
)
user_group_ids = [g["group_id"] for g in user_groups]
# Check if user matches any restriction
for restriction in restrictions:
if restriction["restriction_type"] == "user" and restriction["restriction_id"] == user_id:
return True
if restriction["restriction_type"] == "group" and restriction["restriction_id"] in user_group_ids:
return True
return False
def _get_entity_name(entity_type: str, entity_id: int) -> Optional[str]:
"""Get the name of the entity (customer or contact)"""
if entity_type == "customer":
result = execute_query(
"SELECT name FROM customers WHERE id = %s",
(entity_id,)
)
return result[0]["name"] if result else None
elif entity_type == "contact":
result = execute_query(
"SELECT first_name, last_name FROM contacts WHERE id = %s",
(entity_id,)
)
if result:
return f"{result[0]['first_name']} {result[0]['last_name']}"
return None
def _get_alert_with_relations(alert_id: int, current_user: dict) -> Optional[Dict]:
"""Get alert note with all its relations"""
# Get main alert
alerts = execute_query(
"""
SELECT an.*, u.full_name as created_by_user_name
FROM alert_notes an
LEFT JOIN users u ON an.created_by_user_id = u.user_id
WHERE an.id = %s
""",
(alert_id,)
)
if not alerts:
return None
alert = dict(alerts[0])
# Get entity name
alert["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"])
# Get restrictions
restrictions = execute_query(
"""
SELECT anr.*,
CASE
WHEN anr.restriction_type = 'group' THEN g.name
WHEN anr.restriction_type = 'user' THEN u.full_name
END as restriction_name
FROM alert_note_restrictions anr
LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id
LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id
WHERE anr.alert_note_id = %s
""",
(alert_id,)
)
alert["restrictions"] = restrictions
# Get acknowledgements
acknowledgements = execute_query(
"""
SELECT ana.*, u.full_name as user_name
FROM alert_note_acknowledgements ana
LEFT JOIN users u ON ana.user_id = u.user_id
WHERE ana.alert_note_id = %s
ORDER BY ana.acknowledged_at DESC
""",
(alert_id,)
)
alert["acknowledgements"] = acknowledgements
return alert
@router.get("/alert-notes/check", response_model=AlertNoteCheck)
async def check_alerts(
entity_type: EntityType = Query(..., description="Entity type (customer/contact)"),
entity_id: int = Query(..., description="Entity ID"),
current_user: dict = Depends(get_current_user)
):
"""
Check if there are active alert notes for a specific entity.
Returns alerts that the current user is allowed to see based on restrictions.
"""
# Get active alerts for this entity
alerts = execute_query(
"""
SELECT an.*, u.full_name as created_by_user_name
FROM alert_notes an
LEFT JOIN users u ON an.created_by_user_id = u.user_id
WHERE an.entity_type = %s
AND an.entity_id = %s
AND an.active = TRUE
ORDER BY
CASE an.severity
WHEN 'critical' THEN 1
WHEN 'warning' THEN 2
WHEN 'info' THEN 3
END,
an.created_at DESC
""",
(entity_type.value, entity_id)
)
if not alerts:
return AlertNoteCheck(
has_alerts=False,
alerts=[],
user_can_handle=True,
user_has_acknowledged=False
)
# Enrich alerts with relations
enriched_alerts = []
for alert in alerts:
alert_dict = dict(alert)
alert_dict["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"])
# Get restrictions
restrictions = execute_query(
"""
SELECT anr.*,
CASE
WHEN anr.restriction_type = 'group' THEN g.name
WHEN anr.restriction_type = 'user' THEN u.full_name
END as restriction_name
FROM alert_note_restrictions anr
LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id
LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id
WHERE anr.alert_note_id = %s
""",
(alert["id"],)
)
alert_dict["restrictions"] = restrictions
# Get acknowledgements
acknowledgements = execute_query(
"""
SELECT ana.*, u.full_name as user_name
FROM alert_note_acknowledgements ana
LEFT JOIN users u ON ana.user_id = u.user_id
WHERE ana.alert_note_id = %s
ORDER BY ana.acknowledged_at DESC
""",
(alert["id"],)
)
alert_dict["acknowledgements"] = acknowledgements
enriched_alerts.append(alert_dict)
# Check if user can handle based on restrictions
user_can_handle = all(_check_user_can_handle(a["id"], current_user) for a in alerts)
# Check if user has acknowledged all alerts that require it
user_id = current_user["id"]
user_has_acknowledged = True
for alert in alerts:
if alert["requires_acknowledgement"]:
ack = execute_query(
"SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s",
(alert["id"], user_id)
)
if not ack:
user_has_acknowledged = False
break
return AlertNoteCheck(
has_alerts=True,
alerts=enriched_alerts,
user_can_handle=user_can_handle,
user_has_acknowledged=user_has_acknowledged
)
@router.post("/alert-notes/{alert_id}/acknowledge")
async def acknowledge_alert(
alert_id: int,
current_user: dict = Depends(get_current_user)
):
"""
Mark an alert note as acknowledged by the current user.
"""
# Check if alert exists
alert = execute_query(
"SELECT id, active FROM alert_notes WHERE id = %s",
(alert_id,)
)
if not alert:
raise HTTPException(status_code=404, detail="Alert note not found")
if not alert[0]["active"]:
raise HTTPException(status_code=400, detail="Alert note is not active")
user_id = current_user["id"]
# Check if already acknowledged
existing = execute_query(
"SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s",
(alert_id, user_id)
)
if existing:
return {"status": "already_acknowledged", "alert_id": alert_id}
# Insert acknowledgement
execute_update(
"""
INSERT INTO alert_note_acknowledgements (alert_note_id, user_id)
VALUES (%s, %s)
""",
(alert_id, user_id)
)
logger.info(f"Alert {alert_id} acknowledged by user {user_id}")
return {"status": "acknowledged", "alert_id": alert_id}
@router.get("/alert-notes", response_model=List[AlertNoteFull])
async def list_alerts(
entity_type: Optional[EntityType] = Query(None),
entity_id: Optional[int] = Query(None),
severity: Optional[Severity] = Query(None),
active: Optional[bool] = Query(None),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(require_permission("alert_notes.view"))
):
"""
List alert notes with filtering (admin endpoint).
Requires alert_notes.view permission.
"""
conditions = []
params = []
if entity_type:
conditions.append("an.entity_type = %s")
params.append(entity_type.value)
if entity_id:
conditions.append("an.entity_id = %s")
params.append(entity_id)
if severity:
conditions.append("an.severity = %s")
params.append(severity.value)
if active is not None:
conditions.append("an.active = %s")
params.append(active)
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""
SELECT an.*, u.full_name as created_by_user_name
FROM alert_notes an
LEFT JOIN users u ON an.created_by_user_id = u.user_id
{where_clause}
ORDER BY an.created_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
alerts = execute_query(query, tuple(params))
# Enrich with relations
enriched_alerts = []
for alert in alerts:
alert_full = _get_alert_with_relations(alert["id"], current_user)
if alert_full:
enriched_alerts.append(alert_full)
return enriched_alerts
@router.post("/alert-notes", response_model=AlertNoteFull)
async def create_alert(
alert: AlertNoteCreate,
current_user: dict = Depends(require_permission("alert_notes.create"))
):
"""
Create a new alert note.
Requires alert_notes.create permission.
"""
# Verify entity exists
entity_name = _get_entity_name(alert.entity_type.value, alert.entity_id)
if not entity_name:
raise HTTPException(
status_code=404,
detail=f"{alert.entity_type.value.capitalize()} with ID {alert.entity_id} not found"
)
# Insert alert note
result = execute_query(
"""
INSERT INTO alert_notes (
entity_type, entity_id, title, message, severity,
requires_acknowledgement, active, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
alert.entity_type.value, alert.entity_id, alert.title, alert.message,
alert.severity.value, alert.requires_acknowledgement, alert.active,
current_user["id"]
)
)
if not result or len(result) == 0:
raise HTTPException(status_code=500, detail="Failed to create alert note")
alert_id = result[0]["id"]
# Insert restrictions
for group_id in alert.restriction_group_ids:
execute_query(
"""
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
VALUES (%s, 'group', %s)
""",
(alert_id, group_id)
)
for user_id in alert.restriction_user_ids:
execute_query(
"""
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
VALUES (%s, 'user', %s)
""",
(alert_id, user_id)
)
logger.info(f"Alert note {alert_id} created for {alert.entity_type.value} {alert.entity_id} by user {current_user['id']}")
# Return full alert with relations
alert_full = _get_alert_with_relations(alert_id, current_user)
return alert_full
@router.patch("/alert-notes/{alert_id}", response_model=AlertNoteFull)
async def update_alert(
alert_id: int,
alert_update: AlertNoteUpdate,
current_user: dict = Depends(require_permission("alert_notes.edit"))
):
"""
Update an existing alert note.
Requires alert_notes.edit permission.
"""
# Check if alert exists
existing = execute_query(
"SELECT id FROM alert_notes WHERE id = %s",
(alert_id,)
)
if not existing:
raise HTTPException(status_code=404, detail="Alert note not found")
# Build update query
update_fields = []
params = []
if alert_update.title is not None:
update_fields.append("title = %s")
params.append(alert_update.title)
if alert_update.message is not None:
update_fields.append("message = %s")
params.append(alert_update.message)
if alert_update.severity is not None:
update_fields.append("severity = %s")
params.append(alert_update.severity.value)
if alert_update.requires_acknowledgement is not None:
update_fields.append("requires_acknowledgement = %s")
params.append(alert_update.requires_acknowledgement)
if alert_update.active is not None:
update_fields.append("active = %s")
params.append(alert_update.active)
if update_fields:
query = f"UPDATE alert_notes SET {', '.join(update_fields)} WHERE id = %s"
params.append(alert_id)
execute_update(query, tuple(params))
# Update restrictions if provided
if alert_update.restriction_group_ids is not None or alert_update.restriction_user_ids is not None:
# Delete existing restrictions
execute_update(
"DELETE FROM alert_note_restrictions WHERE alert_note_id = %s",
(alert_id,)
)
# Insert new group restrictions
if alert_update.restriction_group_ids is not None:
for group_id in alert_update.restriction_group_ids:
execute_update(
"""
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
VALUES (%s, 'group', %s)
""",
(alert_id, group_id)
)
# Insert new user restrictions
if alert_update.restriction_user_ids is not None:
for user_id in alert_update.restriction_user_ids:
execute_update(
"""
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
VALUES (%s, 'user', %s)
""",
(alert_id, user_id)
)
logger.info(f"Alert note {alert_id} updated by user {current_user['id']}")
# Return updated alert
alert_full = _get_alert_with_relations(alert_id, current_user)
return alert_full
@router.delete("/alert-notes/{alert_id}")
async def delete_alert(
alert_id: int,
current_user: dict = Depends(require_permission("alert_notes.delete"))
):
"""
Soft delete an alert note (sets active = false).
Requires alert_notes.delete permission.
"""
# Check if alert exists
existing = execute_query(
"SELECT id, active FROM alert_notes WHERE id = %s",
(alert_id,)
)
if not existing:
raise HTTPException(status_code=404, detail="Alert note not found")
# Soft delete
execute_update(
"UPDATE alert_notes SET active = FALSE WHERE id = %s",
(alert_id,)
)
logger.info(f"Alert note {alert_id} deactivated by user {current_user['id']}")
return {"status": "deleted", "alert_id": alert_id}

View File

@ -0,0 +1,99 @@
"""
Alert Notes Pydantic Schemas
Data models for contextual customer/contact alerts
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class EntityType(str, Enum):
"""Entity types that can have alert notes"""
customer = "customer"
contact = "contact"
class Severity(str, Enum):
"""Alert severity levels"""
info = "info"
warning = "warning"
critical = "critical"
class RestrictionType(str, Enum):
"""Types of restrictions for alert notes"""
group = "group"
user = "user"
class AlertNoteRestriction(BaseModel):
"""Alert note restriction (who can handle the customer/contact)"""
id: Optional[int] = None
alert_note_id: int
restriction_type: RestrictionType
restriction_id: int # References groups.id or users.user_id
restriction_name: Optional[str] = None # Filled by JOIN in query
created_at: Optional[datetime] = None
class AlertNoteAcknowledgement(BaseModel):
"""Alert note acknowledgement record"""
id: Optional[int] = None
alert_note_id: int
user_id: int
user_name: Optional[str] = None # Filled by JOIN
acknowledged_at: Optional[datetime] = None
class AlertNoteBase(BaseModel):
"""Base schema for alert notes"""
entity_type: EntityType
entity_id: int
title: str = Field(..., min_length=1, max_length=255)
message: str = Field(..., min_length=1)
severity: Severity = Severity.info
requires_acknowledgement: bool = True
active: bool = True
class AlertNoteCreate(AlertNoteBase):
"""Schema for creating an alert note"""
restriction_group_ids: List[int] = [] # List of group IDs
restriction_user_ids: List[int] = [] # List of user IDs
class AlertNoteUpdate(BaseModel):
"""Schema for updating an alert note"""
title: Optional[str] = Field(None, min_length=1, max_length=255)
message: Optional[str] = Field(None, min_length=1)
severity: Optional[Severity] = None
requires_acknowledgement: Optional[bool] = None
active: Optional[bool] = None
restriction_group_ids: Optional[List[int]] = None
restriction_user_ids: Optional[List[int]] = None
class AlertNoteFull(AlertNoteBase):
"""Full alert note schema with all relations"""
id: int
created_by_user_id: Optional[int] = None
created_by_user_name: Optional[str] = None # Filled by JOIN
created_at: datetime
updated_at: datetime
# Related data
restrictions: List[AlertNoteRestriction] = []
acknowledgements: List[AlertNoteAcknowledgement] = []
# Entity info (filled by JOIN)
entity_name: Optional[str] = None
class AlertNoteCheck(BaseModel):
"""Response for checking alerts on an entity"""
has_alerts: bool
alerts: List[AlertNoteFull]
user_can_handle: bool # Whether current user is allowed per restrictions
user_has_acknowledged: bool = False

View File

@ -0,0 +1,199 @@
<!-- Alert Notes Box Component - For inline display on detail pages -->
<style>
.alert-note-box {
border-left: 5px solid;
padding: 15px 20px;
margin: 15px 0;
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: all 0.3s;
}
.alert-note-box:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.alert-note-info {
border-left-color: #0dcaf0;
background: #d1ecf1;
}
[data-bs-theme="dark"] .alert-note-info {
background: rgba(13, 202, 240, 0.15);
}
.alert-note-warning {
border-left-color: #ffc107;
background: #fff3cd;
}
[data-bs-theme="dark"] .alert-note-warning {
background: rgba(255, 193, 7, 0.15);
}
.alert-note-critical {
border-left-color: #dc3545;
background: #f8d7da;
}
[data-bs-theme="dark"] .alert-note-critical {
background: rgba(220, 53, 69, 0.15);
}
.alert-note-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.alert-note-severity-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
}
.alert-note-severity-badge.info {
background: #0dcaf0;
color: white;
}
.alert-note-severity-badge.warning {
background: #ffc107;
color: #000;
}
.alert-note-severity-badge.critical {
background: #dc3545;
color: white;
}
.alert-note-message {
margin-bottom: 12px;
line-height: 1.6;
white-space: pre-wrap;
}
.alert-note-restrictions {
padding: 10px;
background: rgba(0,0,0,0.05);
border-radius: 6px;
font-size: 0.9rem;
margin-top: 12px;
}
[data-bs-theme="dark"] .alert-note-restrictions {
background: rgba(255,255,255,0.05);
}
.alert-note-restrictions strong {
display: block;
margin-bottom: 5px;
}
.alert-note-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.alert-note-acknowledge-btn {
font-size: 0.85rem;
padding: 4px 12px;
}
</style>
<!-- Template structure (fill via JavaScript) -->
<div id="alert-notes-container"></div>
<script>
function renderAlertBox(alert) {
const severityClass = `alert-note-${alert.severity}`;
const severityBadgeClass = alert.severity;
let restrictionsHtml = '';
if (alert.restrictions && alert.restrictions.length > 0) {
const restrictionNames = alert.restrictions.map(r => r.restriction_name).join(', ');
restrictionsHtml = `
<div class="alert-note-restrictions">
<strong><i class="bi bi-shield-lock"></i> Håndteres kun af:</strong>
${restrictionNames}
</div>
`;
}
let acknowledgeBtn = '';
if (alert.requires_acknowledgement && !alert.user_has_acknowledged) {
acknowledgeBtn = `
<button class="btn btn-sm btn-outline-secondary alert-note-acknowledge-btn"
onclick="acknowledgeAlert(${alert.id}, this)">
<i class="bi bi-check-circle"></i> Forstået
</button>
`;
}
// Edit button (always show for admins/creators)
const editBtn = `
<button class="btn btn-sm btn-outline-primary alert-note-acknowledge-btn"
onclick="openAlertNoteForm('${alert.entity_type}', ${alert.entity_id}, ${alert.id})"
title="Rediger alert note">
<i class="bi bi-pencil"></i>
</button>
`;
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
return `
<div class="alert-note-box ${severityClass}" data-alert-id="${alert.id}">
<div class="alert-note-title">
<span class="alert-note-severity-badge ${severityBadgeClass}">
${alert.severity === 'info' ? 'INFO' : alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK'}
<div class="d-flex gap-2">
${editBtn}
${acknowledgeBtn}
</div>
${alert.title}
</div>
<div class="alert-note-message">${alert.message}</div>
${restrictionsHtml}
<div class="alert-note-footer">
<span class="text-muted">
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK')}${createdBy}
</span>
${acknowledgeBtn}
</div>
</div>
`;
}
function acknowledgeAlert(alertId, buttonElement) {
fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
// Remove the alert box with fade animation
const alertBox = buttonElement.closest('.alert-note-box');
alertBox.style.opacity = '0';
alertBox.style.transform = 'translateX(-20px)';
setTimeout(() => alertBox.remove(), 300);
}
})
.catch(error => {
console.error('Error acknowledging alert:', error);
alert('Kunne ikke markere som læst. Prøv igen.');
});
}
</script>

View File

@ -0,0 +1,131 @@
/**
* Alert Notes JavaScript Module
* Handles loading and displaying alert notes for customers and contacts
*/
/**
* Load and display alerts for an entity
* @param {string} entityType - 'customer' or 'contact'
* @param {number} entityId - The entity ID
* @param {string} mode - 'inline' (show in page) or 'modal' (show popup)
* @param {string} containerId - Optional container ID for inline mode (default: 'alert-notes-container')
*/
async function loadAndDisplayAlerts(entityType, entityId, mode = 'inline', containerId = 'alert-notes-container') {
try {
const response = await fetch(`/api/v1/alert-notes/check?entity_type=${entityType}&entity_id=${entityId}`, {
credentials: 'include'
});
if (!response.ok) {
console.error('Failed to fetch alerts:', response.status);
return;
}
const data = await response.json();
if (!data.has_alerts) {
// No alerts - clear container if in inline mode
if (mode === 'inline') {
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = '';
}
}
return;
}
// Store for later use
window.currentAlertData = data;
if (mode === 'modal') {
// Show modal popup
showAlertModal(data.alerts);
} else {
// Show inline
displayAlertsInline(data.alerts, containerId, data.user_has_acknowledged);
}
} catch (error) {
console.error('Error loading alerts:', error);
}
}
/**
* Display alerts inline in a container
* @param {Array} alerts - Array of alert objects
* @param {string} containerId - Container element ID
* @param {boolean} userHasAcknowledged - Whether user has acknowledged all
*/
function displayAlertsInline(alerts, containerId, userHasAcknowledged) {
const container = document.getElementById(containerId);
if (!container) {
console.error('Alert container not found:', containerId);
return;
}
// Clear existing content
container.innerHTML = '';
// Add each alert
alerts.forEach(alert => {
// Set user_has_acknowledged on individual alert if needed
alert.user_has_acknowledged = userHasAcknowledged;
// Render using the renderAlertBox function from alert_box.html
const alertHtml = renderAlertBox(alert);
container.innerHTML += alertHtml;
});
}
/**
* Acknowledge a single alert
* @param {number} alertId - The alert ID
* @param {HTMLElement} buttonElement - The button that was clicked
*/
async function acknowledgeAlert(alertId, buttonElement) {
try {
const response = await fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
const data = await response.json();
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
// Remove the alert box with fade animation
const alertBox = buttonElement.closest('.alert-note-box');
if (alertBox) {
alertBox.style.opacity = '0';
alertBox.style.transform = 'translateX(-20px)';
alertBox.style.transition = 'all 0.3s';
setTimeout(() => alertBox.remove(), 300);
}
}
} catch (error) {
console.error('Error acknowledging alert:', error);
alert('Kunne ikke markere som læst. Prøv igen.');
}
}
/**
* Initialize alert checking on page load
* Call this from your page's DOMContentLoaded or similar
* @param {string} entityType - 'customer' or 'contact'
* @param {number} entityId - The entity ID
* @param {Object} options - Optional settings {mode: 'inline'|'modal', containerId: 'element-id'}
*/
function initAlertNotes(entityType, entityId, options = {}) {
const mode = options.mode || 'inline';
const containerId = options.containerId || 'alert-notes-container';
loadAndDisplayAlerts(entityType, entityId, mode, containerId);
}
// Make functions globally available
window.loadAndDisplayAlerts = loadAndDisplayAlerts;
window.displayAlertsInline = displayAlertsInline;
window.acknowledgeAlert = acknowledgeAlert;
window.initAlertNotes = initAlertNotes;

View File

@ -0,0 +1,551 @@
<!-- Alert Note Create/Edit Modal -->
<div class="modal fade" id="alertNoteFormModal" tabindex="-1" aria-labelledby="alertNoteFormModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning bg-opacity-10 border-bottom border-warning">
<h5 class="modal-title d-flex align-items-center" id="alertNoteFormModalLabel">
<i class="bi bi-exclamation-triangle-fill text-warning me-2" style="font-size: 1.3rem;"></i>
<span id="alertFormTitle" class="fw-bold">Opret Alert Note</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
<form id="alertNoteForm">
<input type="hidden" id="alertNoteId" value="">
<input type="hidden" id="alertEntityType" value="">
<input type="hidden" id="alertEntityId" value="">
<!-- Titel Section -->
<div class="mb-4">
<label for="alertTitle" class="form-label fw-semibold">
Titel <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control form-control-lg"
id="alertTitle"
required
maxlength="255"
placeholder="Kort beskrivende titel">
</div>
<!-- Besked Section -->
<div class="mb-4">
<label for="alertMessage" class="form-label fw-semibold">
Besked <span class="text-danger">*</span>
</label>
<textarea class="form-control"
id="alertMessage"
rows="6"
required
placeholder="Detaljeret information der skal vises..."
style="font-family: inherit; line-height: 1.6;"></textarea>
<div class="form-text mt-2">
<i class="bi bi-info-circle me-1"></i>
Du kan bruge linjeskift for formatering
</div>
</div>
<!-- Alvorlighed Section -->
<div class="mb-4">
<label for="alertSeverity" class="form-label fw-semibold">
Alvorlighed <span class="text-danger">*</span>
</label>
<select class="form-select form-select-lg" id="alertSeverity" required>
<option value="info"> Info - General kontekst</option>
<option value="warning" selected>⚠️ Advarsel - Særlige forhold</option>
<option value="critical">🚨 Kritisk - Følsomme forhold</option>
</select>
</div>
<!-- Checkboxes Section -->
<div class="mb-4 p-3 bg-light rounded">
<div class="form-check mb-3">
<input class="form-check-input"
type="checkbox"
id="alertRequiresAck"
checked>
<label class="form-check-label" for="alertRequiresAck">
<strong>Kræv bekræftelse</strong>
<div class="text-muted small mt-1">
Brugere skal klikke "Forstået" for at bekræfte at de har set advarslen
</div>
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="alertActive"
checked>
<label class="form-check-label" for="alertActive">
<strong>Aktiv</strong>
<div class="text-muted small mt-1">
Alert noten vises på kunde/kontakt siden
</div>
</label>
</div>
</div>
<hr class="my-4">
<!-- Restrictions Section -->
<div class="mb-3">
<label class="form-label fw-semibold d-flex align-items-center mb-3">
<i class="bi bi-shield-lock me-2 text-primary"></i>
Begrænsninger (Valgfri)
</label>
<div class="alert alert-info d-flex align-items-start mb-4">
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
<div>
<strong>Hvad er begrænsninger?</strong>
<p class="mb-0 mt-1 small">
Angiv hvilke grupper eller brugere der må håndtere denne kunde/kontakt.
Lad felterne stå tomme hvis alle må håndtere kunden.
</p>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="alertGroups" class="form-label fw-semibold">
<i class="bi bi-people-fill me-1"></i>
Godkendte Grupper
</label>
<select class="form-select" id="alertGroups" multiple size="5">
<!-- Populated via JavaScript -->
</select>
<div class="form-text mt-2">
<i class="bi bi-hand-index me-1"></i>
Hold Ctrl/Cmd for at vælge flere
</div>
</div>
<div class="col-md-6 mb-3">
<label for="alertUsers" class="form-label fw-semibold">
<i class="bi bi-person-fill me-1"></i>
Godkendte Brugere
</label>
<select class="form-select" id="alertUsers" multiple size="5">
<!-- Populated via JavaScript -->
</select>
<div class="form-text mt-2">
<i class="bi bi-hand-index me-1"></i>
Hold Ctrl/Cmd for at vælge flere
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-2"></i>
Annuller
</button>
<button type="button" class="btn btn-primary btn-lg" id="saveAlertNoteBtn" onclick="saveAlertNote()">
<i class="bi bi-save me-2"></i>
Gem Alert Note
</button>
</div>
</div>
</div>
</div>
<style>
/* Modal Header Styling */
#alertNoteFormModal .modal-header {
padding: 1.25rem 1.5rem;
}
#alertNoteFormModal .modal-body {
padding: 1.5rem;
}
#alertNoteFormModal .modal-footer {
padding: 1rem 1.5rem;
}
/* Form Labels */
#alertNoteFormModal .form-label {
font-weight: 600;
color: var(--bs-body-color);
margin-bottom: 0.5rem;
}
/* Input Fields */
#alertNoteFormModal .form-control,
#alertNoteFormModal .form-select {
border-radius: 8px;
border: 1px solid #dee2e6;
transition: all 0.2s ease;
}
#alertNoteFormModal .form-control:focus,
#alertNoteFormModal .form-select:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
}
/* Textarea specific */
#alertNoteFormModal textarea.form-control {
resize: vertical;
min-height: 150px;
}
/* Multiselect Styling */
#alertNoteFormModal select[multiple] {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 0.5rem;
transition: all 0.2s ease;
}
#alertNoteFormModal select[multiple]:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
outline: none;
}
#alertNoteFormModal select[multiple] option {
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
#alertNoteFormModal select[multiple] option:hover {
background: rgba(13, 110, 253, 0.1);
}
#alertNoteFormModal select[multiple] option:checked {
background: var(--bs-primary);
color: white;
font-weight: 500;
}
/* Checkbox Container */
#alertNoteFormModal .form-check {
padding: 0.75rem;
border-radius: 8px;
transition: background 0.2s ease;
}
#alertNoteFormModal .form-check:hover {
background: rgba(0, 0, 0, 0.02);
}
[data-bs-theme="dark"] #alertNoteFormModal .form-check:hover {
background: rgba(255, 255, 255, 0.05);
}
#alertNoteFormModal .form-check-input {
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
cursor: pointer;
}
#alertNoteFormModal .form-check-label {
cursor: pointer;
user-select: none;
}
/* Alert Info Box */
#alertNoteFormModal .alert-info {
border-left: 4px solid var(--bs-info);
background: rgba(13, 202, 240, 0.1);
border-radius: 8px;
}
[data-bs-theme="dark"] #alertNoteFormModal .alert-info {
background: rgba(13, 202, 240, 0.15);
}
/* Background Color Theme Support */
[data-bs-theme="dark"] #alertNoteFormModal .bg-light {
background: rgba(255, 255, 255, 0.05) !important;
}
[data-bs-theme="dark"] #alertNoteFormModal .modal-header {
background: rgba(255, 193, 7, 0.1) !important;
border-bottom-color: rgba(255, 193, 7, 0.3) !important;
}
/* Form Text Helpers */
#alertNoteFormModal .form-text {
font-size: 0.875rem;
color: #6c757d;
}
/* Divider */
#alertNoteFormModal hr {
margin: 1.5rem 0;
opacity: 0.1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
#alertNoteFormModal .row > .col-md-6 {
margin-bottom: 1rem !important;
}
}
</style>
<script>
let alertFormModal = null;
let currentAlertEntityType = null;
let currentAlertEntityId = null;
async function openAlertNoteForm(entityType, entityId, alertId = null) {
currentAlertEntityType = entityType;
currentAlertEntityId = entityId;
// Load groups and users for restrictions
await loadGroupsAndUsers();
if (alertId) {
// Edit mode
await loadAlertForEdit(alertId);
document.getElementById('alertFormTitle').textContent = 'Rediger Alert Note';
} else {
// Create mode
document.getElementById('alertFormTitle').textContent = 'Opret Alert Note';
document.getElementById('alertNoteForm').reset();
document.getElementById('alertNoteId').value = '';
document.getElementById('alertRequiresAck').checked = true;
document.getElementById('alertActive').checked = true;
document.getElementById('alertSeverity').value = 'warning';
}
document.getElementById('alertEntityType').value = entityType;
document.getElementById('alertEntityId').value = entityId;
// Show modal
const modalEl = document.getElementById('alertNoteFormModal');
alertFormModal = new bootstrap.Modal(modalEl);
alertFormModal.show();
}
async function loadGroupsAndUsers() {
try {
// Load groups
const groupsResponse = await fetch('/api/v1/admin/groups', {
credentials: 'include'
});
if (groupsResponse.ok) {
const groups = await groupsResponse.json();
const groupsSelect = document.getElementById('alertGroups');
groupsSelect.innerHTML = groups.map(g =>
`<option value="${g.id}">${g.name}</option>`
).join('');
}
// Load users
const usersResponse = await fetch('/api/v1/admin/users', {
credentials: 'include'
});
if (usersResponse.ok) {
const users = await usersResponse.json();
const usersSelect = document.getElementById('alertUsers');
usersSelect.innerHTML = users.map(u =>
`<option value="${u.user_id}">${u.full_name || u.username} (${u.username})</option>`
).join('');
}
} catch (error) {
console.error('Error loading groups/users:', error);
}
}
async function loadAlertForEdit(alertId) {
try {
const response = await fetch(`/api/v1/alert-notes?entity_type=${currentAlertEntityType}&entity_id=${currentAlertEntityId}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to load alert');
const alerts = await response.json();
const alert = alerts.find(a => a.id === alertId);
if (!alert) throw new Error('Alert not found');
document.getElementById('alertNoteId').value = alert.id;
document.getElementById('alertTitle').value = alert.title;
document.getElementById('alertMessage').value = alert.message;
document.getElementById('alertSeverity').value = alert.severity;
document.getElementById('alertRequiresAck').checked = alert.requires_acknowledgement;
document.getElementById('alertActive').checked = alert.active;
// Set restrictions
if (alert.restrictions && alert.restrictions.length > 0) {
const groupIds = alert.restrictions
.filter(r => r.restriction_type === 'group')
.map(r => r.restriction_id);
const userIds = alert.restrictions
.filter(r => r.restriction_type === 'user')
.map(r => r.restriction_id);
// Select options
Array.from(document.getElementById('alertGroups').options).forEach(opt => {
opt.selected = groupIds.includes(parseInt(opt.value));
});
Array.from(document.getElementById('alertUsers').options).forEach(opt => {
opt.selected = userIds.includes(parseInt(opt.value));
});
}
} catch (error) {
console.error('Error loading alert for edit:', error);
alert('Kunne ikke indlæse alert. Prøv igen.');
}
}
async function saveAlertNote() {
const form = document.getElementById('alertNoteForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const alertId = document.getElementById('alertNoteId').value;
const isEdit = !!alertId;
// Get selected groups and users
const selectedGroups = Array.from(document.getElementById('alertGroups').selectedOptions)
.map(opt => parseInt(opt.value));
const selectedUsers = Array.from(document.getElementById('alertUsers').selectedOptions)
.map(opt => parseInt(opt.value));
// Build data object - different structure for create vs update
let data;
if (isEdit) {
// PATCH: Only send fields to update (no entity_type, entity_id)
data = {
title: document.getElementById('alertTitle').value,
message: document.getElementById('alertMessage').value,
severity: document.getElementById('alertSeverity').value,
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
active: document.getElementById('alertActive').checked,
restriction_group_ids: selectedGroups,
restriction_user_ids: selectedUsers
};
} else {
// POST: Include entity_type and entity_id for creation
data = {
entity_type: document.getElementById('alertEntityType').value,
entity_id: parseInt(document.getElementById('alertEntityId').value),
title: document.getElementById('alertTitle').value,
message: document.getElementById('alertMessage').value,
severity: document.getElementById('alertSeverity').value,
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
active: document.getElementById('alertActive').checked,
restriction_group_ids: selectedGroups,
restriction_user_ids: selectedUsers
};
}
try {
const saveBtn = document.getElementById('saveAlertNoteBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
// Debug logging
console.log('Saving alert note:', { isEdit, alertId, data });
let response;
if (isEdit) {
// Update existing
response = await fetch(`/api/v1/alert-notes/${alertId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
} else {
// Create new
response = await fetch('/api/v1/alert-notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
}
if (!response.ok) {
let errorMsg = 'Failed to save alert note';
try {
const error = await response.json();
console.error('API Error Response:', error);
// Handle Pydantic validation errors
if (error.detail && Array.isArray(error.detail)) {
errorMsg = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join('\n');
} else if (error.detail) {
errorMsg = error.detail;
}
} catch (e) {
errorMsg = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
// Success
alertFormModal.hide();
// Reload alerts on page
loadAndDisplayAlerts(
currentAlertEntityType,
currentAlertEntityId,
'inline',
'alert-notes-container'
);
// Show success message
showSuccessToast(isEdit ? 'Alert note opdateret!' : 'Alert note oprettet!');
} catch (error) {
console.error('Error saving alert note:', error);
// Show detailed error message
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
errorDiv.innerHTML = `
<strong>Kunne ikke gemme alert note:</strong><br>
<pre style="white-space: pre-wrap; margin-top: 10px; font-size: 0.9em;">${error.message}</pre>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert error before form
const modalBody = document.querySelector('#alertNoteFormModal .modal-body');
modalBody.insertBefore(errorDiv, modalBody.firstChild);
// Auto-remove after 10 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.remove();
}
}, 10000);
} finally {
const saveBtn = document.getElementById('saveAlertNoteBtn');
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-save me-2"></i>Gem Alert Note';
}
}
function showSuccessToast(message) {
// Simple toast notification
const toast = document.createElement('div');
toast.className = 'alert alert-success position-fixed bottom-0 end-0 m-3';
toast.style.zIndex = '9999';
toast.innerHTML = `<i class="bi bi-check-circle me-2"></i>${message}`;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade');
setTimeout(() => toast.remove(), 150);
}, 3000);
}
// Make functions globally available
window.openAlertNoteForm = openAlertNoteForm;
window.saveAlertNote = saveAlertNote;
</script>

View File

@ -0,0 +1,198 @@
<!-- Alert Notes Modal Component - For popup display -->
<div class="modal fade" id="alertNoteModal" tabindex="-1" aria-labelledby="alertNoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" id="alertModalHeader">
<h5 class="modal-title" id="alertNoteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Vigtig information
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="alertModalBody">
<!-- Alert content will be inserted here -->
</div>
<div class="modal-footer" id="alertModalFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" id="alertModalAcknowledgeBtn" style="display: none;">
<i class="bi bi-check-circle"></i> Forstået
</button>
</div>
</div>
</div>
</div>
<style>
#alertNoteModal .modal-header.severity-info {
background: linear-gradient(135deg, #0dcaf0 0%, #00b4d8 100%);
color: white;
}
#alertNoteModal .modal-header.severity-warning {
background: linear-gradient(135deg, #ffc107 0%, #ffb703 100%);
color: #000;
}
#alertNoteModal .modal-header.severity-critical {
background: linear-gradient(135deg, #dc3545 0%, #bb2d3b 100%);
color: white;
}
.alert-modal-content {
padding: 15px 0;
}
.alert-modal-title {
font-weight: 600;
font-size: 1.2rem;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #dee2e6;
}
.alert-modal-message {
line-height: 1.6;
margin-bottom: 15px;
white-space: pre-wrap;
}
.alert-modal-restrictions {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #0f4c75;
margin-top: 15px;
}
[data-bs-theme="dark"] .alert-modal-restrictions {
background: #2c3034;
}
.alert-modal-restrictions strong {
display: block;
margin-bottom: 8px;
color: var(--accent);
}
.alert-modal-restrictions ul {
margin-bottom: 0;
padding-left: 20px;
}
.alert-modal-meta {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
</style>
<script>
let currentAlertModal = null;
let currentAlerts = [];
function showAlertModal(alerts) {
if (!alerts || alerts.length === 0) return;
currentAlerts = alerts;
const modal = document.getElementById('alertNoteModal');
const modalHeader = document.getElementById('alertModalHeader');
const modalBody = document.getElementById('alertModalBody');
const modalAckBtn = document.getElementById('alertModalAcknowledgeBtn');
// Set severity styling (use highest severity)
const highestSeverity = alerts.find(a => a.severity === 'critical') ? 'critical' :
alerts.find(a => a.severity === 'warning') ? 'warning' : 'info';
modalHeader.className = `modal-header severity-${highestSeverity}`;
// Build content
let contentHtml = '';
alerts.forEach((alert, index) => {
const severityText = alert.severity === 'info' ? 'INFO' :
alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK';
let restrictionsHtml = '';
if (alert.restrictions && alert.restrictions.length > 0) {
const restrictionsList = alert.restrictions
.map(r => `<li>${r.restriction_name}</li>`)
.join('');
restrictionsHtml = `
<div class="alert-modal-restrictions">
<strong><i class="bi bi-shield-lock"></i> Kun følgende må håndtere denne ${alert.entity_type === 'customer' ? 'kunde' : 'kontakt'}:</strong>
<ul>${restrictionsList}</ul>
</div>
`;
}
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
contentHtml += `
<div class="alert-modal-content" data-alert-id="${alert.id}">
${index > 0 ? '<hr>' : ''}
<div class="alert-modal-title">
<span class="badge bg-${alert.severity === 'critical' ? 'danger' : alert.severity === 'warning' ? 'warning' : 'info'}">
${severityText}
</span>
${alert.title}
</div>
<div class="alert-modal-message">${alert.message}</div>
${restrictionsHtml}
<div class="alert-modal-meta">
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}${createdBy}
</div>
</div>
`;
});
modalBody.innerHTML = contentHtml;
// Show acknowledge button if any alert requires it and user hasn't acknowledged
const requiresAck = alerts.some(a => a.requires_acknowledgement && !a.user_has_acknowledged);
if (requiresAck) {
modalAckBtn.style.display = 'inline-block';
modalAckBtn.onclick = function() {
acknowledgeAllAlerts();
};
} else {
modalAckBtn.style.display = 'none';
}
// Show modal
currentAlertModal = new bootstrap.Modal(modal);
currentAlertModal.show();
}
function acknowledgeAllAlerts() {
const promises = currentAlerts
.filter(a => a.requires_acknowledgement && !a.user_has_acknowledged)
.map(alert => {
return fetch(`/api/v1/alert-notes/${alert.id}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
});
Promise.all(promises)
.then(() => {
if (currentAlertModal) {
currentAlertModal.hide();
}
// Reload alerts on the page if in inline view
if (typeof loadAlerts === 'function') {
loadAlerts();
}
})
.catch(error => {
console.error('Error acknowledging alerts:', error);
alert('Kunne ikke markere som læst. Prøv igen.');
});
}
</script>

View File

@ -0,0 +1,50 @@
from app.core.database import get_db_connection, release_db_connection, init_db
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
init_db() # Initialize the pool
conn = get_db_connection()
try:
with conn.cursor() as cursor:
# Files linked to a Case
cursor.execute("""
CREATE TABLE IF NOT EXISTS sag_files (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
size_bytes INTEGER,
stored_name TEXT NOT NULL,
uploaded_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sag_files_sag_id ON sag_files(sag_id);")
cursor.execute("COMMENT ON TABLE sag_files IS 'Files uploaded directly to the Case.';")
# Emails linked to a Case (Many-to-Many)
cursor.execute("""
CREATE TABLE IF NOT EXISTS sag_emails (
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE CASCADE,
email_id INTEGER REFERENCES email_messages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (sag_id, email_id)
);
""")
cursor.execute("COMMENT ON TABLE sag_emails IS 'Emails linked to the Case.';")
conn.commit()
logger.info("Migration 084 applied successfully.")
except Exception as e:
conn.rollback()
logger.error(f"Migration failed: {e}")
finally:
release_db_connection(conn)
if __name__ == "__main__":
run_migration()

View File

@ -0,0 +1,69 @@
import logging
import os
import sys
# Ensure we can import app modules
sys.path.append("/app")
from app.core.database import execute_query, init_db
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SQL_MIGRATION = """
CREATE TABLE IF NOT EXISTS sag_solutions (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
solution_type VARCHAR(50),
result VARCHAR(50),
created_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_sag_solutions_sag_id UNIQUE (sag_id)
);
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS solution_id INTEGER REFERENCES sag_solutions(id) ON DELETE SET NULL;
ALTER TABLE tmodule_times ADD COLUMN IF NOT EXISTS sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL;
ALTER TABLE tmodule_times ALTER COLUMN vtiger_id DROP NOT NULL;
ALTER TABLE tmodule_times ALTER COLUMN case_id DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sag_solutions_sag_id ON sag_solutions(sag_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_solution_id ON tmodule_times(solution_id);
CREATE INDEX IF NOT EXISTS idx_tmodule_times_sag_id ON tmodule_times(sag_id);
"""
def run_migration():
logger.info("Initializing DB connection...")
try:
init_db()
except Exception as e:
logger.error(f"Failed to init db: {e}")
return
logger.info("Applying migration 085...")
commands = [cmd.strip() for cmd in SQL_MIGRATION.split(";") if cmd.strip()]
for cmd in commands:
# Skip empty lines or pure comments
if not cmd or cmd.startswith("--"):
continue
logger.info(f"Executing: {cmd[:50]}...")
try:
execute_query(cmd, ())
except Exception as e:
logger.warning(f"Error executing command: {e}")
logger.info("✅ Migration applied successfully")
if __name__ == "__main__":
run_migration()

186
app/auth/backend/admin.py Normal file
View File

@ -0,0 +1,186 @@
"""
Auth Admin API - Users, Groups, Permissions management
"""
from fastapi import APIRouter, HTTPException, status, Depends
from app.core.auth_dependencies import require_permission
from app.core.auth_service import AuthService
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate, UserTwoFactorResetRequest
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))])
async def list_users():
users = execute_query(
"""
SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, u.is_2fa_enabled,
u.telefoni_extension, u.telefoni_aktiv, u.telefoni_phone_ip, u.telefoni_phone_username,
u.created_at, u.last_login_at,
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
FROM users u
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
LEFT JOIN groups g ON ug.group_id = g.id
GROUP BY u.user_id
ORDER BY u.user_id
"""
)
return users
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("users.manage"))])
async def create_user(payload: UserAdminCreate):
existing = execute_query_single(
"SELECT user_id FROM users WHERE username = %s OR email = %s",
(payload.username, payload.email)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username or email already exists"
)
try:
password_hash = AuthService.hash_password(payload.password)
except Exception as exc:
logger.error("❌ Password hash failed: %s", exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Kunne ikke hashe adgangskoden"
) from exc
user_id = execute_insert(
"""
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING user_id
""",
(payload.username, payload.email, password_hash, payload.full_name, payload.is_superadmin, payload.is_active)
)
if payload.group_ids:
for group_id in payload.group_ids:
execute_update(
"""
INSERT INTO user_groups (user_id, group_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user_id, group_id)
)
logger.info("✅ User created via admin: %s (ID: %s)", payload.username, user_id)
return {"user_id": user_id}
@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_permission("users.manage"))])
async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
for group_id in payload.group_ids:
execute_update(
"""
INSERT INTO user_groups (user_id, group_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user_id, group_id)
)
return {"message": "Groups updated"}
@router.post("/admin/users/{user_id}/2fa/reset")
async def reset_user_2fa(
user_id: int,
payload: UserTwoFactorResetRequest,
current_user: dict = Depends(require_permission("users.manage"))
):
ok = AuthService.admin_reset_user_2fa(user_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
reason = (payload.reason or "").strip()
if reason:
logger.info(
"✅ Admin reset 2FA for user_id=%s by %s (reason: %s)",
user_id,
current_user.get("username"),
reason
)
else:
logger.info(
"✅ Admin reset 2FA for user_id=%s by %s",
user_id,
current_user.get("username")
)
return {"message": "2FA reset"}
@router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))])
async def list_groups():
groups = execute_query(
"""
SELECT g.id, g.name, g.description,
COALESCE(array_remove(array_agg(p.code), NULL), ARRAY[]::varchar[]) AS permissions
FROM groups g
LEFT JOIN group_permissions gp ON g.id = gp.group_id
LEFT JOIN permissions p ON gp.permission_id = p.id
GROUP BY g.id
ORDER BY g.id
"""
)
return groups
@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("permissions.manage"))])
async def create_group(payload: GroupCreate):
existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists")
group_id = execute_insert(
"""
INSERT INTO groups (name, description)
VALUES (%s, %s) RETURNING id
""",
(payload.name, payload.description)
)
return {"group_id": group_id}
@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_permission("permissions.manage"))])
async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate):
group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,))
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
for permission_id in payload.permission_ids:
execute_update(
"""
INSERT INTO group_permissions (group_id, permission_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(group_id, permission_id)
)
return {"message": "Permissions updated"}
@router.get("/admin/permissions", dependencies=[Depends(require_permission("permissions.manage"))])
async def list_permissions():
permissions = execute_query(
"""
SELECT id, code, description, category
FROM permissions
ORDER BY category, code
"""
)
return permissions

View File

@ -1,9 +1,11 @@
"""
Auth API Router - Login, Logout, Me endpoints
"""
from fastapi import APIRouter, HTTPException, status, Request, Depends
from fastapi import APIRouter, HTTPException, status, Request, Depends, Response
from pydantic import BaseModel
from typing import Optional
from app.core.auth_service import AuthService
from app.core.config import settings
from app.core.auth_dependencies import get_current_user
import logging
@ -15,30 +17,44 @@ router = APIRouter()
class LoginRequest(BaseModel):
username: str
password: str
otp_code: Optional[str] = None
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: dict
requires_2fa_setup: bool = False
class LogoutRequest(BaseModel):
token_jti: str
token_jti: Optional[str] = None
class TwoFactorCodeRequest(BaseModel):
otp_code: str
@router.post("/login", response_model=LoginResponse)
async def login(request: Request, credentials: LoginRequest):
async def login(request: Request, credentials: LoginRequest, response: Response):
"""
Authenticate user and return JWT token
"""
ip_address = request.client.host if request.client else None
# Authenticate user
user = AuthService.authenticate_user(
user, error_detail = AuthService.authenticate_user(
username=credentials.username,
password=credentials.password,
ip_address=ip_address
ip_address=ip_address,
otp_code=credentials.otp_code
)
if error_detail:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_detail,
headers={"WWW-Authenticate": "Bearer"},
)
if not user:
@ -52,21 +68,48 @@ async def login(request: Request, credentials: LoginRequest):
access_token = AuthService.create_access_token(
user_id=user['user_id'],
username=user['username'],
is_superadmin=user['is_superadmin']
is_superadmin=user['is_superadmin'],
is_shadow_admin=user.get('is_shadow_admin', False)
)
requires_2fa_setup = (
not user.get("is_shadow_admin", False)
and not user.get("is_2fa_enabled", False)
)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
samesite=settings.COOKIE_SAMESITE,
secure=settings.COOKIE_SECURE
)
return LoginResponse(
access_token=access_token,
user=user
user=user,
requires_2fa_setup=requires_2fa_setup
)
@router.post("/logout")
async def logout(request: LogoutRequest, current_user: dict = Depends(get_current_user)):
async def logout(
response: Response,
current_user: dict = Depends(get_current_user),
request: Optional[LogoutRequest] = None
):
"""
Revoke JWT token (logout)
"""
AuthService.revoke_token(request.token_jti, current_user['id'])
token_jti = request.token_jti if request and request.token_jti else current_user.get("token_jti")
if token_jti:
AuthService.revoke_token(
token_jti,
current_user['id'],
current_user.get('is_shadow_admin', False)
)
response.delete_cookie("access_token")
return {"message": "Successfully logged out"}
@ -82,5 +125,75 @@ async def get_me(current_user: dict = Depends(get_current_user)):
"email": current_user['email'],
"full_name": current_user['full_name'],
"is_superadmin": current_user['is_superadmin'],
"is_2fa_enabled": current_user.get('is_2fa_enabled', False),
"permissions": current_user['permissions']
}
@router.post("/2fa/setup")
async def setup_2fa(current_user: dict = Depends(get_current_user)):
"""Generate and store TOTP secret (requires verification to enable)"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
result = AuthService.setup_user_2fa(
user_id=current_user["id"],
username=current_user["username"]
)
return result
@router.post("/2fa/enable")
async def enable_2fa(
request: TwoFactorCodeRequest,
current_user: dict = Depends(get_current_user)
):
"""Enable 2FA after verifying the provided code"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
ok = AuthService.enable_user_2fa(
user_id=current_user["id"],
otp_code=request.otp_code
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code or missing setup",
)
return {"message": "2FA enabled"}
@router.post("/2fa/disable")
async def disable_2fa(
request: TwoFactorCodeRequest,
current_user: dict = Depends(get_current_user)
):
"""Disable 2FA after verifying the provided code"""
if current_user.get("is_shadow_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Shadow admin cannot configure 2FA",
)
ok = AuthService.disable_user_2fa(
user_id=current_user["id"],
otp_code=request.otp_code
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code or missing setup",
)
return {"message": "2FA disabled"}

View File

@ -18,3 +18,14 @@ async def login_page(request: Request):
"auth/frontend/login.html",
{"request": request}
)
@router.get("/2fa/setup", response_class=HTMLResponse)
async def two_factor_setup_page(request: Request):
"""
Render 2FA setup page
"""
return templates.TemplateResponse(
"auth/frontend/2fa_setup.html",
{"request": request}
)

View File

@ -0,0 +1,145 @@
{% extends "shared/frontend/base.html" %}
{% block title %}2FA Setup - BMC Hub{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 80vh;">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2>
<p class="text-muted">Opsaet tofaktor for din konto</p>
</div>
<div id="statusMessage" class="alert alert-info" role="alert">
Klik "Generer 2FA" for at starte opsaetningen.
</div>
<div class="d-grid gap-2 mb-3">
<button class="btn btn-primary" id="generateBtn">
<i class="bi bi-shield-lock me-2"></i>
Generer 2FA
</button>
</div>
<div id="setupDetails" class="d-none">
<div class="mb-3">
<label class="form-label">Secret</label>
<input type="text" class="form-control" id="totpSecret" readonly>
</div>
<div class="mb-3">
<label class="form-label">Provisioning URI</label>
<textarea class="form-control" id="provisioningUri" rows="3" readonly></textarea>
</div>
<div class="mb-3">
<label class="form-label">2FA-kode</label>
<input type="text" class="form-control" id="otpCode" placeholder="Indtast 2FA-kode">
</div>
<div class="d-grid gap-2">
<button class="btn btn-success" id="enableBtn">
<i class="bi bi-check-circle me-2"></i>
Aktiver 2FA
</button>
</div>
</div>
<div class="text-center mt-3">
<a href="/" class="text-decoration-none text-muted">Spring over for nu</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const statusMessage = document.getElementById('statusMessage');
const generateBtn = document.getElementById('generateBtn');
const enableBtn = document.getElementById('enableBtn');
const setupDetails = document.getElementById('setupDetails');
const totpSecret = document.getElementById('totpSecret');
const provisioningUri = document.getElementById('provisioningUri');
const otpCode = document.getElementById('otpCode');
async function ensureAuthenticated() {
const token = localStorage.getItem('access_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
try {
const response = await fetch('/api/v1/auth/me', { headers, credentials: 'include' });
if (!response.ok) {
window.location.href = '/login';
}
} catch (error) {
window.location.href = '/login';
}
}
generateBtn.addEventListener('click', async () => {
statusMessage.className = 'alert alert-info';
statusMessage.textContent = 'Genererer 2FA...';
try {
const response = await fetch('/api/v1/auth/2fa/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = data.detail || 'Kunne ikke generere 2FA.';
return;
}
totpSecret.value = data.secret || '';
provisioningUri.value = data.provisioning_uri || '';
setupDetails.classList.remove('d-none');
statusMessage.className = 'alert alert-success';
statusMessage.textContent = '2FA secret genereret. Indtast koden fra din authenticator.';
} catch (error) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = 'Kunne ikke generere 2FA.';
}
});
enableBtn.addEventListener('click', async () => {
const code = (otpCode.value || '').trim();
if (!code) {
statusMessage.className = 'alert alert-warning';
statusMessage.textContent = 'Indtast 2FA-koden.';
return;
}
try {
const response = await fetch('/api/v1/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ otp_code: code })
});
const data = await response.json();
if (!response.ok) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = data.detail || 'Kunne ikke aktivere 2FA.';
return;
}
statusMessage.className = 'alert alert-success';
statusMessage.textContent = '2FA aktiveret. Du bliver sendt videre.';
setTimeout(() => {
window.location.href = '/';
}, 1200);
} catch (error) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = 'Kunne ikke aktivere 2FA.';
}
});
ensureAuthenticated();
</script>
{% endblock %}

View File

@ -39,6 +39,18 @@
>
</div>
<div class="mb-3">
<label for="otp_code" class="form-label">2FA-kode</label>
<input
type="text"
class="form-control"
id="otp_code"
name="otp_code"
placeholder="Indtast 2FA-kode"
autocomplete="one-time-code"
>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="rememberMe">
<label class="form-check-label" for="rememberMe">
@ -80,6 +92,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const otp_code = document.getElementById('otp_code').value;
const errorMessage = document.getElementById('errorMessage');
const errorText = document.getElementById('errorText');
const submitBtn = e.target.querySelector('button[type="submit"]');
@ -97,7 +110,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, otp_code })
});
const data = await response.json();
@ -107,6 +120,17 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Set cookie for HTML navigation access (expires in 24 hours)
const d = new Date();
d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
if (data.requires_2fa_setup) {
const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?');
window.location.href = goSetup ? '/2fa/setup' : '/';
return;
}
// Redirect to dashboard
window.location.href = '/';
} else {
@ -140,6 +164,11 @@ if (token) {
})
.then(response => {
if (response.ok) {
// Ensure cookie is set (sync with localStorage)
const d = new Date();
d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/';
} else {

View File

@ -6,6 +6,15 @@ Handles contact CRUD operations with multi-company support
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
from app.core.database import execute_query, execute_insert, execute_update
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
save_subscription_comment,
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
)
import logging
logger = logging.getLogger(__name__)
@ -148,11 +157,13 @@ async def get_contact(contact_id: int):
FROM contacts
WHERE id = %s
"""
contact = execute_query(contact_query, (contact_id,))
contact_result = execute_query(contact_query, (contact_id,))
if not contact:
if not contact_result:
raise HTTPException(status_code=404, detail="Contact not found")
contact = contact_result[0]
# Get linked companies
companies_query = """
SELECT
@ -163,7 +174,7 @@ async def get_contact(contact_id: int):
WHERE cc.contact_id = %s
ORDER BY cc.is_primary DESC, cu.name
"""
companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall
companies = execute_query(companies_query, (contact_id,))
contact['companies'] = companies or []
return contact
@ -356,3 +367,88 @@ async def unlink_contact_from_company(contact_id: int, customer_id: int):
except Exception as e:
logger.error(f"Failed to unlink contact from company: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts", response_model=dict)
async def get_related_contacts(contact_id: int):
"""
Get contacts from the same companies as the contact (excluding itself).
"""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return {"contacts": []}
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
c.created_at, c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id IN ({placeholders}) AND c.id <> %s
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
"""
params = tuple(customer_ids + [contact_id])
results = execute_query(query, params) or []
return {"contacts": results}
except Exception as e:
logger.error(f"Failed to get related contacts for {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/subscriptions")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
return {
"status": "no_linked_customer",
"message": "Kontakt er ikke tilknyttet et firma",
"recurring_orders": [],
"sales_orders": [],
"subscriptions": [],
"expired_subscriptions": [],
"bmc_office_subscriptions": [],
}
return await get_customer_subscriptions(customer_id)
@router.post("/contacts/{contact_id}/subscriptions/lock")
async def lock_contact_subscriptions(contact_id: int, lock_request: dict):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await lock_customer_subscriptions(customer_id, lock_request)
@router.post("/contacts/{contact_id}/subscription-comment")
async def save_contact_subscription_comment(contact_id: int, data: SubscriptionComment):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await save_subscription_comment(customer_id, data)
@router.get("/contacts/{contact_id}/subscription-comment")
async def get_contact_subscription_comment(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_comment(customer_id)
@router.get("/contacts/{contact_id}/subscriptions/billing-matrix")
async def get_contact_subscription_billing_matrix(
contact_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show"),
):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_billing_matrix(customer_id, months)

View File

@ -7,6 +7,15 @@ from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
save_subscription_comment,
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
)
import logging
logger = logging.getLogger(__name__)
@ -23,6 +32,24 @@ class ContactCreate(BaseModel):
company_id: Optional[int] = None
class ContactUpdate(BaseModel):
"""Schema for updating a contact"""
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
title: Optional[str] = None
department: Optional[str] = None
is_active: Optional[bool] = None
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
role: Optional[str] = None
@router.get("/contacts-debug")
async def debug_contacts():
@ -167,6 +194,9 @@ async def create_contact(contact: ContactCreate):
link_query = """
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, true, 'primary')
ON CONFLICT (contact_id, customer_id)
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
RETURNING id
"""
execute_insert(link_query, (contact_id, contact.company_id))
except Exception as e:
@ -221,3 +251,210 @@ async def get_contact(contact_id: int):
except Exception as e:
logger.error(f"Failed to get contact {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/contacts/{contact_id}")
async def update_contact(contact_id: int, contact_data: ContactUpdate):
"""Update a contact"""
try:
# Ensure contact exists
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Build update query dynamically
update_fields = []
params = []
for field, value in contact_data.model_dump(exclude_unset=True).items():
update_fields.append(f"{field} = %s")
params.append(value)
if not update_fields:
# No fields to update
return await get_contact(contact_id)
params.append(contact_id)
update_query = f"""
UPDATE contacts
SET {', '.join(update_fields)}, updated_at = NOW()
WHERE id = %s
RETURNING id
"""
execute_query(update_query, tuple(params))
return await get_contact(contact_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update contact {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts/{contact_id}/companies")
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
"""Link a contact to a company"""
try:
# Ensure contact exists
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Ensure customer exists
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
query = """
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 = EXCLUDED.is_primary, role = EXCLUDED.role
RETURNING id
"""
execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role))
return {"message": "Contact linked to company successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to link contact to company: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/related-contacts")
async def get_related_contacts(contact_id: int):
"""Get contacts from the same companies as the contact (excluding itself)."""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return {"contacts": []}
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
c.created_at, c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id IN ({placeholders}) AND c.id <> %s
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
"""
params = tuple(customer_ids + [contact_id])
results = execute_query(query, params) or []
return {"contacts": results}
except Exception as e:
logger.error(f"Failed to get related contacts for {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/subscriptions")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
return {
"status": "no_linked_customer",
"message": "Kontakt er ikke tilknyttet et firma",
"recurring_orders": [],
"sales_orders": [],
"subscriptions": [],
"expired_subscriptions": [],
"bmc_office_subscriptions": [],
}
return await get_customer_subscriptions(customer_id)
@router.post("/contacts/{contact_id}/subscriptions/lock")
async def lock_contact_subscriptions(contact_id: int, lock_request: dict):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await lock_customer_subscriptions(customer_id, lock_request)
@router.post("/contacts/{contact_id}/subscription-comment")
async def save_contact_subscription_comment(contact_id: int, data: SubscriptionComment):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await save_subscription_comment(customer_id, data)
@router.get("/contacts/{contact_id}/subscription-comment")
async def get_contact_subscription_comment(contact_id: int):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_comment(customer_id)
@router.get("/contacts/{contact_id}/subscriptions/billing-matrix")
async def get_contact_subscription_billing_matrix(
contact_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show"),
):
customer_id = get_primary_customer_id(contact_id)
if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_billing_matrix(customer_id, months)
@router.get("/contacts/{contact_id}/kontakt")
async def get_contact_kontakt_history(contact_id: int, limit: int = Query(default=200, ge=1, le=1000)):
try:
exists = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not exists:
raise HTTPException(status_code=404, detail="Contact not found")
query = """
SELECT * FROM (
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
t.ekstern_nummer AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
NULL::text AS sms_status
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
WHERE t.kontakt_id = %s
UNION ALL
SELECT
'sms' AS type,
s.id::text AS event_id,
s.created_at AS happened_at,
NULL::text AS direction,
s.recipient AS number,
s.message,
NULL::int AS duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
s.status AS sms_status
FROM sms_messages s
LEFT JOIN users u ON u.user_id = s.bruger_id
WHERE s.kontakt_id = %s
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (contact_id, contact_id, limit)) or []
return {"items": rows}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch kontakt history for contact {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@ -215,6 +215,74 @@
</div>
</div>
</div>
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editContactForm">
<input type="hidden" id="editContactId">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editFirstNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editLastNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="editEmailInput">
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="text" class="form-control" id="editPhoneInput">
</div>
<div class="col-md-6">
<label class="form-label">Mobil</label>
<input type="text" class="form-control" id="editMobileInput">
</div>
<div class="col-md-6">
<label class="form-label">Titel</label>
<input type="text" class="form-control" id="editTitleInput" placeholder="CEO, CTO, Manager...">
</div>
<div class="col-md-6">
<label class="form-label">Afdeling</label>
<input type="text" class="form-control" id="editDepartmentInput">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editIsActiveInput">
<label class="form-check-label" for="editIsActiveInput">
Aktiv kontakt
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveEditContact()">
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -308,6 +376,19 @@ function displayContacts(contacts) {
const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: '-';
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const mobileLine = contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.mobile)}
<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.mobile)}')">Ring op</button>
<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})">SMS</button>
</div>`
: '';
const phoneLine = !contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.phone || '-')}
${contact.phone ? `<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.phone)}')">Ring op</button>` : ''}
</div>`
: '';
const smsLine = mobileLine || phoneLine;
return `
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
@ -322,7 +403,7 @@ function displayContacts(contacts) {
</td>
<td>
<div class="fw-medium">${contact.email || '-'}</div>
<div class="small text-muted">${contact.mobile || contact.phone || '-'}</div>
${smsLine}
</td>
<td class="text-muted">${contact.title || '-'}</td>
<td>
@ -379,8 +460,120 @@ function viewContact(contactId) {
}
function editContact(contactId) {
// TODO: Open edit modal
console.log('Edit contact:', contactId);
// Load contact data and open edit modal
loadContactForEdit(contactId);
}
let contactsCurrentUserId = null;
async function ensureContactsCurrentUserId() {
if (contactsCurrentUserId !== null) return contactsCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactsCurrentUserId = Number(me?.id) || null;
return contactsCurrentUserId;
} catch (e) {
return null;
}
}
async function contactsCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactsCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadContactForEdit(contactId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);
if (!response.ok) throw new Error('Kunne ikke indlæse kontakt');
const contact = await response.json();
// Fill form
document.getElementById('editContactId').value = contactId;
document.getElementById('editFirstNameInput').value = contact.first_name || '';
document.getElementById('editLastNameInput').value = contact.last_name || '';
document.getElementById('editEmailInput').value = contact.email || '';
document.getElementById('editPhoneInput').value = contact.phone || '';
document.getElementById('editMobileInput').value = contact.mobile || '';
document.getElementById('editTitleInput').value = contact.title || '';
document.getElementById('editDepartmentInput').value = contact.department || '';
document.getElementById('editIsActiveInput').checked = contact.is_active || false;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
modal.show();
} catch (error) {
console.error('Failed to load contact:', error);
alert('Fejl: Kunne ikke indlæse kontakt');
}
}
async function saveEditContact() {
const contactId = document.getElementById('editContactId').value;
const firstName = document.getElementById('editFirstNameInput').value.trim();
const lastName = document.getElementById('editLastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email: document.getElementById('editEmailInput').value || null,
phone: document.getElementById('editPhoneInput').value || null,
mobile: document.getElementById('editMobileInput').value || null,
title: document.getElementById('editTitleInput').value || null,
department: document.getElementById('editDepartmentInput').value || null,
is_active: document.getElementById('editIsActiveInput').checked
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
}
// Close modal and reload
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
modal.hide();
loadContacts();
} catch (error) {
console.error('Failed to save contact:', error);
alert('Fejl: ' + error.message);
}
}
async function loadCompaniesForSelect() {

View File

@ -13,6 +13,7 @@ from pathlib import Path
from app.core.database import execute_query, execute_update
from app.models.schemas import Conversation, ConversationUpdate
from app.core.config import settings
from app.core.contact_utils import get_contact_customer_ids
router = APIRouter()
@ -20,6 +21,7 @@ router = APIRouter()
async def get_conversations(
request: Request,
customer_id: Optional[int] = None,
contact_id: Optional[int] = None,
ticket_id: Optional[int] = None,
only_mine: bool = False,
include_deleted: bool = False
@ -34,7 +36,20 @@ async def get_conversations(
if not include_deleted:
where_clauses.append("deleted_at IS NULL")
if customer_id:
if contact_id:
contact_customer_ids = get_contact_customer_ids(contact_id)
if customer_id is not None:
if customer_id not in contact_customer_ids:
return []
contact_customer_ids = [customer_id]
if not contact_customer_ids:
return []
placeholders = ",".join(["%s"] * len(contact_customer_ids))
where_clauses.append(f"customer_id IN ({placeholders})")
params.extend(contact_customer_ids)
elif customer_id:
where_clauses.append("customer_id = %s")
params.append(customer_id)

View File

@ -6,16 +6,18 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
from app.core.auth_service import AuthService
from app.core.config import settings
from app.core.database import execute_query_single
import logging
logger = logging.getLogger(__name__)
security = HTTPBearer()
security = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security)
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> dict:
"""
Dependency to get current authenticated user from JWT token
@ -25,7 +27,13 @@ async def get_current_user(
async def my_endpoint(current_user: dict = Depends(get_current_user)):
...
"""
token = credentials.credentials
token = credentials.credentials if credentials else request.cookies.get("access_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify token
payload = AuthService.verify_token(token)
@ -41,14 +49,29 @@ async def get_current_user(
user_id = int(payload.get("sub"))
username = payload.get("username")
is_superadmin = payload.get("is_superadmin", False)
is_shadow_admin = payload.get("shadow_admin", False)
token_jti = payload.get("jti")
# Add IP address to user info
ip_address = request.client.host if request.client else None
if is_shadow_admin:
return {
"id": user_id,
"username": username,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True,
"is_2fa_enabled": True,
"ip_address": ip_address,
"token_jti": token_jti,
"permissions": AuthService.get_all_permissions()
}
# Get additional user details from database
from app.core.database import execute_query
user_details = execute_query_single(
"SELECT email, full_name FROM users WHERE id = %s",
"SELECT email, full_name, is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,))
return {
@ -57,7 +80,10 @@ async def get_current_user(
"email": user_details.get('email') if user_details else None,
"full_name": user_details.get('full_name') if user_details else None,
"is_superadmin": is_superadmin,
"is_shadow_admin": False,
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
"ip_address": ip_address,
"token_jti": token_jti,
"permissions": AuthService.get_user_permissions(user_id)
}
@ -70,7 +96,7 @@ async def get_optional_user(
Dependency to get current user if authenticated, None otherwise
Allows endpoints that work both with and without authentication
"""
if not credentials:
if not credentials and not request.cookies.get("access_token"):
return None
try:

View File

@ -2,22 +2,26 @@
Authentication Service - Håndterer login, JWT tokens, password hashing
Adapted from OmniSync for BMC Hub
"""
from typing import Optional, Dict, List
from typing import Optional, Dict, List, Tuple
from datetime import datetime, timedelta
import hashlib
import secrets
import jwt
from app.core.database import execute_query, execute_insert, execute_update
import pyotp
from passlib.context import CryptContext
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# JWT Settings
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production')
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"], deprecated="auto")
class AuthService:
"""Service for authentication and authorization"""
@ -25,18 +29,141 @@ class AuthService:
@staticmethod
def hash_password(password: str) -> str:
"""
Hash password using SHA256
I produktion: Brug bcrypt eller argon2!
Hash password using bcrypt
"""
return hashlib.sha256(password.encode()).hexdigest()
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash"""
return AuthService.hash_password(plain_password) == hashed_password
if not hashed_password:
return False
try:
if not hashed_password.startswith("$"):
return False
return pwd_context.verify(plain_password, hashed_password)
except Exception:
return False
@staticmethod
def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str:
def verify_legacy_sha256(plain_password: str, hashed_password: str) -> bool:
"""Verify legacy SHA256 hash and upgrade when used"""
if not hashed_password or len(hashed_password) != 64:
return False
try:
return hashlib.sha256(plain_password.encode()).hexdigest() == hashed_password
except Exception:
return False
@staticmethod
def upgrade_password_hash(user_id: int, plain_password: str):
"""Upgrade legacy password hash to bcrypt"""
new_hash = AuthService.hash_password(plain_password)
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(new_hash, user_id)
)
@staticmethod
def verify_totp_code(secret: str, code: str) -> bool:
"""Verify TOTP code"""
if not secret or not code:
return False
try:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
@staticmethod
def generate_2fa_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
@staticmethod
def get_2fa_provisioning_uri(username: str, secret: str) -> str:
"""Generate provisioning URI for authenticator apps"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name="BMC Hub")
@staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)"""
secret = AuthService.generate_2fa_secret()
execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(secret, user_id)
)
return {
"secret": secret,
"provisioning_uri": AuthService.get_2fa_provisioning_uri(username, secret)
}
@staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code"""
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = TRUE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code"""
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def admin_reset_user_2fa(user_id: int) -> bool:
"""Admin reset: disable 2FA and remove TOTP secret without OTP"""
user = execute_query_single(
"SELECT user_id FROM users WHERE user_id = %s",
(user_id,)
)
if not user:
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def create_access_token(
user_id: int,
username: str,
is_superadmin: bool = False,
is_shadow_admin: bool = False
) -> str:
"""
Create JWT access token
@ -55,6 +182,7 @@ class AuthService:
"sub": str(user_id),
"username": username,
"is_superadmin": is_superadmin,
"shadow_admin": is_shadow_admin,
"exp": expire,
"iat": datetime.utcnow(),
"jti": jti
@ -62,8 +190,9 @@ class AuthService:
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Store session for token revocation
execute_insert(
# Store session for token revocation (skip for shadow admin)
if not is_shadow_admin:
execute_update(
"""INSERT INTO sessions (user_id, token_jti, expires_at)
VALUES (%s, %s, %s)""",
(user_id, jti, expire)
@ -82,6 +211,9 @@ class AuthService:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("shadow_admin"):
return payload
# Check if token is revoked
jti = payload.get('jti')
if jti:
@ -102,7 +234,12 @@ class AuthService:
return None
@staticmethod
def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]:
def authenticate_user(
username: str,
password: str,
ip_address: Optional[str] = None,
otp_code: Optional[str] = None
) -> Tuple[Optional[Dict], Optional[str]]:
"""
Authenticate user with username/password
@ -114,38 +251,79 @@ class AuthService:
Returns:
User dict if successful, None otherwise
"""
# Normalize username once (used by both normal and shadow login paths)
shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower()
request_username = (username or "").strip().lower()
# Get user
user = execute_query_single(
"""SELECT id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until
"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
is_2fa_enabled, totp_secret, last_2fa_at
FROM users
WHERE username = %s OR email = %s""",
(username, username))
if not user:
# Shadow Admin fallback (only when no regular user matches)
if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username:
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
logger.error("❌ Shadow admin enabled but not configured")
return None, "Shadow admin not configured"
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
return None, "Invalid username or password"
if not settings.AUTH_DISABLE_2FA:
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
return None, "Invalid 2FA code"
else:
logger.warning(f"⚠️ 2FA disabled via settings for shadow admin login from IP: {ip_address}")
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
return {
"user_id": 0,
"username": settings.SHADOW_ADMIN_USERNAME,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True,
"is_2fa_enabled": True,
"has_2fa_configured": True
}, None
logger.warning(f"❌ Login failed: User not found - {username}")
return None
return None, "Invalid username or password"
# Check if account is active
if not user['is_active']:
logger.warning(f"❌ Login failed: Account disabled - {username}")
return None
return None, "Account disabled"
# Check if account is locked
if user['locked_until']:
locked_until = user['locked_until']
if datetime.now() < locked_until:
logger.warning(f"❌ Login failed: Account locked - {username}")
return None
return None, "Account locked"
else:
# Unlock account
execute_update(
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s",
(user['id'],)
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE user_id = %s",
(user['user_id'],)
)
# Verify password
if not AuthService.verify_password(password, user['password_hash']):
if AuthService.verify_password(password, user['password_hash']):
pass
elif AuthService.verify_legacy_sha256(password, user['password_hash']):
AuthService.upgrade_password_hash(user['user_id'], password)
else:
# Increment failed attempts
failed_attempts = user['failed_login_attempts'] + 1
@ -155,18 +333,44 @@ class AuthService:
execute_update(
"""UPDATE users
SET failed_login_attempts = %s, locked_until = %s
WHERE id = %s""",
(failed_attempts, locked_until, user['id'])
WHERE user_id = %s""",
(failed_attempts, locked_until, user['user_id'])
)
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
else:
execute_update(
"UPDATE users SET failed_login_attempts = %s WHERE id = %s",
(failed_attempts, user['id'])
"UPDATE users SET failed_login_attempts = %s WHERE user_id = %s",
(failed_attempts, user['user_id'])
)
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None
return None, "Invalid username or password"
# 2FA check (only once per grace window)
if settings.AUTH_DISABLE_2FA:
logger.warning(f"⚠️ 2FA disabled via settings for login: {username}")
elif user.get('is_2fa_enabled'):
if not user.get('totp_secret'):
return None, "2FA not configured"
last_2fa_at = user.get("last_2fa_at")
grace_hours = max(1, int(settings.TWO_FA_GRACE_HOURS))
grace_window = timedelta(hours=grace_hours)
now = datetime.utcnow()
within_grace = bool(last_2fa_at and (now - last_2fa_at) < grace_window)
if not within_grace:
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
# Success! Reset failed attempts and update last login
execute_update(
@ -174,29 +378,50 @@ class AuthService:
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(user['id'],)
WHERE user_id = %s""",
(user['user_id'],)
)
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
return {
'user_id': user['id'],
'user_id': user['user_id'],
'username': user['username'],
'email': user['email'],
'full_name': user['full_name'],
'is_superadmin': bool(user['is_superadmin'])
}
'is_superadmin': bool(user['is_superadmin']),
'is_shadow_admin': False,
'is_2fa_enabled': bool(user.get('is_2fa_enabled')),
'has_2fa_configured': bool(user.get('totp_secret'))
}, None
@staticmethod
def revoke_token(jti: str, user_id: int):
def revoke_token(jti: str, user_id: int, is_shadow_admin: bool = False):
"""Revoke a JWT token"""
if is_shadow_admin:
logger.info("🔒 Shadow admin logout - no session to revoke")
return
execute_update(
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
(jti, user_id)
)
logger.info(f"🔒 Token revoked for user {user_id}")
@staticmethod
def get_all_permissions() -> List[str]:
"""Get all permission codes"""
perms = execute_query("SELECT code FROM permissions")
return [p['code'] for p in perms] if perms else []
@staticmethod
def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled"""
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
return bool(user and user.get("is_2fa_enabled"))
@staticmethod
def get_user_permissions(user_id: int) -> List[str]:
"""
@ -210,13 +435,12 @@ class AuthService:
"""
# Check if user is superadmin first
user = execute_query_single(
"SELECT is_superadmin FROM users WHERE id = %s",
"SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,))
# Superadmins have all permissions
if user and user['is_superadmin']:
all_perms = execute_query_single("SELECT code FROM permissions")
return [p['code'] for p in all_perms] if all_perms else []
return AuthService.get_all_permissions()
# Get permissions through groups
perms = execute_query("""
@ -242,8 +466,8 @@ class AuthService:
True if user has permission
"""
# Superadmins have all permissions
user = execute_query(
"SELECT is_superadmin FROM users WHERE id = %s",
user = execute_query_single(
"SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,))
if user and user['is_superadmin']:
@ -279,7 +503,7 @@ class AuthService:
user_id = execute_insert(
"""INSERT INTO users
(username, email, password_hash, full_name, is_superadmin)
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
VALUES (%s, %s, %s, %s, %s) RETURNING user_id""",
(username, email, password_hash, full_name, is_superadmin)
)
@ -292,7 +516,7 @@ class AuthService:
password_hash = AuthService.hash_password(new_password)
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(password_hash, user_id)
)

View File

@ -25,11 +25,32 @@ class Settings(BaseSettings):
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12
# API Gateway (Product catalog)
APIGW_BASE_URL: str = "https://apigateway.bmcnetworks.dk"
APIGATEWAY_URL: str = ""
APIGW_TOKEN: str = ""
APIGW_TIMEOUT_SECONDS: int = 12
# Security
SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
COOKIE_SECURE: bool = False
COOKIE_SAMESITE: str = "lax"
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
# Shadow Admin (emergency access)
SHADOW_ADMIN_ENABLED: bool = False
SHADOW_ADMIN_USERNAME: str = "shadowadmin"
SHADOW_ADMIN_PASSWORD: str = ""
SHADOW_ADMIN_TOTP_SECRET: str = ""
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
# 2FA grace period (hours) before re-prompting
TWO_FA_GRACE_HOURS: int = 24
AUTH_DISABLE_2FA: bool = False
# Logging
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/app.log"
@ -42,9 +63,23 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True
# Nextcloud Integration
NEXTCLOUD_READ_ONLY: bool = True
NEXTCLOUD_DRY_RUN: bool = True
NEXTCLOUD_TIMEOUT_SECONDS: int = 15
NEXTCLOUD_CACHE_TTL_SECONDS: int = 300
NEXTCLOUD_ENCRYPTION_KEY: str = ""
# Wiki.js Integration
WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk"
WIKI_API_TOKEN: str = ""
WIKI_API_KEY: str = ""
WIKI_TIMEOUT_SECONDS: int = 12
WIKI_READ_ONLY: bool = True
# Ollama LLM
OLLAMA_ENDPOINT: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3.2:3b"
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2"
# Email System Configuration
# IMAP Settings
@ -102,6 +137,12 @@ class Settings(BaseSettings):
TIMETRACKING_ECONOMIC_LAYOUT: int = 19 # e-conomic invoice layout number (default: 19 = Danish standard)
TIMETRACKING_ECONOMIC_PRODUCT: str = "1000" # e-conomic product number for time entries (default: 1000)
# Global Ordre Module Safety Flags
ORDRE_ECONOMIC_READ_ONLY: bool = True
ORDRE_ECONOMIC_DRY_RUN: bool = True
ORDRE_ECONOMIC_LAYOUT: int = 19
ORDRE_ECONOMIC_PRODUCT: str = "1000"
# Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = ""
@ -111,6 +152,12 @@ class Settings(BaseSettings):
SIMPLYCRM_URL: str = ""
SIMPLYCRM_USERNAME: str = ""
SIMPLYCRM_API_KEY: str = ""
SIMPLYCRM_TICKET_MODULE: str = "Tickets"
SIMPLYCRM_TICKET_COMMENT_MODULE: str = "ModComments"
SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD: str = "related_to"
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
# Backup System Configuration
BACKUP_ENABLED: bool = True
@ -146,6 +193,62 @@ class Settings(BaseSettings):
MATTERMOST_ENABLED: bool = False
MATTERMOST_CHANNEL: str = ""
# Email Sending (SMTP) Configuration
EMAIL_SMTP_HOST: str = ""
EMAIL_SMTP_PORT: int = 587
EMAIL_SMTP_USER: str = ""
EMAIL_SMTP_PASSWORD: str = ""
EMAIL_SMTP_USE_TLS: bool = True
EMAIL_SMTP_FROM_ADDRESS: str = "noreply@bmcnetworks.dk"
EMAIL_SMTP_FROM_NAME: str = "BMC Hub"
# Reminder System Configuration
REMINDERS_ENABLED: bool = False
REMINDERS_EMAIL_ENABLED: bool = False
REMINDERS_MATTERMOST_ENABLED: bool = False
REMINDERS_DRY_RUN: bool = True # SAFETY: Log without sending if true
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
REMINDERS_QUEUE_BATCH_SIZE: int = 10
# AnyDesk Remote Support Integration
ANYDESK_LICENSE_ID: str = ""
ANYDESK_API_TOKEN: str = ""
ANYDESK_PASSWORD: str = ""
ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
# Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
TELEFONI_IP_WHITELIST: str = "172.16.31.0/24" # CSV of IPs/CIDRs, e.g. "192.168.1.0/24,10.0.0.10"
# ESET Integration
ESET_ENABLED: bool = False
ESET_API_URL: str = "https://eu.device-management.eset.systems"
ESET_IAM_URL: str = "https://eu.business-account.iam.eset.systems"
ESET_INCIDENTS_URL: str = "https://eu.incident-management.eset.systems"
ESET_USERNAME: str = ""
ESET_PASSWORD: str = ""
ESET_OAUTH_CLIENT_ID: str = ""
ESET_OAUTH_CLIENT_SECRET: str = ""
ESET_OAUTH_SCOPE: str = ""
ESET_READ_ONLY: bool = True
ESET_TIMEOUT_SECONDS: int = 30
ESET_SYNC_ENABLED: bool = True
ESET_SYNC_INTERVAL_MINUTES: int = 120
ESET_INCIDENTS_ENABLED: bool = True
# SMS Integration (CPSMS)
SMS_API_KEY: str = ""
SMS_USERNAME: str = ""
SMS_SENDER: str = "BMC Networks"
SMS_WEBHOOK_SECRET: str = ""
# Dev-only shortcuts
DEV_ALLOW_ARCHIVED_IMPORT: bool = False
# Deployment Configuration (used by Docker/Podman)
POSTGRES_USER: str = "bmc_hub"
POSTGRES_PASSWORD: str = "bmc_hub"

32
app/core/contact_utils.py Normal file
View File

@ -0,0 +1,32 @@
"""
Contact helpers for resolving linked customers.
"""
from typing import List, Optional
from app.core.database import execute_query
def get_contact_customer_ids(contact_id: int) -> List[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, customer_id
"""
rows = execute_query(query, (contact_id,)) or []
return [row["customer_id"] for row in rows]
def get_primary_customer_id(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, customer_id
LIMIT 1
"""
rows = execute_query(query, (contact_id,)) or []
if not rows:
return None
return rows[0]["customer_id"]

31
app/core/crypto.py Normal file
View File

@ -0,0 +1,31 @@
"""
Crypto helpers for encrypting/decrypting secrets at rest.
"""
import logging
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
logger = logging.getLogger(__name__)
def _get_fernet() -> Fernet:
if not settings.NEXTCLOUD_ENCRYPTION_KEY:
raise ValueError("NEXTCLOUD_ENCRYPTION_KEY not configured")
return Fernet(settings.NEXTCLOUD_ENCRYPTION_KEY.encode())
def encrypt_secret(value: str) -> str:
fernet = _get_fernet()
return fernet.encrypt(value.encode()).decode()
def decrypt_secret(value: str) -> Optional[str]:
try:
fernet = _get_fernet()
return fernet.decrypt(value.encode()).decode()
except (InvalidToken, ValueError) as exc:
logger.error("❌ Nextcloud credential decryption failed: %s", exc)
return None

View File

@ -68,19 +68,18 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
cursor.execute(query, params)
# Auto-detect write operations and commit
query_upper = query.strip().upper()
is_write = query_upper.startswith(('INSERT', 'UPDATE', 'DELETE'))
# Robust detection handling comments and whitespace
clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper()
is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT'))
if is_write:
conn.commit()
# Only fetch if there are results to fetch
# (SELECT queries or INSERT/UPDATE/DELETE with RETURNING clause)
if fetch and (not is_write or 'RETURNING' in query_upper):
# Only fetch if there are results to fetch (cursor.description is not None)
if cursor.description:
return cursor.fetchall()
elif is_write:
return cursor.rowcount
return []
except Exception as e:
conn.rollback()
logger.error(f"Query error: {e}")

View File

@ -34,6 +34,7 @@ class CustomerBase(BaseModel):
postal_code: Optional[str] = None
country: Optional[str] = "DK"
website: Optional[str] = None
wiki_slug: Optional[str] = None
is_active: Optional[bool] = True
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
@ -53,9 +54,11 @@ class CustomerUpdate(BaseModel):
postal_code: Optional[str] = None
country: Optional[str] = None
website: Optional[str] = None
wiki_slug: Optional[str] = None
is_active: Optional[bool] = None
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
department: Optional[str] = None
class ContactCreate(BaseModel):
@ -568,11 +571,15 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
"SELECT * FROM customers WHERE id = %s",
(customer_id,))
return updated
except Exception as e:
logger.error(f"❌ Failed to update customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/customers/{customer_id}")
async def patch_customer(customer_id: int, update: CustomerUpdate):
"""Partially update customer information (same as PUT)"""
return await update_customer(customer_id, update)
@router.get("/customers/{customer_id}/data-consistency")
async def check_customer_data_consistency(customer_id: int):
@ -962,6 +969,93 @@ async def get_customer_contacts(customer_id: int):
return rows or []
@router.get("/customers/{customer_id}/kontakt")
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
"""Get unified contact communication history (calls + SMS) for all company contacts."""
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")
sms_table_exists = execute_query_single("SELECT to_regclass('public.sms_messages') AS name")
has_sms_table = bool(sms_table_exists and sms_table_exists.get("name"))
if has_sms_table:
query = """
SELECT * FROM (
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
NULL::text AS sms_status
FROM telefoni_opkald t
JOIN contacts c ON c.id = t.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = t.bruger_id
UNION ALL
SELECT
'sms' AS type,
s.id::text AS event_id,
s.created_at AS happened_at,
NULL::text AS direction,
s.recipient AS number,
s.message,
NULL::int AS duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
s.status AS sms_status
FROM sms_messages s
JOIN contacts c ON c.id = s.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = s.bruger_id
) x
ORDER BY x.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (customer_id, customer_id, limit))
else:
query = """
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
NULL::text AS sms_status
FROM telefoni_opkald t
JOIN contacts c ON c.id = t.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = t.bruger_id
ORDER BY t.started_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (customer_id, limit))
return {"items": rows or []}
@router.post("/customers/{customer_id}/contacts")
async def create_customer_contact(customer_id: int, contact: ContactCreate):
"""Create a new contact for a customer"""

File diff suppressed because it is too large Load Diff

View File

@ -136,7 +136,7 @@ async function loadStages() {
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=10000');
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []);
@ -158,20 +158,20 @@ function renderBoard() {
return;
}
board.innerHTML = stages.map(stage => {
const items = opportunities.filter(o => o.stage_id === stage.id);
const cards = items.map(o => `
const renderCards = (items, stage) => {
return items.map(o => `
<div class="pipeline-card">
<div class="d-flex justify-content-between align-items-start">
<h6>${escapeHtml(o.title)}</h6>
<span class="badge" style="background:${stage.color}; color: white;">${o.probability || 0}%</span>
<h6>${escapeHtml(o.titel || '')}</h6>
<span class="badge" style="background:${(stage && stage.color) || '#6c757d'}; color: white;">${o.pipeline_probability || 0}%</span>
</div>
<div class="pipeline-meta">${escapeHtml(o.customer_name || '-')}
· ${formatCurrency(o.amount, o.currency)}
· ${formatCurrency(o.pipeline_amount, 'DKK')}
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<select class="form-select form-select-sm" onchange="changeStage(${o.id}, this.value)">
${stages.map(s => `<option value="${s.id}" ${s.id === o.stage_id ? 'selected' : ''}>${s.name}</option>`).join('')}
<option value="">Ikke sat</option>
${stages.map(s => `<option value="${s.id}" ${Number(s.id) === Number(o.pipeline_stage_id) ? 'selected' : ''}>${s.name}</option>`).join('')}
</select>
<button class="btn btn-sm btn-outline-primary ms-2" onclick="goToDetail(${o.id})">
<i class="bi bi-arrow-right"></i>
@ -179,24 +179,52 @@ function renderBoard() {
</div>
</div>
`).join('');
};
return `
const unassignedItems = opportunities.filter(o => !o.pipeline_stage_id);
const columns = [];
if (unassignedItems.length > 0) {
columns.push(`
<div class="pipeline-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Ikke sat</strong>
<span class="small text-muted">${unassignedItems.length}</span>
</div>
${renderCards(unassignedItems, null)}
</div>
`);
}
stages.forEach(stage => {
const items = opportunities.filter(o => Number(o.pipeline_stage_id) === Number(stage.id));
if (!items.length) return;
columns.push(`
<div class="pipeline-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>${stage.name}</strong>
<span class="small text-muted">${items.length}</span>
</div>
${cards || '<div class="text-muted small">Ingen muligheder</div>'}
${renderCards(items, stage)}
</div>
`;
}).join('');
`);
});
if (!columns.length) {
board.innerHTML = '<div class="pipeline-column"><div class="text-muted small">Ingen muligheder i pipeline endnu</div></div>';
return;
}
board.innerHTML = columns.join('');
}
async function changeStage(opportunityId, stageId) {
const response = await fetch(`/api/v1/opportunities/${opportunityId}/stage`, {
const response = await fetch(`/api/v1/sag/${opportunityId}/pipeline`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_id: parseInt(stageId) })
body: JSON.stringify({ stage_id: stageId ? parseInt(stageId, 10) : null })
});
if (!response.ok) {
@ -231,6 +259,7 @@ async function createOpportunity() {
const response = await fetch('/api/v1/opportunities', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
@ -240,12 +269,27 @@ async function createOpportunity() {
return;
}
const createdCase = await response.json();
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities();
if (createdCase?.id && (payload.stage_id || payload.amount)) {
await fetch(`/api/v1/sag/${createdCase.id}/pipeline`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stage_id: payload.stage_id || null,
amount: payload.amount || null
})
});
await loadOpportunities();
}
}
function goToDetail(id) {
window.location.href = `/opportunities/${id}`;
window.location.href = `/sag/${id}`;
}
function formatCurrency(value, currency) {

View File

@ -1,5 +1,5 @@
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 Dict, Any, List
import logging
@ -15,49 +15,95 @@ async def get_dashboard_stats():
try:
logger.info("📊 Fetching dashboard stats...")
# 1. Customer Counts
# 1. Customer Counts & Trends
logger.info("Fetching customer count...")
customer_res = execute_query_single("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL")
customer_count = customer_res['count'] if customer_res else 0
# 2. Contact Counts
logger.info("Fetching contact count...")
contact_res = execute_query_single("SELECT COUNT(*) as count FROM contacts")
contact_count = contact_res['count'] if contact_res else 0
# 3. Vendor Counts
logger.info("Fetching vendor count...")
vendor_res = execute_query_single("SELECT COUNT(*) as count FROM vendors")
vendor_count = vendor_res['count'] if vendor_res else 0
# 4. Recent Customers (Real "Activity")
logger.info("Fetching recent customers...")
recent_customers = execute_query_single("""
SELECT id, name, created_at, 'customer' as type
# New customers this month
new_customers_res = execute_query_single("""
SELECT COUNT(*) as count
FROM customers
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 5
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
""")
new_customers_this_month = new_customers_res['count'] if new_customers_res else 0
# 5. Vendor Categories Distribution
logger.info("Fetching vendor distribution...")
vendor_categories = execute_query("""
SELECT category, COUNT(*) as count
FROM vendors
GROUP BY category
# Previous month's new customers for trend calculation
prev_month_customers_res = execute_query_single("""
SELECT COUNT(*) as count
FROM customers
WHERE deleted_at IS NULL
AND created_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND created_at < DATE_TRUNC('month', CURRENT_DATE)
""")
prev_month_customers = prev_month_customers_res['count'] if prev_month_customers_res else 0
customer_growth_pct = 0
if prev_month_customers > 0:
customer_growth_pct = round(((new_customers_this_month - prev_month_customers) / prev_month_customers) * 100, 1)
elif new_customers_this_month > 0:
customer_growth_pct = 100
# 2. Ticket Counts
logger.info("Fetching ticket stats...")
ticket_res = execute_query_single("""
SELECT COUNT(*) as total_count,
COUNT(*) FILTER (WHERE status IN ('open', 'in_progress')) as open_count,
COUNT(*) FILTER (WHERE priority = 'high' AND status IN ('open', 'in_progress')) as urgent_count
FROM tticket_tickets
""")
ticket_count = ticket_res['open_count'] if ticket_res else 0
urgent_ticket_count = ticket_res['urgent_count'] if ticket_res else 0
# 3. Hardware Count
logger.info("Fetching hardware count...")
hardware_res = execute_query_single("SELECT COUNT(*) as count FROM hardware")
hardware_count = hardware_res['count'] if hardware_res else 0
# 4. Revenue (from fixed price billing periods - current month)
logger.info("Fetching revenue stats...")
revenue_res = execute_query_single("""
SELECT COALESCE(SUM(base_amount + COALESCE(overtime_amount, 0)), 0) as total
FROM fixed_price_billing_periods
WHERE period_start >= DATE_TRUNC('month', CURRENT_DATE)
AND period_start < DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')
""")
current_revenue = float(revenue_res['total']) if revenue_res and revenue_res['total'] else 0
# Previous month revenue for trend
prev_revenue_res = execute_query_single("""
SELECT COALESCE(SUM(base_amount + COALESCE(overtime_amount, 0)), 0) as total
FROM fixed_price_billing_periods
WHERE period_start >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND period_start < DATE_TRUNC('month', CURRENT_DATE)
""")
prev_revenue = float(prev_revenue_res['total']) if prev_revenue_res and prev_revenue_res['total'] else 0
revenue_growth_pct = 0
if prev_revenue > 0:
revenue_growth_pct = round(((current_revenue - prev_revenue) / prev_revenue) * 100, 1)
elif current_revenue > 0:
revenue_growth_pct = 100
logger.info("✅ Dashboard stats fetched successfully")
return {
"counts": {
"customers": customer_count,
"contacts": contact_count,
"vendors": vendor_count
"customers": {
"total": customer_count,
"new_this_month": new_customers_this_month,
"growth_pct": customer_growth_pct
},
"recent_activity": recent_customers or [],
"vendor_distribution": vendor_categories or [],
"system_status": "online"
"tickets": {
"open_count": ticket_count,
"urgent_count": urgent_ticket_count
},
"hardware": {
"total": hardware_count
},
"revenue": {
"current_month": current_revenue,
"growth_pct": revenue_growth_pct
}
}
except Exception as e:
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
@ -124,6 +170,46 @@ async def global_search(q: str):
return {"customers": [], "contacts": [], "vendors": []}
@router.get("/search/sag", response_model=List[Dict[str, Any]])
async def search_sag(q: str):
"""
Search for cases (sager) with customer information
"""
if not q or len(q) < 2:
return []
search_term = f"%{q}%"
try:
# Search cases with customer names
sager = execute_query("""
SELECT
s.id,
s.titel,
s.beskrivelse,
s.status,
s.created_at,
s.customer_id,
c.name as customer_name
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND (
CAST(s.id AS TEXT) ILIKE %s OR
s.titel ILIKE %s OR
s.beskrivelse ILIKE %s OR
c.name ILIKE %s
)
ORDER BY s.created_at DESC
LIMIT 20
""", (search_term, search_term, search_term, search_term))
return sager or []
except Exception as e:
logger.error(f"❌ Error searching sager: {e}", exc_info=True)
return []
@router.get("/live-stats", response_model=Dict[str, Any])
async def get_live_stats():
"""
@ -173,10 +259,41 @@ async def get_live_stats():
}
@router.get("/reminders/upcoming", response_model=List[Dict[str, Any]])
async def get_upcoming_reminders():
"""
Get upcoming reminders for the dashboard calendar widget
"""
try:
# Get active reminders with next check date within 7 days
reminders = execute_query("""
SELECT
r.id,
r.sag_id,
r.title,
r.next_check_at as due_date,
r.priority,
s.titel as case_title
FROM sag_reminders r
LEFT JOIN sag_sager s ON r.sag_id = s.id
WHERE r.is_active = true
AND r.deleted_at IS NULL
AND r.next_check_at IS NOT NULL
AND r.next_check_at <= CURRENT_DATE + INTERVAL '7 days'
ORDER BY r.next_check_at ASC
LIMIT 10
""")
return reminders or []
except Exception as e:
logger.error(f"❌ Error fetching upcoming reminders: {e}", exc_info=True)
return []
@router.get("/recent-activity", response_model=List[Dict[str, Any]])
async def get_recent_activity():
"""
Get recent activity across the system for the sidebar
Get recent activity across the system for the dashboard feed
"""
try:
activities = []
@ -187,37 +304,38 @@ async def get_recent_activity():
FROM customers
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 3
LIMIT 5
""")
# Recent contacts
recent_contacts = execute_query("""
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color
FROM contacts
# Recent tickets
recent_tickets = execute_query("""
SELECT id, subject as name, created_at, 'ticket' as activity_type, 'bi-ticket' as icon, 'warning' as color
FROM tticket_tickets
ORDER BY created_at DESC
LIMIT 3
LIMIT 5
""")
# Recent vendors
recent_vendors = execute_query("""
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color
FROM vendors
# Recent cases (sager)
recent_cases = execute_query("""
SELECT id, titel as name, created_at, 'case' as activity_type, 'bi-folder' as icon, 'info' as color
FROM sag_sager
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 2
LIMIT 5
""")
# Combine all activities
if recent_customers:
activities.extend(recent_customers)
if recent_contacts:
activities.extend(recent_contacts)
if recent_vendors:
activities.extend(recent_vendors)
if recent_tickets:
activities.extend(recent_tickets)
if recent_cases:
activities.extend(recent_cases)
# Sort by created_at and limit
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return activities[:10]
return activities[:15]
except Exception as e:
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
return []

View File

@ -1,16 +1,106 @@
from fastapi import APIRouter, Request
import logging
from fastapi import APIRouter, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.core.database import execute_query_single
from fastapi.responses import HTMLResponse, RedirectResponse
from app.core.database import execute_query, execute_query_single
router = APIRouter()
templates = Jinja2Templates(directory="app")
logger = logging.getLogger(__name__)
_DISALLOWED_DASHBOARD_PATHS = {
"/dashboard/default",
"/dashboard/default/clear",
}
def _sanitize_dashboard_path(value: str) -> str:
if not value:
return ""
candidate = value.strip()
if not candidate.startswith("/"):
return ""
if candidate.startswith("/api"):
return ""
if candidate.startswith("//"):
return ""
if candidate in _DISALLOWED_DASHBOARD_PATHS:
return ""
return candidate
def _get_user_default_dashboard(user_id: int) -> str:
try:
row = execute_query_single(
"""
SELECT default_dashboard_path
FROM user_dashboard_preferences
WHERE user_id = %s
""",
(user_id,)
)
return _sanitize_dashboard_path((row or {}).get("default_dashboard_path", ""))
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ user_dashboard_preferences table not found; using fallback default dashboard")
return ""
raise
def _get_user_group_names(user_id: int):
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,)
)
return [r["name"] for r in (rows or []) if r.get("name")]
def _is_technician_group(group_names) -> bool:
return any(
"technician" in group or "teknik" in group
for group in (group_names or [])
)
def _is_sales_group(group_names) -> bool:
return any(
"sales" in group or "salg" in group
for group in (group_names or [])
)
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""
Render the dashboard page
"""
user_id = getattr(request.state, "user_id", None)
preferred_dashboard = ""
if user_id:
preferred_dashboard = _get_user_default_dashboard(int(user_id))
if not preferred_dashboard:
preferred_dashboard = _sanitize_dashboard_path(request.cookies.get("bmc_default_dashboard", ""))
if preferred_dashboard and preferred_dashboard != "/":
return RedirectResponse(url=preferred_dashboard, status_code=302)
if user_id:
group_names = _get_user_group_names(int(user_id))
if _is_technician_group(group_names):
return RedirectResponse(
url=f"/ticket/dashboard/technician/v1?technician_user_id={int(user_id)}",
status_code=302
)
if _is_sales_group(group_names):
return RedirectResponse(url="/dashboard/sales", status_code=302)
# Fetch count of unknown billing worklogs
unknown_query = """
SELECT COUNT(*) as count
@ -60,3 +150,197 @@ async def dashboard(request: Request):
"bankruptcy_alerts": bankruptcy_alerts
})
@router.get("/dashboard/sales", response_class=HTMLResponse)
async def sales_dashboard(request: Request):
pipeline_stats_query = """
SELECT
COUNT(*) FILTER (WHERE s.status = 'åben') AS open_count,
COUNT(*) FILTER (WHERE s.status = 'lukket') AS closed_count,
COALESCE(SUM(COALESCE(s.pipeline_amount, 0)) FILTER (WHERE s.status = 'åben'), 0) AS open_value,
COALESCE(AVG(COALESCE(s.pipeline_probability, 0)) FILTER (WHERE s.status = 'åben'), 0) AS avg_probability
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
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 LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
"""
recent_opportunities_query = """
SELECT
s.id,
s.titel,
s.status,
s.pipeline_amount,
s.pipeline_probability,
ps.name AS pipeline_stage,
s.deadline,
s.created_at,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
WHERE s.deleted_at IS NULL
AND (
s.template_key = 'pipeline'
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 LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.created_at DESC
LIMIT 12
"""
due_soon_query = """
SELECT
s.id,
s.titel,
s.deadline,
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
COALESCE(u.full_name, u.username, 'Ingen') AS owner_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '14 days')
AND (
s.template_key = 'pipeline'
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 LOWER(t.name) = 'pipeline'
)
OR EXISTS (
SELECT 1
FROM sag_tags st
WHERE st.sag_id = s.id
AND st.deleted_at IS NULL
AND LOWER(st.tag_navn) = 'pipeline'
)
)
ORDER BY s.deadline ASC
LIMIT 8
"""
pipeline_stats = execute_query_single(pipeline_stats_query) or {}
recent_opportunities = execute_query(recent_opportunities_query) or []
due_soon = execute_query(due_soon_query) or []
return templates.TemplateResponse(
"dashboard/frontend/sales.html",
{
"request": request,
"pipeline_stats": pipeline_stats,
"recent_opportunities": recent_opportunities,
"due_soon": due_soon,
"default_dashboard": _get_user_default_dashboard(getattr(request.state, "user_id", 0) or 0)
or request.cookies.get("bmc_default_dashboard", "")
}
)
@router.post("/dashboard/default")
async def set_default_dashboard(
request: Request,
dashboard_path: str = Form(...),
redirect_to: str = Form(default="/")
):
safe_path = _sanitize_dashboard_path(dashboard_path)
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
response = RedirectResponse(url=safe_redirect, status_code=303)
if safe_path:
if user_id:
try:
execute_query(
"""
INSERT INTO user_dashboard_preferences (user_id, default_dashboard_path, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (user_id)
DO UPDATE SET
default_dashboard_path = EXCLUDED.default_dashboard_path,
updated_at = CURRENT_TIMESTAMP
""",
(int(user_id), safe_path)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not persist dashboard preference in DB (table missing); cookie fallback still active")
else:
raise
response.set_cookie(
key="bmc_default_dashboard",
value=safe_path,
httponly=True,
samesite="Lax"
)
return response
@router.get("/dashboard/default")
async def set_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)
@router.post("/dashboard/default/clear")
async def clear_default_dashboard(
request: Request,
redirect_to: str = Form(default="/")
):
safe_redirect = _sanitize_dashboard_path(redirect_to) or "/"
user_id = getattr(request.state, "user_id", None)
if user_id:
try:
execute_query(
"DELETE FROM user_dashboard_preferences WHERE user_id = %s",
(int(user_id),)
)
except Exception as exc:
if "user_dashboard_preferences" in str(exc):
logger.warning("⚠️ Could not clear DB dashboard preference (table missing); cookie fallback still active")
else:
raise
response = RedirectResponse(url=safe_redirect, status_code=303)
response.delete_cookie("bmc_default_dashboard")
return response
@router.get("/dashboard/default/clear")
async def clear_default_dashboard_get_fallback():
return RedirectResponse(url="/settings#system", status_code=303)

View File

@ -2,22 +2,352 @@
{% block title %}Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Modern Dashboard Styling */
body {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
}
.dashboard-header {
background: linear-gradient(135deg, var(--accent) 0%, #1e5a8e 100%);
color: white;
padding: 2.5rem 0;
margin: -2rem -15px 2rem -15px;
border-radius: 0 0 24px 24px;
box-shadow: 0 10px 30px rgba(15, 76, 117, 0.15);
}
.dashboard-header h2 {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.dashboard-header p {
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stat Cards - Modern Gradient Design */
.stat-card {
position: relative;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
display: block;
background: white;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--accent), #3b82f6);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px rgba(15, 76, 117, 0.15);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
margin-bottom: 1.25rem;
}
.stat-card-icon.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card-icon.success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
}
.stat-card-icon.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.stat-card-icon.info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.stat-card-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 0.75rem;
}
.stat-card-value {
font-size: 2.5rem;
font-weight: 800;
color: #1e293b;
margin: 0.5rem 0 0.75rem 0;
line-height: 1;
}
.trend-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.trend-badge.positive {
background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%);
color: #166534;
}
.trend-badge.negative {
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
color: #991b1b;
}
.trend-badge.neutral {
background: #e2e8f0;
color: #64748b;
}
/* Content Cards */
.content-card {
background: white;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.8);
}
.card-header-modern {
padding: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
.card-header-modern h5 {
font-size: 1.125rem;
font-weight: 700;
color: #1e293b;
margin: 0;
}
/* Activity Items */
.activity-item {
padding: 1rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
transition: all 0.2s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
}
.activity-item:hover {
background: linear-gradient(90deg, rgba(99, 102, 241, 0.02) 0%, rgba(59, 130, 246, 0.05) 100%);
padding-left: 1.75rem;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.activity-icon.primary {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
color: #6366f1;
}
.activity-icon.warning {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.1) 0%, rgba(245, 87, 108, 0.1) 100%);
color: #f59e0b;
}
.activity-icon.info {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(6, 182, 212, 0.1) 100%);
color: #0ea5e9;
}
.activity-content {
flex: 1;
}
.activity-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 0.25rem;
}
.activity-time {
font-size: 0.8rem;
color: #94a3b8;
}
/* Reminders */
.reminder-item {
padding: 1rem;
border-left: 4px solid;
border-radius: 12px;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.reminder-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.reminder-item.priority-high {
border-left-color: #ef4444;
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, rgba(239, 68, 68, 0.02) 100%);
}
.reminder-item.priority-medium {
border-left-color: #f59e0b;
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, rgba(245, 158, 11, 0.02) 100%);
}
.reminder-item.priority-low {
border-left-color: #10b981;
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05) 0%, rgba(16, 185, 129, 0.02) 100%);
}
/* Quick Actions */
.quick-action-btn {
border-radius: 14px;
padding: 1rem 1.5rem;
font-weight: 600;
border: 2px solid transparent;
transition: all 0.3s ease;
background: linear-gradient(135deg, var(--accent) 0%, #1e5a8e 100%);
color: white;
}
.quick-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.25);
border-color: var(--accent);
color: white;
}
.quick-action-btn i {
font-size: 1.1rem;
}
/* Activity Feed Scrollbar */
.activity-feed {
max-height: 550px;
overflow-y: auto;
}
.activity-feed::-webkit-scrollbar {
width: 6px;
}
.activity-feed::-webkit-scrollbar-track {
background: #f1f5f9;
}
.activity-feed::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.activity-feed::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Skeleton Loading */
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 8px;
}
.skeleton-text {
height: 1rem;
margin-bottom: 0.5rem;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Alerts Modern Style */
.alert {
border-radius: 16px;
border: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: #94a3b8;
}
.empty-state i {
font-size: 3rem;
opacity: 0.3;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Dashboard</h2>
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
</div>
<div class="d-flex gap-3">
<input type="text" class="header-search" placeholder="Søg...">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
<div class="container-fluid">
<!-- Modern Header -->
<div class="dashboard-header">
<div class="container-fluid" style="max-width: 1400px;">
<h2>📊 Dashboard</h2>
<p>Oversigt over BMC Hub - alt på ét sted</p>
</div>
</div>
<div class="container-fluid" style="max-width: 1400px;">
<!-- Alerts -->
{% if bankruptcy_alerts %}
<div class="alert alert-danger d-flex align-items-center mb-4 border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
@ -38,7 +368,7 @@
{% endif %}
{% if unknown_worklog_count > 0 %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
<div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>
<div>
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
@ -48,118 +378,300 @@
</div>
{% endif %}
<div class="row g-4 mb-5">
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Aktive Kunder</p>
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
<!-- Stat Cards -->
<div class="row g-4 mb-5" id="statCards">
<div class="col-md-6 col-xl-3">
<a href="/customers" class="stat-card p-4 h-100">
<div class="stat-card-icon primary">
<i class="bi bi-people-fill"></i>
</div>
<h3>124</h3>
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
<div class="stat-card-label">Aktive Kunder</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 80px; height: 2.5rem;" id="customerCount">-</div>
<div id="customerTrend">
<span class="trend-badge neutral skeleton skeleton-text" style="width: 120px; height: 24px;"></span>
</div>
</a>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Hardware</p>
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
<div class="col-md-6 col-xl-3">
<a href="/ticket/tickets?status=open" class="stat-card p-4 h-100">
<div class="stat-card-icon warning">
<i class="bi bi-ticket-perforated-fill"></i>
</div>
<h3>856</h3>
<small class="text-muted">Enheder online</small>
<div class="stat-card-label">Support Tickets</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 60px; height: 2.5rem;" id="ticketCount">-</div>
<div id="ticketUrgent">
<span class="skeleton skeleton-text" style="width: 120px; height: 20px;"></span>
</div>
</a>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Support</p>
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
<div class="col-md-6 col-xl-3">
<a href="/billing" class="stat-card p-4 h-100">
<div class="stat-card-icon success">
<i class="bi bi-graph-up-arrow"></i>
</div>
<h3>12</h3>
<small class="text-warning">3 kræver handling</small>
<div class="stat-card-label">Omsætning</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 100px; height: 2.5rem;" id="revenueCount">-</div>
<div id="revenueTrend">
<span class="trend-badge neutral skeleton skeleton-text" style="width: 110px; height: 24px;"></span>
</div>
</a>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Omsætning</p>
<i class="bi bi-currency-dollar text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3>450k</h3>
<small class="text-success">Over budget</small>
<div class="col-md-6 col-xl-3">
<a href="/hardware" class="stat-card p-4 h-100">
<div class="stat-card-icon info">
<i class="bi bi-hdd-rack-fill"></i>
</div>
<div class="stat-card-label">Hardware</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 80px; height: 2.5rem;" id="hardwareCount">-</div>
<small class="text-muted">Enheder registreret</small>
</a>
</div>
</div>
<!-- Main Content -->
<div class="row g-4">
<div class="col-lg-8">
<div class="card p-4">
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kunde</th>
<th>Handling</th>
<th>Status</th>
<th class="text-end">Tid</th>
</tr>
</thead>
<tbody>
<tr>
<td class="fw-bold">Advokatgruppen A/S</td>
<td>Firewall konfiguration</td>
<td><span class="badge bg-success bg-opacity-10 text-success">Fuldført</span></td>
<td class="text-end text-muted">10:23</td>
</tr>
<tr>
<td class="fw-bold">Byg & Bo ApS</td>
<td>Licens fornyelse</td>
<td><span class="badge bg-warning bg-opacity-10 text-warning">Afventer</span></td>
<td class="text-end text-muted">I går</td>
</tr>
<tr>
<td class="fw-bold">Cafe Møller</td>
<td>Netværksnedbrud</td>
<td><span class="badge bg-danger bg-opacity-10 text-danger">Kritisk</span></td>
<td class="text-end text-muted">I går</td>
</tr>
</tbody>
</table>
<!-- Quick Actions -->
<div class="content-card mb-4">
<div class="card-header-modern">
<h5>⚡ Hurtige handlinger</h5>
</div>
<div class="p-4">
<div class="row g-3">
<div class="col-md-4">
<a href="/customers/new" class="btn quick-action-btn w-100">
<i class="bi bi-person-plus-fill me-2"></i>Ny kunde
</a>
</div>
<div class="col-md-4">
<a href="/ticket/tickets/new" class="btn quick-action-btn w-100">
<i class="bi bi-ticket-fill me-2"></i>Opret ticket
</a>
</div>
<div class="col-md-4">
<a href="/sag/new" class="btn quick-action-btn w-100">
<i class="bi bi-folder-plus me-2"></i>Ny sag
</a>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="content-card">
<div class="card-header-modern">
<h5>🕐 Seneste aktivitet</h5>
</div>
<div class="activity-feed" id="activityFeed">
<div class="empty-state">
<i class="bi bi-arrow-clockwise"></i>
<div>Henter aktivitet...</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card p-4 h-100">
<h5 class="fw-bold mb-4">System Status</h5>
<div class="mb-4">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">CPU LOAD</span>
<span class="small fw-bold">24%</span>
<!-- Calendar / Reminders -->
<div class="content-card">
<div class="card-header-modern">
<h5>📅 Kommende påmindelser</h5>
</div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div>
</div>
</div>
<div class="mb-4">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">MEMORY</span>
<span class="small fw-bold">56%</span>
</div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
</div>
</div>
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
<div class="d-flex">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
<div class="p-4" id="remindersWidget">
<div class="empty-state">
<i class="bi bi-arrow-clockwise"></i>
<div>Henter påmindelser...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Format currency in Danish format
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount) + ' kr';
}
// Format relative time
function timeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Lige nu';
if (diffMins < 60) return `${diffMins} min siden`;
if (diffHours < 24) return `${diffHours} timer siden`;
if (diffDays === 1) return 'I går';
if (diffDays < 7) return `${diffDays} dage siden`;
return date.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
}
// Get icon for activity type
function getActivityIcon(type, color) {
const icons = {
'customer': 'bi-building-fill',
'ticket': 'bi-ticket-perforated-fill',
'case': 'bi-folder-fill'
};
return `<div class="activity-icon ${color}"><i class="bi ${icons[type] || 'bi-circle-fill'}"></i></div>`;
}
// Get link for activity type
function getActivityLink(type, id) {
const links = {
'customer': '/customers',
'ticket': '/ticket/tickets',
'case': '/sag'
};
return `${links[type] || '#'}/${id}`;
}
// Load dashboard stats
async function loadStats() {
try {
const response = await fetch('/api/v1/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
// Update customer stats
const customerEl = document.getElementById('customerCount');
customerEl.textContent = data.customers.total.toLocaleString('da-DK');
customerEl.classList.remove('skeleton', 'skeleton-text');
const customerTrendEl = document.getElementById('customerTrend');
const customerGrowth = data.customers.growth_pct;
const trendClass = customerGrowth > 0 ? 'positive' : customerGrowth < 0 ? 'negative' : 'neutral';
const trendIcon = customerGrowth > 0 ? '▲' : customerGrowth < 0 ? '' : '';
customerTrendEl.innerHTML = `<span class="trend-badge ${trendClass}">${trendIcon} ${Math.abs(customerGrowth)}% denne måned</span>`;
// Update ticket stats
const ticketEl = document.getElementById('ticketCount');
ticketEl.textContent = data.tickets.open_count.toLocaleString('da-DK');
ticketEl.classList.remove('skeleton', 'skeleton-text');
const ticketUrgentEl = document.getElementById('ticketUrgent');
const urgentCount = data.tickets.urgent_count;
if (urgentCount > 0) {
ticketUrgentEl.innerHTML = `<span class="badge bg-danger">${urgentCount} kræver handling</span>`;
} else {
ticketUrgentEl.textContent = 'Ingen hastesager';
}
ticketUrgentEl.classList.remove('skeleton', 'skeleton-text');
// Update revenue stats
const revenueEl = document.getElementById('revenueCount');
revenueEl.textContent = formatCurrency(data.revenue.current_month);
revenueEl.classList.remove('skeleton', 'skeleton-text');
const revenueTrendEl = document.getElementById('revenueTrend');
const revenueGrowth = data.revenue.growth_pct;
const revTrendClass = revenueGrowth > 0 ? 'positive' : revenueGrowth < 0 ? 'negative' : 'neutral';
const revTrendIcon = revenueGrowth > 0 ? '▲' : revenueGrowth < 0 ? '' : '';
revenueTrendEl.innerHTML = `<span class="trend-badge ${revTrendClass}">${revTrendIcon} ${Math.abs(revenueGrowth)}% denne måned</span>`;
// Update hardware stats
const hardwareEl = document.getElementById('hardwareCount');
hardwareEl.textContent = data.hardware.total.toLocaleString('da-DK');
hardwareEl.classList.remove('skeleton', 'skeleton-text');
} catch (error) {
console.error('Error loading stats:', error);
// Remove skeletons even on error
document.querySelectorAll('.skeleton').forEach(el => {
el.classList.remove('skeleton', 'skeleton-text');
el.textContent = 'Fejl';
});
}
}
// Load recent activity
async function loadActivity() {
try {
const response = await fetch('/api/v1/recent-activity');
if (!response.ok) throw new Error('Failed to fetch activity');
const activities = await response.json();
const feedEl = document.getElementById('activityFeed');
if (!activities || activities.length === 0) {
feedEl.innerHTML = '<div class="empty-state"><i class="bi bi-inbox"></i><div>Ingen aktivitet at vise</div></div>';
return;
}
feedEl.innerHTML = activities.map(activity => `
<div class="activity-item" onclick="window.location.href='${getActivityLink(activity.activity_type, activity.id)}'">
${getActivityIcon(activity.activity_type, activity.color)}
<div class="activity-content">
<div class="activity-name">${activity.name || 'Unavngivet'}</div>
<div class="activity-time">${timeAgo(activity.created_at)}</div>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
`).join('');
} catch (error) {
console.error('Error loading activity:', error);
document.getElementById('activityFeed').innerHTML = '<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><div class="text-danger">Fejl ved indlæsning af aktivitet</div></div>';
}
}
// Load reminders
async function loadReminders() {
try {
const response = await fetch('/api/v1/reminders/upcoming');
if (!response.ok) throw new Error('Failed to fetch reminders');
const reminders = await response.json();
const widgetEl = document.getElementById('remindersWidget');
if (!reminders || reminders.length === 0) {
widgetEl.innerHTML = '<div class="empty-state"><i class="bi bi-calendar-check"></i><div>Ingen kommende påmindelser</div></div>';
return;
}
widgetEl.innerHTML = reminders.map(reminder => {
const dueDate = new Date(reminder.due_date);
const priorityClass = reminder.priority === 'high' ? 'priority-high' : reminder.priority === 'medium' ? 'priority-medium' : 'priority-low';
return `
<div class="reminder-item ${priorityClass}">
<div class="d-flex justify-content-between align-items-start mb-1">
<div class="fw-semibold small">${reminder.title || reminder.case_title || 'Påmindelse'}</div>
<small class="text-muted ms-2">${dueDate.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' })}</small>
</div>
${reminder.case_title ? `<div class="small text-muted">Sag: ${reminder.case_title}</div>` : ''}
</div>
`;
}).join('');
// Add link to view all
widgetEl.innerHTML += '<div class="text-center mt-3"><a href="/sag" class="text-decoration-none small fw-semibold">Se alle påmindelser →</a></div>';
} catch (error) {
console.error('Error loading reminders:', error);
document.getElementById('remindersWidget').innerHTML = '<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><div class="text-danger">Fejl ved indlæsning</div></div>';
}
}
// Load all data on page load
document.addEventListener('DOMContentLoaded', function() {
loadStats();
loadActivity();
loadReminders();
});
</script>
{% endblock %}

View File

@ -0,0 +1,115 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Salg Dashboard - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">💼 Salg Dashboard</h1>
<p class="text-muted mb-0">Pipeline-overblik og opfølgning for salgsteamet</p>
</div>
<div class="d-flex gap-2">
<a href="/opportunities" class="btn btn-outline-primary btn-sm">Åbn Opportunities</a>
<a href="/" class="btn btn-outline-secondary btn-sm">Til hoveddashboard</a>
</div>
</div>
<div class="alert alert-info border-0 shadow-sm mb-4" role="alert">
Vælg standard-dashboard under <strong>Indstillinger → System</strong>. Dashboard åbnes altid fra roden <code>/</code>.
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Åbne opportunities</div>
<div class="h3 mb-0">{{ pipeline_stats.open_count or 0 }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Lukkede opportunities</div>
<div class="h3 mb-0">{{ pipeline_stats.closed_count or 0 }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Åben pipeline værdi</div>
<div class="h4 mb-0">{{ "{:,.0f}".format((pipeline_stats.open_value or 0)|float).replace(',', '.') }} kr.</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="small text-muted">Gns. sandsynlighed</div>
<div class="h3 mb-0">{{ "%.0f"|format((pipeline_stats.avg_probability or 0)|float) }}%</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Seneste opportunities</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Stage</th>
<th>Beløb</th>
<th>Sandsynlighed</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in recent_opportunities %}
<tr>
<td>#{{ item.id }}</td>
<td>{{ item.titel }}</td>
<td>{{ item.customer_name }}</td>
<td>{{ item.pipeline_stage or '-' }}</td>
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</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>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Deadline næste 14 dage</h5></div>
<div class="card-body">
{% for item in due_soon %}
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">{{ item.titel }}</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>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1 @@
"""Fixed-Price Agreement Module"""

View File

@ -0,0 +1 @@
"""Backend package"""

View File

@ -0,0 +1,194 @@
"""
Fixed-Price Agreement Models
Pydantic schemas for API validation
"""
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import date, datetime
from decimal import Decimal
# Type aliases
PeriodType = Literal['calendar_month', 'rolling_30days', 'quarterly', 'yearly']
AgreementStatus = Literal['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
BillingPeriodStatus = Literal['active', 'pending_approval', 'ready_to_bill', 'billed', 'cancelled']
class FixedPriceAgreementBase(BaseModel):
"""Base schema with common fields"""
customer_id: int
customer_name: Optional[str] = None
monthly_hours: Decimal = Field(gt=0)
monthly_amount: Optional[Decimal] = Field(default=None, ge=0) # Fast månedspris
hourly_rate: Optional[Decimal] = Field(default=None, ge=0) # Beregnes fra monthly_amount hvis ikke givet
overtime_rate: Decimal = Field(ge=0)
internal_cost_rate: Decimal = Field(default=Decimal("350.00"), ge=0)
rounding_minutes: int = Field(default=0, ge=0, le=60)
notes: Optional[str] = None
@field_validator('rounding_minutes')
@classmethod
def validate_rounding(cls, v: int) -> int:
if v not in (0, 15, 30, 60):
raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
return v
@model_validator(mode='after')
def compute_hourly_rate(self):
"""Beregn hourly_rate fra monthly_amount hvis ikke direkte angivet"""
if self.hourly_rate is None:
if self.monthly_amount is not None and self.monthly_hours:
self.hourly_rate = self.monthly_amount / self.monthly_hours
else:
raise ValueError('Either hourly_rate or monthly_amount must be provided')
return self
class FixedPriceAgreementCreate(FixedPriceAgreementBase):
"""Schema for creating new agreement"""
subscription_id: Optional[int] = None
# Contract terms
start_date: date
binding_months: int = Field(default=0, ge=0)
end_date: Optional[date] = None
notice_period_days: int = Field(default=30, ge=0)
auto_renew: bool = False
# e-conomic integration
economic_product_number: Optional[str] = None
economic_overtime_product_number: Optional[str] = None
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v: Optional[date], info) -> Optional[date]:
if v and 'start_date' in info.data and v < info.data['start_date']:
raise ValueError('end_date must be after start_date')
return v
class FixedPriceAgreementUpdate(BaseModel):
"""Schema for updating existing agreement"""
monthly_hours: Optional[Decimal] = Field(default=None, gt=0)
hourly_rate: Optional[Decimal] = Field(default=None, ge=0)
overtime_rate: Optional[Decimal] = Field(default=None, ge=0)
internal_cost_rate: Optional[Decimal] = Field(default=None, ge=0)
rounding_minutes: Optional[int] = Field(default=None, ge=0, le=60)
end_date: Optional[date] = None
notice_period_days: Optional[int] = Field(default=None, ge=0)
auto_renew: Optional[bool] = None
billing_enabled: Optional[bool] = None
notes: Optional[str] = None
@field_validator('rounding_minutes')
@classmethod
def validate_rounding(cls, v: Optional[int]) -> Optional[int]:
if v is not None and v not in (0, 15, 30, 60):
raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
return v
class FixedPriceAgreement(FixedPriceAgreementBase):
"""Full agreement response schema"""
id: int
agreement_number: str
subscription_id: Optional[int]
start_date: date
binding_months: int
binding_end_date: Optional[date]
end_date: Optional[date]
notice_period_days: int
auto_renew: bool
status: AgreementStatus
cancellation_requested_date: Optional[date]
cancellation_effective_date: Optional[date]
cancelled_by_user_id: Optional[int]
cancellation_reason: Optional[str]
billing_enabled: bool
last_billed_period: Optional[date]
economic_product_number: Optional[str]
economic_overtime_product_number: Optional[str]
created_by_user_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CancellationRequest(BaseModel):
"""Schema for agreement cancellation"""
reason: str = Field(min_length=1)
effective_date: Optional[date] = None
force: bool = False # Admin override for binding period
@field_validator('reason')
@classmethod
def validate_reason(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('cancellation reason is required')
return v.strip()
class BillingPeriodBase(BaseModel):
"""Base schema for billing periods"""
agreement_id: int
period_start: date
period_end: date
period_type: PeriodType = 'calendar_month'
included_hours: Decimal = Field(gt=0)
base_amount: Decimal = Field(ge=0)
class BillingPeriodCreate(BillingPeriodBase):
"""Schema for creating billing period"""
pass
class BillingPeriod(BillingPeriodBase):
"""Full billing period response"""
id: int
used_hours: Decimal
overtime_hours: Decimal
remaining_hours: Decimal
overtime_amount: Decimal
overtime_approved: bool
status: BillingPeriodStatus
billed_at: Optional[datetime]
economic_invoice_number: Optional[str]
invoice_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BillingPeriodApproval(BaseModel):
"""Schema for approving overtime"""
overtime_hours: Decimal = Field(ge=0)
approved: bool
approved_by_user_id: Optional[int] = None
notes: Optional[str] = None
class AgreementPerformance(BaseModel):
"""Schema for performance metrics"""
id: int
agreement_number: str
customer_name: str
status: AgreementStatus
total_periods: int
total_used_hours: Decimal
total_revenue: Decimal
total_internal_cost: Decimal
total_profit: Decimal
utilization_percent: Decimal
class Config:
from_attributes = True

View File

@ -0,0 +1,769 @@
"""
Fixed-Price Agreement Router
CRUD operations, billing period management, and reporting
"""
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import StreamingResponse
from app.core.database import execute_query
from app.fixed_price.backend.models import (
FixedPriceAgreement,
FixedPriceAgreementCreate,
FixedPriceAgreementUpdate,
CancellationRequest,
BillingPeriod,
BillingPeriodCreate,
BillingPeriodApproval,
)
from typing import List, Optional, Dict, Any
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_CEILING
from calendar import monthrange
import logging
import csv
from io import StringIO
logger = logging.getLogger(__name__)
router = APIRouter()
def _apply_rounding(hours: Decimal, rounding_minutes: int) -> Decimal:
"""Apply rounding to hours based on interval (same as prepaid cards)"""
if rounding_minutes <= 0:
return hours
interval = Decimal(rounding_minutes) / Decimal(60)
rounded = (hours / interval).to_integral_value(ROUND_CEILING) * interval
return rounded
def _last_day_of_month(dt: date) -> date:
"""Get last day of month for given date"""
last_day = monthrange(dt.year, dt.month)[1]
return date(dt.year, dt.month, last_day)
def _calculate_prorated_amount(monthly_hours: float, hourly_rate: float,
period_start: date, period_end: date) -> float:
"""
Calculate pro-rated amount based on actual days in period.
If period is a full calendar month, returns full monthly amount.
Otherwise, calculates daily rate and multiplies by days in period.
"""
# Full month amount
monthly_amount = monthly_hours * hourly_rate
# Check if period is a full month (starts on 1st and ends on last day)
last_day_of_month = monthrange(period_start.year, period_start.month)[1]
if period_start.day == 1 and period_end.day == last_day_of_month and period_start.month == period_end.month:
return monthly_amount
# Calculate pro-rated amount for partial month
days_in_month = monthrange(period_start.year, period_start.month)[1]
days_in_period = (period_end - period_start).days + 1 # +1 to include both start and end
daily_rate = monthly_amount / days_in_month
prorated_amount = daily_rate * days_in_period
return prorated_amount
# ============================================================================
# CRUD Operations
# ============================================================================
@router.get("/fixed-price-agreements", response_model=List[Dict[str, Any]])
async def get_agreements(
customer_id: Optional[int] = None,
status: Optional[str] = None,
include_current_period: bool = True
):
"""
Get all fixed-price agreements with optional filters
"""
try:
filters = []
params = []
if customer_id:
filters.append("customer_id = %s")
params.append(customer_id)
if status:
filters.append("status = %s")
params.append(status)
where_clause = "WHERE " + " AND ".join(filters) if filters else ""
agreements = execute_query(f"""
SELECT * FROM customer_fixed_price_agreements
{where_clause}
ORDER BY created_at DESC
""", params if params else None)
# Enrich with current period info
if include_current_period and agreements:
for agr in agreements:
period = execute_query("""
SELECT
used_hours,
remaining_hours,
overtime_hours,
status
FROM fixed_price_billing_periods
WHERE agreement_id = %s
AND period_start <= CURRENT_DATE
AND period_end >= CURRENT_DATE
ORDER BY period_start DESC
LIMIT 1
""", (agr['id'],))
if period and len(period) > 0:
agr['current_period'] = period[0]
agr['remaining_hours_this_month'] = float(period[0]['remaining_hours'])
else:
agr['current_period'] = None
agr['remaining_hours_this_month'] = float(agr['monthly_hours'])
return agreements or []
except Exception as e:
logger.error(f"❌ Error fetching agreements: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
async def get_agreement(agreement_id: int):
"""
Get single agreement with full details including periods and timelogs
"""
try:
agreements = execute_query("""
SELECT * FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
agreement = agreements[0]
# Get billing periods
periods = execute_query("""
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
""", (agreement_id,))
agreement['billing_periods'] = periods or []
# Get timelogs (similar to prepaid card detail)
sag_logs = execute_query("""
SELECT
t.id,
t.worked_date,
t.original_hours as actual_hours,
t.approved_hours as rounded_hours,
t.description,
t.sag_id as source_id,
'sag' as source,
s.titel as source_title
FROM tmodule_times t
LEFT JOIN tmodule_sag s ON t.sag_id = s.id
WHERE t.fixed_price_agreement_id = %s
ORDER BY t.worked_date DESC
""", (agreement_id,))
ticket_logs = execute_query("""
SELECT
w.id,
w.created_at::date as worked_date,
w.hours as actual_hours,
w.rounded_hours,
w.description,
w.ticket_id as source_id,
'ticket' as source,
t.ticket_number,
t.subject as source_title
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE w.fixed_price_agreement_id = %s
ORDER BY w.created_at DESC
""", (agreement_id,))
# Combine and sort timelogs
timelogs = []
for log in (sag_logs or []):
timelogs.append(log)
for log in (ticket_logs or []):
timelogs.append(log)
timelogs.sort(key=lambda x: x['worked_date'], reverse=True)
agreement['timelogs'] = timelogs
return agreement
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching agreement {agreement_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/fixed-price-agreements", response_model=Dict[str, Any])
async def create_agreement(data: FixedPriceAgreementCreate):
"""
Create new fixed-price agreement and initialize first billing period
"""
try:
# Validate rounding
if data.rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding_minutes")
# Insert agreement
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
# Create agreement
cursor.execute("""
INSERT INTO customer_fixed_price_agreements (
customer_id, customer_name, subscription_id,
monthly_hours, hourly_rate, overtime_rate, internal_cost_rate,
rounding_minutes, start_date, binding_months, end_date,
notice_period_days, auto_renew, economic_product_number,
economic_overtime_product_number, notes
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""", (
data.customer_id,
data.customer_name,
data.subscription_id,
data.monthly_hours,
data.hourly_rate,
data.overtime_rate,
data.internal_cost_rate,
data.rounding_minutes,
data.start_date,
data.binding_months,
data.end_date,
data.notice_period_days,
data.auto_renew,
data.economic_product_number,
data.economic_overtime_product_number,
data.notes
))
agreement = cursor.fetchone()
# Create first billing period
period_start = data.start_date
period_end = _last_day_of_month(period_start)
# Calculate pro-rated amount for first period
base_amount = _calculate_prorated_amount(
data.monthly_hours,
data.hourly_rate,
period_start,
period_end
)
# Pro-rate included hours as well for partial months
days_in_month = monthrange(period_start.year, period_start.month)[1]
days_in_period = (period_end - period_start).days + 1
last_day_of_month = monthrange(period_start.year, period_start.month)[1]
if period_start.day == 1 and period_end.day == last_day_of_month:
# Full month
included_hours = data.monthly_hours
else:
# Pro-rate hours for partial month
included_hours = (data.monthly_hours / days_in_month) * days_in_period
cursor.execute("""
INSERT INTO fixed_price_billing_periods (
agreement_id, period_start, period_end,
included_hours, base_amount
) VALUES (%s, %s, %s, %s, %s)
RETURNING *
""", (
agreement['id'],
period_start,
period_end,
included_hours,
base_amount
))
first_period = cursor.fetchone()
conn.commit()
logger.info(f"✅ Created fixed-price agreement {agreement['agreement_number']} for customer {data.customer_id}")
agreement['first_period'] = first_period
return agreement
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating agreement: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
async def update_agreement(agreement_id: int, data: FixedPriceAgreementUpdate):
"""
Update agreement terms (does not affect existing periods)
"""
try:
# Build UPDATE query dynamically
updates = []
params = []
for field, value in data.model_dump(exclude_unset=True).items():
if value is not None:
updates.append(f"{field} = %s")
params.append(value)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(agreement_id)
result = execute_query(f"""
UPDATE customer_fixed_price_agreements
SET {", ".join(updates)}
WHERE id = %s
RETURNING *
""", params)
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
logger.info(f"✅ Updated fixed-price agreement {agreement_id}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating agreement {agreement_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/fixed-price-agreements/{agreement_id}/status")
async def update_status(agreement_id: int, status: str):
"""
Update agreement status (active, suspended, etc.)
"""
try:
valid_statuses = ['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
if status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of {valid_statuses}")
result = execute_query("""
UPDATE customer_fixed_price_agreements
SET status = %s
WHERE id = %s
RETURNING *
""", (status, agreement_id))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
logger.info(f"✅ Agreement {agreement_id} status → {status}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/fixed-price-agreements/{agreement_id}/cancel")
async def cancel_agreement(agreement_id: int, request: CancellationRequest):
"""
Request cancellation with binding period validation
"""
try:
agreements = execute_query("""
SELECT * FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
agreement = agreements[0]
today = date.today()
binding_end = agreement['binding_end_date']
# Check binding period
if binding_end and today < binding_end and not request.force:
raise HTTPException(
status_code=400,
detail=f"Aftale er bundet til {binding_end}. Kontakt administrator for tvungen opsigelse."
)
# Calculate effective date
effective_date = request.effective_date or (today + timedelta(days=agreement['notice_period_days']))
# Update agreement
result = execute_query("""
UPDATE customer_fixed_price_agreements
SET status = 'pending_cancellation',
cancellation_requested_date = %s,
cancellation_effective_date = %s,
cancellation_reason = %s
WHERE id = %s
RETURNING *
""", (today, effective_date, request.reason, agreement_id))
logger.info(f"⚠️ Agreement {agreement_id} cancellation requested, effective {effective_date}")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error cancelling agreement: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Billing Period Management
# ============================================================================
@router.get("/fixed-price-agreements/{agreement_id}/periods", response_model=List[Dict[str, Any]])
async def get_periods(agreement_id: int):
"""Get all billing periods for agreement"""
try:
periods = execute_query("""
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
""", (agreement_id,))
return periods or []
except Exception as e:
logger.error(f"❌ Error fetching periods: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/fixed-price-agreements/{agreement_id}/periods/{period_id}/approve-overtime")
async def approve_overtime(agreement_id: int, period_id: int, approval: BillingPeriodApproval):
"""
Approve overtime hours for billing
"""
try:
# Calculate overtime amount
agreements = execute_query("""
SELECT overtime_rate FROM customer_fixed_price_agreements
WHERE id = %s
""", (agreement_id,))
if not agreements or len(agreements) == 0:
raise HTTPException(status_code=404, detail="Agreement not found")
overtime_rate = agreements[0]['overtime_rate']
overtime_amount = approval.overtime_hours * Decimal(str(overtime_rate))
# Update period
result = execute_query("""
UPDATE fixed_price_billing_periods
SET overtime_amount = %s,
overtime_approved = %s,
status = CASE
WHEN %s THEN 'ready_to_bill'
ELSE status
END
WHERE id = %s AND agreement_id = %s
RETURNING *
""", (overtime_amount, approval.approved, approval.approved, period_id, agreement_id))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Period not found")
logger.info(f"✅ Overtime approved for period {period_id}: {approval.overtime_hours}t = {overtime_amount} DKK")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving overtime: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Reporting & Analytics
# ============================================================================
@router.get("/fixed-price-agreements/stats/summary")
async def get_stats_summary():
"""
Overall fixed-price system statistics
"""
try:
result = execute_query("""
SELECT
COUNT(DISTINCT id) as total_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'active') as active_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'cancelled') as cancelled_agreements,
COUNT(DISTINCT id) FILTER (WHERE status = 'expired') as expired_agreements,
SUM(monthly_hours) FILTER (WHERE status = 'active') as total_active_monthly_hours,
AVG(hourly_rate) FILTER (WHERE status = 'active') as avg_hourly_rate,
COUNT(DISTINCT customer_id) as unique_customers
FROM customer_fixed_price_agreements
""")[0]
# Get revenue and profit from performance view
performance = execute_query("""
SELECT
COALESCE(SUM(total_revenue), 0) as total_revenue,
COALESCE(SUM(total_internal_cost), 0) as total_cost,
COALESCE(SUM(total_profit), 0) as total_profit,
COALESCE(AVG(utilization_percent), 0) as avg_utilization
FROM fixed_price_agreement_performance
WHERE status = 'active'
""")[0]
return {**result, **performance}
except Exception as e:
logger.error(f"❌ Error fetching stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/profitability")
async def get_profitability_report(
customer_id: Optional[int] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
):
"""
Detailed profitability analysis with filters
"""
try:
filters = []
params = []
if customer_id:
filters.append("a.customer_id = %s")
params.append(customer_id)
if start_date:
filters.append("bp.period_start >= %s")
params.append(start_date)
if end_date:
filters.append("bp.period_end <= %s")
params.append(end_date)
where_clause = "WHERE " + " AND ".join(filters) if filters else ""
return execute_query(f"""
SELECT
a.id,
a.agreement_number,
a.customer_name,
a.monthly_hours,
a.hourly_rate,
a.internal_cost_rate,
COUNT(bp.id) as period_count,
COALESCE(SUM(bp.used_hours), 0) as total_hours,
COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as overtime_hours,
COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as base_revenue,
COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as overtime_revenue,
COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) as total_revenue,
COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as internal_cost,
COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) -
COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as profit,
CASE
WHEN SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') > 0
THEN ROUND((
(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
SUM(bp.used_hours) * a.internal_cost_rate) /
SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') * 100
)::numeric, 1)
ELSE 0
END as profit_margin_percent
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
{where_clause}
GROUP BY a.id
ORDER BY profit DESC
""", params if params else None) or []
except Exception as e:
logger.error(f"❌ Error generating profitability report: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/monthly-trends")
async def get_monthly_trends(months: int = 12):
"""
Month-over-month trend analysis
"""
try:
return execute_query("""
SELECT * FROM fixed_price_monthly_trends
ORDER BY month DESC
LIMIT %s
""", (months,)) or []
except Exception as e:
logger.error(f"❌ Error fetching trends: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/customer-breakdown")
async def get_customer_breakdown():
"""
Per-customer revenue and profitability
"""
try:
return execute_query("""
SELECT * FROM fixed_price_customer_summary
ORDER BY total_revenue DESC
""") or []
except Exception as e:
logger.error(f"❌ Error fetching customer breakdown: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/overtime-analysis")
async def get_overtime_analysis():
"""
Analyze overtime patterns to identify agreements with frequent overruns
"""
try:
return execute_query("""
SELECT
a.id,
a.agreement_number,
a.customer_name,
a.monthly_hours,
COUNT(bp.id) as total_periods,
COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0) as periods_with_overtime,
ROUND((COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0)::numeric /
NULLIF(COUNT(bp.id), 0) * 100), 1) as overtime_frequency_percent,
AVG(bp.overtime_hours) FILTER (WHERE bp.overtime_hours > 0) as avg_overtime_per_period,
MAX(bp.overtime_hours) as max_overtime_single_period,
COALESCE(SUM(bp.overtime_hours), 0) as total_overtime_hours,
COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.overtime_approved), 0) as total_overtime_revenue
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
WHERE a.status = 'active'
GROUP BY a.id
HAVING COUNT(bp.id) > 0
ORDER BY overtime_frequency_percent DESC, total_overtime_hours DESC
""") or []
except Exception as e:
logger.error(f"❌ Error analyzing overtime: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/{agreement_id}/reports/period-detail")
async def get_period_detail_report(agreement_id: int):
"""
Detailed period-by-period breakdown for single agreement
"""
try:
return execute_query("""
SELECT
bp.id,
bp.period_start,
bp.period_end,
bp.included_hours,
bp.used_hours,
bp.overtime_hours,
bp.base_amount,
bp.overtime_amount,
bp.overtime_approved,
bp.status,
bp.economic_invoice_number,
-- Calculate profit for this period
a.internal_cost_rate,
bp.used_hours * a.internal_cost_rate as period_cost,
(bp.base_amount + COALESCE(bp.overtime_amount, 0)) -
(bp.used_hours * a.internal_cost_rate) as period_profit,
-- Time entry breakdown
(SELECT COUNT(*) FROM tmodule_times
WHERE fixed_price_agreement_id = a.id
AND worked_date BETWEEN bp.period_start AND bp.period_end) as sag_entries,
(SELECT COUNT(*) FROM tticket_worklog
WHERE fixed_price_agreement_id = a.id
AND created_at::date BETWEEN bp.period_start AND bp.period_end) as ticket_entries
FROM fixed_price_billing_periods bp
JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id
WHERE a.id = %s
ORDER BY bp.period_start DESC
""", (agreement_id,)) or []
except Exception as e:
logger.error(f"❌ Error generating period detail: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/export/csv")
async def export_profitability_csv():
"""
Export full profitability report as CSV
"""
try:
data = execute_query("""
SELECT
a.agreement_number,
a.customer_name,
a.status,
a.monthly_hours,
a.hourly_rate,
a.overtime_rate,
a.internal_cost_rate,
a.start_date,
perf.total_periods,
perf.total_used_hours,
perf.total_approved_overtime,
perf.total_revenue,
perf.total_internal_cost,
perf.total_profit,
perf.utilization_percent
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_agreement_performance perf ON a.id = perf.id
ORDER BY a.customer_name, a.agreement_number
""")
# Generate CSV
output = StringIO()
if data and len(data) > 0:
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=fixed_price_profitability.csv"}
)
except Exception as e:
logger.error(f"❌ Error exporting CSV: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,322 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ agreement.agreement_number }} - Fastpris Aftale{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/fixed-price-agreements">Fastpris Aftaler</a></li>
<li class="breadcrumb-item active">{{ agreement.agreement_number }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">📋 {{ agreement.agreement_number }}</h1>
<p class="text-muted">{{ agreement.customer_name }}</p>
</div>
<div class="col-auto">
{% if agreement.status == 'active' %}
<span class="badge bg-success fs-6">Aktiv</span>
{% elif agreement.status == 'suspended' %}
<span class="badge bg-warning fs-6">Suspenderet</span>
{% elif agreement.status == 'expired' %}
<span class="badge bg-danger fs-6">Udløbet</span>
{% elif agreement.status == 'cancelled' %}
<span class="badge bg-secondary fs-6">Annulleret</span>
{% endif %}
</div>
</div>
<!-- Overview Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Månedlige Timer</h6>
<h3 class="mb-0">{{ '%.0f'|format(agreement.monthly_hours or 0) }} t</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Timepris</h6>
<h3 class="mb-0">{{ '%.0f'|format(agreement.hourly_rate or 0) }} kr</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Denne Måned</h6>
<h3 class="mb-0">{{ '%.1f'|format(agreement.current_used_hours or 0) }} / {{ '%.0f'|format(agreement.monthly_hours or 0) }} t</h3>
<small class="text-muted">{{ '%.1f'|format(agreement.current_remaining_hours or 0) }}t tilbage</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Binding</h6>
<h3 class="mb-0">{{ agreement.binding_months }} mdr</h3>
{% if agreement.binding_end_date %}
<small class="text-muted">Til {{ agreement.binding_end_date }}</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#details-tab">Detaljer</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#periods-tab">
Perioder <span class="badge bg-secondary">{{ periods|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sager-tab">
Sager <span class="badge bg-secondary">{{ sager|length }}</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#time-tab">
Tidsregistreringer <span class="badge bg-secondary">{{ time_entries|length }}</span>
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Details Tab -->
<div class="tab-pane fade show active" id="details-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="mb-3">Aftale Information</h5>
<table class="table table-sm">
<tr>
<th width="40%">Kunde ID:</th>
<td>{{ agreement.customer_id }}</td>
</tr>
<tr>
<th>Kunde:</th>
<td>{{ agreement.customer_name }}</td>
</tr>
<tr>
<th>Start Dato:</th>
<td>{{ agreement.start_date }}</td>
</tr>
{% if agreement.end_date %}
<tr>
<th>Slut Dato:</th>
<td>{{ agreement.end_date }}</td>
</tr>
{% endif %}
<tr>
<th>Oprettet:</th>
<td>{{ agreement.created_at }}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h5 class="mb-3">Priser & Vilkår</h5>
<table class="table table-sm">
<tr>
<th width="40%">Månedlige Timer:</th>
<td>{{ '%.0f'|format(agreement.monthly_hours or 0) }} timer</td>
</tr>
<tr>
<th>Normal Timepris:</th>
<td>{{ '%.0f'|format(agreement.hourly_rate or 0) }} kr</td>
</tr>
<tr>
<th>Overtid Timepris:</th>
<td>{{ '%.0f'|format(agreement.overtime_rate or 0) }} kr {% if agreement.overtime_rate and agreement.hourly_rate %}({{ '%.0f'|format((agreement.overtime_rate / agreement.hourly_rate - 1) * 100) }}%){% endif %}</td>
</tr>
<tr>
<th>Afrunding:</th>
<td>{% if agreement.rounding_minutes == 0 %}Ingen{% else %}{{ agreement.rounding_minutes }} min{% endif %}</td>
</tr>
<tr>
<th>Bindingsperiode:</th>
<td>{{ agreement.binding_months }} måneder</td>
</tr>
<tr>
<th>Opsigelsesfrist:</th>
<td>{{ agreement.notice_period_days }} dage</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Periods Tab -->
<div class="tab-pane fade" id="periods-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Periode</th>
<th>Status</th>
<th>Brugte Timer</th>
<th>Resterende Timer</th>
<th>Overtid</th>
<th>Månedlig Værdi</th>
</tr>
</thead>
<tbody>
{% for period in periods %}
<tr>
<td>
<strong>{{ period.period_start }}</strong> til {{ period.period_end }}
</td>
<td>
{% if period.status == 'active' %}
<span class="badge bg-success">Aktiv</span>
{% elif period.status == 'pending_approval' %}
<span class="badge bg-warning">⚠️ Overtid</span>
{% elif period.status == 'ready_to_bill' %}
<span class="badge bg-info">Klar til faktura</span>
{% elif period.status == 'billed' %}
<span class="badge bg-secondary">Faktureret</span>
{% endif %}
</td>
<td>{{ '%.1f'|format(period.used_hours or 0) }}t</td>
<td>{{ '%.1f'|format(period.remaining_hours or 0) }}t</td>
<td>
{% if period.overtime_hours and period.overtime_hours > 0 %}
<span class="text-danger">+{{ '%.1f'|format(period.overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ '%.0f'|format(period.base_amount or 0) }} kr</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Sager Tab -->
<div class="tab-pane fade" id="sager-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if sager %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Sag ID</th>
<th>Titel</th>
<th>Status</th>
<th>Oprettet</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{% for sag in sager %}
<tr>
<td><strong>#{{ sag.id }}</strong></td>
<td>{{ sag.titel }}</td>
<td>
{% if sag.status == 'open' %}
<span class="badge bg-success">Åben</span>
{% elif sag.status == 'in_progress' %}
<span class="badge bg-primary">I gang</span>
{% elif sag.status == 'closed' %}
<span class="badge bg-secondary">Lukket</span>
{% endif %}
</td>
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
<td>
<a href="/sag/{{ sag.id }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Vis
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen sager endnu</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Time Entries Tab -->
<div class="tab-pane fade" id="time-tab">
<div class="card border-0 shadow-sm">
<div class="card-body">
{% if time_entries %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Sag</th>
<th>Beskrivelse</th>
<th>Timer</th>
<th>Afrundet</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
<td>
{% if entry.sag_id %}
<a href="/sag/{{ entry.sag_id }}">#{{ entry.sag_id }}</a>
{% if entry.sag_titel %}
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<small>{{ entry.note[:50] if entry.note else '-' }}</small>
</td>
<td>{{ '%.2f'|format(entry.approved_hours or entry.original_hours or 0) }}t</td>
<td>
{% if entry.rounded_to %}
<span class="badge bg-info">{{ entry.rounded_to }} min</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-clock fs-1 mb-3"></i>
<p>Ingen tidsregistreringer endnu</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,616 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Fastpris Aftaler - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">📋 Fastpris Aftaler</h1>
<p class="text-muted">Månedlige timer aftaler med overtid håndtering</p>
</div>
<div class="col-auto">
<a href="/fixed-price-agreements/reports/dashboard" class="btn btn-outline-primary me-2">
<i class="bi bi-graph-up"></i> Rapporter
</a>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Opret Ny Aftale
</button>
</div>
</div>
<!-- Stats Row -->
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-success bg-opacity-10 p-3">
<i class="bi bi-calendar-check text-success fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Aktive Aftaler</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-primary bg-opacity-10 p-3">
<i class="bi bi-currency-dollar text-primary fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Omsætning</p>
<h3 class="mb-0" id="totalRevenue">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-info bg-opacity-10 p-3">
<i class="bi bi-pie-chart text-info fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Profit</p>
<h3 class="mb-0" id="totalProfit">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-warning bg-opacity-10 p-3">
<i class="bi bi-clock text-warning fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Brugte Timer</p>
<h3 class="mb-0" id="totalHours">-</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Agreements Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="row align-items-center">
<div class="col">
<h5 class="mb-0">Alle Aftaler</h5>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm" id="searchInput" placeholder="Søg...">
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="agreementsTable">
<thead class="table-light">
<tr>
<th>Aftale Nr.</th>
<th>Kunde</th>
<th>Månedlige Timer</th>
<th>Status</th>
<th>Denne Måned</th>
<th>Start</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="agreementsBody">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Load customers from server-side render
const customersCache = {{ customers | tojson }};
console.log('✅ Loaded customers from template:', customersCache?.length || 0);
async function loadAgreements() {
try {
// Load stats
const stats = await fetch('/api/v1/fixed-price-agreements/stats/summary').then(r => r.json());
document.getElementById('activeCount').textContent = stats.active_agreements || 0;
document.getElementById('totalRevenue').textContent = formatCurrency(stats.total_revenue || 0);
document.getElementById('totalProfit').textContent = formatCurrency(stats.total_profit || 0);
document.getElementById('totalHours').textContent = (stats.total_used_hours || 0).toFixed(1) + ' t';
// Load agreements
const agreements = await fetch('/api/v1/fixed-price-agreements?include_current_period=true').then(r => r.json());
renderAgreements(agreements);
} catch (e) {
console.error('Error loading agreements:', e);
document.getElementById('agreementsBody').innerHTML = `
<tr><td colspan="7" class="text-center text-danger py-5">
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
<p>Fejl ved indlæsning</p>
</td></tr>
`;
}
}
function renderAgreements(agreements) {
const tbody = document.getElementById('agreementsBody');
if (!agreements || agreements.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7" class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 mb-3"></i>
<p>Ingen aftaler endnu</p>
</td></tr>
`;
return;
}
tbody.innerHTML = agreements.map(a => {
const statusBadge = getStatusBadge(a.status);
const currentPeriod = a.current_period;
const usedHours = currentPeriod ? parseFloat(currentPeriod.used_hours || 0).toFixed(1) : '0.0';
const remainingHours = a.remaining_hours_this_month ? a.remaining_hours_this_month.toFixed(1) : parseFloat(a.monthly_hours).toFixed(1);
return `
<tr onclick="window.location.href='/fixed-price-agreements/${a.id}'" style="cursor: pointer;">
<td><strong>${a.agreement_number}</strong></td>
<td>${a.customer_name || '-'}</td>
<td>${parseFloat(a.monthly_hours).toFixed(0)} t/md</td>
<td>${statusBadge}</td>
<td>
<small class="text-muted">${usedHours}t brugt / ${remainingHours}t tilbage</small>
${currentPeriod && currentPeriod.status === 'pending_approval' ? '<span class="badge bg-warning ms-1">⚠️ Overtid</span>' : ''}
</td>
<td>${new Date(a.start_date).toLocaleDateString('da-DK')}</td>
<td class="text-end" onclick="event.stopPropagation();">
<a href="/fixed-price-agreements/${a.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Detaljer
</a>
</td>
</tr>
`;
}).join('');
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'suspended': '<span class="badge bg-warning">Suspenderet</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-secondary">Annulleret</span>',
'pending_cancellation': '<span class="badge bg-warning">Opsagt</span>'
};
return badges[status] || status;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
function calculateHourlyRate() {
const monthlyAmount = parseFloat(document.getElementById('createMonthlyAmount')?.value || 0);
const monthlyHours = parseFloat(document.getElementById('createMonthlyHours')?.value || 1);
if (monthlyAmount > 0 && monthlyHours > 0) {
const hourlyRate = (monthlyAmount / monthlyHours).toFixed(2);
document.getElementById('calculatedHourlyRate').textContent = `(≈ ${hourlyRate} kr/t)`;
} else {
document.getElementById('calculatedHourlyRate').textContent = '';
}
}
async function openCreateModal() {
console.log('🚀 openCreateModal() called');
console.log('📊 Customers cache:', customersCache?.length || 0);
// Show loading state
const customerList = document.getElementById('createCustomerList');
if (!customerList) {
console.error('❌ createCustomerList element not found!');
return;
}
console.log('✅ Found createCustomerList element');
customerList.innerHTML = '<div class="list-group-item text-center"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser kunder...</div>';
// Check if customers are loaded
if (!customersCache || customersCache.length === 0) {
console.error('❌ No customers available');
customerList.innerHTML = '<div class="list-group-item text-danger"><i class="bi bi-exclamation-triangle me-2"></i>Ingen kunder tilgængelige</div>';
return;
}
console.log('✅ Customers ready, rendering...');
// Populate customer list
const searchInput = document.getElementById('createCustomerSearch');
// Reset search and render all customers
searchInput.value = '';
renderCustomerOptions(customersCache);
// Reset form
document.getElementById('createAgreementForm').reset();
// Set default values
document.getElementById('createStartDate').value = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
// Calculate initial hourly rate display
setTimeout(() => calculateHourlyRate(), 100);
// Show modal
console.log('📋 Opening modal...');
const modalElement = document.getElementById('createAgreementModal');
if (!modalElement) {
console.error('❌ Modal element not found!');
return;
}
console.log('✅ Found modal element');
const modal = new bootstrap.Modal(modalElement);
modal.show();
console.log('✅ Modal.show() called');
}
function renderCustomerOptions(customers) {
const listGroup = document.getElementById('createCustomerList');
console.log('📋 Rendering customers:', customers?.length || 0);
console.log('First customer:', customers?.[0]);
if (!customers || customers.length === 0) {
console.warn('⚠️ No customers to render');
listGroup.innerHTML = '<div class="list-group-item text-muted"><i class="bi bi-inbox me-2"></i>Ingen kunder fundet</div>';
return;
}
console.log('✅ Rendering', customers.length, 'customers');
listGroup.innerHTML = '';
customers.forEach((c, idx) => {
if (idx < 3) console.log(`Customer ${idx}:`, c);
const item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action';
item.dataset.customerId = c.id;
item.dataset.customerName = c.name || 'Ukendt';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${escapeHtml(c.name || 'Ukendt')}</strong>
${c.cvr_number ? `<br><small class="text-muted">CVR: ${escapeHtml(c.cvr_number)}</small>` : ''}
</div>
${c.is_active === false ? '<span class="badge bg-secondary">Inaktiv</span>' : ''}
</div>
`;
item.addEventListener('click', (e) => {
e.preventDefault();
selectCustomer(c.id, c.name || 'Ukendt');
});
listGroup.appendChild(item);
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectCustomer(customerId, customerName) {
// Set hidden input
document.getElementById('createCustomerId').value = customerId;
// Update display
const selectedDiv = document.getElementById('selectedCustomerName');
selectedDiv.innerHTML = '';
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success mb-2';
alertDiv.innerHTML = `
<i class="bi bi-check-circle me-2"></i>
<strong>Valgt kunde:</strong> ${escapeHtml(customerName)}
`;
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn btn-sm btn-link float-end text-decoration-none';
clearBtn.innerHTML = '<i class="bi bi-x"></i> Ryd';
clearBtn.addEventListener('click', clearCustomerSelection);
alertDiv.appendChild(clearBtn);
selectedDiv.appendChild(alertDiv);
// Hide list
document.getElementById('createCustomerList').style.display = 'none';
document.getElementById('createCustomerSearch').style.display = 'none';
}
function clearCustomerSelection() {
document.getElementById('createCustomerId').value = '';
document.getElementById('selectedCustomerName').innerHTML = '';
document.getElementById('createCustomerList').style.display = 'block';
document.getElementById('createCustomerSearch').style.display = 'block';
renderCustomerOptions(customersCache);
}
function searchCustomers() {
const searchTerm = document.getElementById('createCustomerSearch').value.toLowerCase();
const filtered = customersCache.filter(c =>
(c.name || '').toLowerCase().includes(searchTerm) ||
(c.cvr_number || '').toLowerCase().includes(searchTerm) ||
(c.email || '').toLowerCase().includes(searchTerm)
);
renderCustomerOptions(filtered);
}
async function submitCreateAgreement(event) {
event.preventDefault();
const form = event.target;
const submitBtn = form.querySelector('button[type="submit"]');
const originalBtnText = submitBtn.innerHTML;
// Get form data
const customerIdInput = document.getElementById('createCustomerId');
const customerId = parseInt(customerIdInput.value);
// Validate customer selection
if (!customerId || isNaN(customerId)) {
alert('⚠️ Vælg venligst en kunde først');
return;
}
const customer = customersCache.find(c => c.id === customerId);
const customerName = customer?.name || '';
const data = {
customer_id: customerId,
customer_name: customerName,
monthly_hours: parseFloat(form.monthlyHours.value),
monthly_amount: parseFloat(form.monthlyAmount.value),
overtime_rate: parseFloat(form.overtimeRate.value),
rounding_minutes: parseInt(form.roundingMinutes.value),
start_date: form.startDate.value,
binding_months: parseInt(form.bindingMonths.value),
notice_period_days: parseInt(form.noticePeriodDays.value)
};
// Validate
if (!data.customer_id || data.monthly_hours <= 0 || data.monthly_amount <= 0) {
alert('Udfyld venligst alle påkrævede felter');
return;
}
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const response = await fetch('/api/v1/fixed-price-agreements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const result = await response.json();
// Close modal
bootstrap.Modal.getInstance(document.getElementById('createAgreementModal')).hide();
// Show success message with details
const successMsg = `✅ Aftale oprettet!\n\nAftalenummer: ${result.agreement_number}\nKunde: ${customerName}\nMånedlige timer: ${data.monthly_hours}t\n\nAftalen er nu tilgængelig i listen.`;
// Reload list to show new agreement
await loadAgreements();
alert(successMsg);
} catch (error) {
console.error('Create error:', error);
alert('❌ Fejl: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
}
// Search functionality
document.getElementById('searchInput')?.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
const rows = document.querySelectorAll('#agreementsBody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(term) ? '' : 'none';
});
});
// Initialize after DOM is loaded
loadAgreements();
// Setup event listeners after modal is in DOM
setTimeout(() => {
// Auto-calculate overtime rate (125%)
document.getElementById('createHourlyRate')?.addEventListener('input', (e) => {
const hourlyRate = parseFloat(e.target.value);
if (hourlyRate > 0) {
document.getElementById('createOvertimeRate').value = (hourlyRate * 1.25).toFixed(2);
}
});
// Customer search listener
const searchInput = document.getElementById('createCustomerSearch');
if (searchInput) {
searchInput.addEventListener('input', searchCustomers);
}
}, 100);
</script>
<!-- Create Agreement Modal -->
<div class="modal fade" id="createAgreementModal" tabindex="-1" aria-labelledby="createAgreementModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createAgreementModalLabel">
<i class="bi bi-plus-circle text-primary me-2"></i>Opret Ny Fastpris Aftale
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createAgreementForm" onsubmit="submitCreateAgreement(event)">
<div class="modal-body">
<div class="row g-3">
<!-- Customer Selection -->
<div class="col-12">
<label class="form-label">Kunde <span class="text-danger">*</span></label>
<input type="hidden" id="createCustomerId" name="customerId" required>
<!-- Selected Customer Display -->
<div id="selectedCustomerName"></div>
<!-- Search Input -->
<input type="text"
class="form-control mb-2"
id="createCustomerSearch"
placeholder="🔍 Søg kunde (navn, CVR, email)..."
autocomplete="off">
<!-- Customer List -->
<div class="border rounded" style="max-height: 300px; overflow-y: auto;">
<div class="list-group list-group-flush" id="createCustomerList">
<div class="list-group-item text-center text-muted">
<span class="spinner-border spinner-border-sm me-2"></span>
Indlæser kunder...
</div>
</div>
</div>
</div>
<!-- Monthly Hours -->
<div class="col-md-6">
<label for="createMonthlyHours" class="form-label">Månedlige Timer <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyHours" name="monthlyHours"
min="1" step="0.5" value="160" required oninput="calculateHourlyRate()">
<div class="form-text">Timer inkluderet per måned</div>
</div>
<!-- Rounding Minutes -->
<div class="col-md-6">
<label for="createRoundingMinutes" class="form-label">Afrunding <span class="text-danger">*</span></label>
<select class="form-select" id="createRoundingMinutes" name="roundingMinutes" required>
<option value="0">Ingen afrunding</option>
<option value="15" selected>15 minutter</option>
<option value="30">30 minutter</option>
<option value="60">60 minutter</option>
</select>
<div class="form-text">Afrund tid ved registrering</div>
</div>
<!-- Monthly Amount -->
<div class="col-md-6">
<label for="createMonthlyAmount" class="form-label">Månedspris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createMonthlyAmount" name="monthlyAmount"
min="0" step="0.01" value="80000" required oninput="calculateHourlyRate()">
<div class="form-text">
Fast pris pr. måned
<span id="calculatedHourlyRate" class="text-primary fw-bold"></span>
</div>
</div>
<!-- Overtime Rate -->
<div class="col-md-6">
<label for="createOvertimeRate" class="form-label">Overtid Timepris (DKK) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createOvertimeRate" name="overtimeRate"
min="0" step="0.01" value="625" required>
<div class="form-text">Pris for overtid (typisk 125%)</div>
</div>
<!-- Start Date -->
<div class="col-md-6">
<label for="createStartDate" class="form-label">Start Dato <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="createStartDate" name="startDate" required>
<div class="form-text">Aftalens første dag</div>
</div>
<!-- Binding Months -->
<div class="col-md-6">
<label for="createBindingMonths" class="form-label">Binding (måneder) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="createBindingMonths" name="bindingMonths"
min="0" step="1" value="12" required>
<div class="form-text">0 = ingen binding</div>
</div>
<!-- Notice Period -->
<div class="col-md-6">
<label for="createNoticePeriodDays" class="form-label">Opsigelsesfrist (dage)</label>
<input type="number" class="form-control" id="createNoticePeriodDays" name="noticePeriodDays"
min="0" step="1" value="30">
<div class="form-text">Varslingsfrist ved opsigelse</div>
</div>
<!-- End Date (Optional) -->
<div class="col-md-6">
<label for="createEndDate" class="form-label">Slut Dato (valgfri)</label>
<input type="date" class="form-control" id="createEndDate" name="endDate">
<div class="form-text">Lad blank for løbende aftale</div>
</div>
<div class="col-12">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
<strong>Note:</strong> Aftalen tildeles automatisk et unikt nummer (FPA-YYYYMMDD-XXX) ved oprettelse.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-1"></i>Annuller
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Opret Aftale
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,285 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Fastpris Rapporter - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/fixed-price-agreements">Fastpris Aftaler</a></li>
<li class="breadcrumb-item active">Rapporter</li>
</ol>
</nav>
<h1 class="h3 mb-0">📊 Fastpris Rapporter</h1>
<p class="text-muted">Profitabilitet og performance analyse</p>
</div>
</div>
{% if error %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Fejl:</strong> {{ error }}
</div>
{% endif %}
<!-- Summary Stats -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Aktive Aftaler</h6>
<h3 class="mb-0">{{ stats.active_agreements or 0 }}</h3>
<small class="text-muted">af {{ stats.total_agreements or 0 }} total</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Total Omsætning</h6>
<h3 class="mb-0">{{ "{:,.0f}".format(stats.total_revenue or 0) }} kr</h3>
<small class="text-muted">Månedlig værdi</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Estimeret Profit</h6>
<h3 class="mb-0">{{ "{:,.0f}".format(stats.estimated_profit or 0) }} kr</h3>
<small class="text-muted">Ved 300 kr/t kostpris</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="text-muted mb-2">Profit Margin</h6>
{% set profit_margin = ((stats.estimated_profit|float / stats.total_revenue|float * 100)|round(1)) if stats.total_revenue and stats.total_revenue > 0 else 0 %}
<h3 class="mb-0">{{ profit_margin }}%</h3>
<small class="text-muted">Gennemsnitlig margin</small>
</div>
</div>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#performance-tab">Performance</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#trends-tab">Trends</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#customers-tab">Kunder</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content">
<!-- Performance Tab -->
<div class="tab-pane fade show active" id="performance-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Aftale Performance</h5>
<small class="text-muted">Sorteret efter profit margin</small>
</div>
<div class="card-body">
{% if performance %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Aftale</th>
<th>Kunde</th>
<th class="text-end">Total Timer</th>
<th class="text-end">Månedlig Værdi</th>
<th class="text-end">Profit</th>
<th class="text-end">Margin</th>
<th class="text-end">Udnyttelse</th>
</tr>
</thead>
<tbody>
{% for agr in performance %}
<tr>
<td>
<a href="/fixed-price-agreements/{{ agr.agreement_id }}">
<strong>{{ agr.agreement_number }}</strong>
</a>
</td>
<td>{{ agr.customer_name }}</td>
<td class="text-end">{{ '%.1f'|format(agr.total_used_hours or 0) }}t</td>
<td class="text-end">{{ "{:,.0f}".format(agr.total_base_revenue or 0) }} kr</td>
<td class="text-end">
{% if agr.estimated_profit and agr.estimated_profit > 0 %}
<span class="text-success">{{ "{:,.0f}".format(agr.estimated_profit) }} kr</span>
{% else %}
<span class="text-danger">{{ "{:,.0f}".format(agr.estimated_profit or 0) }} kr</span>
{% endif %}
</td>
<td class="text-end">
{% if agr.profit_margin and agr.profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(agr.profit_margin) }}%</span>
{% elif agr.profit_margin and agr.profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(agr.profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(agr.profit_margin or 0) }}%</span>
{% endif %}
</td>
<td class="text-end">
{% set utilization = ((agr.total_used_hours or 0) / (agr.total_allocated_hours or 1) * 100) if agr.total_allocated_hours else 0 %}
{% if utilization >= 80 %}
<span class="badge bg-success">{{ '%.0f'|format(utilization) }}%</span>
{% elif utilization >= 50 %}
<span class="badge bg-info">{{ '%.0f'|format(utilization) }}%</span>
{% else %}
<span class="badge bg-secondary">{{ '%.0f'|format(utilization) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-graph-down fs-1 mb-3"></i>
<p>Ingen performance data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Trends Tab -->
<div class="tab-pane fade" id="trends-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Månedlige Trends</h5>
<small class="text-muted">Seneste 12 måneder</small>
</div>
<div class="card-body">
{% if trends %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Måned</th>
<th class="text-end">Aktive Aftaler</th>
<th class="text-end">Brugte Timer</th>
<th class="text-end">Overtid Timer</th>
<th class="text-end">Total Værdi</th>
<th class="text-end">Profit</th>
<th class="text-end">Margin</th>
</tr>
</thead>
<tbody>
{% for trend in trends %}
<tr>
<td><strong>{{ trend.period_month }}</strong></td>
<td class="text-end">{{ trend.active_agreements }}</td>
<td class="text-end">{{ '%.1f'|format(trend.total_used_hours or 0) }}t</td>
<td class="text-end">
{% if trend.total_overtime_hours and trend.total_overtime_hours > 0 %}
<span class="text-warning">{{ '%.1f'|format(trend.total_overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-end">{{ "{:,.0f}".format(trend.monthly_total_revenue or 0) }} kr</td>
<td class="text-end">
{% if trend.total_profit and trend.total_profit > 0 %}
<span class="text-success">{{ "{:,.0f}".format(trend.total_profit) }} kr</span>
{% else %}
<span class="text-danger">{{ "{:,.0f}".format(trend.total_profit or 0) }} kr</span>
{% endif %}
</td>
<td class="text-end">
{% if trend.avg_profit_margin and trend.avg_profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(trend.avg_profit_margin) }}%</span>
{% elif trend.avg_profit_margin and trend.avg_profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(trend.avg_profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(trend.avg_profit_margin or 0) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-calendar3 fs-1 mb-3"></i>
<p>Ingen trend data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Customers Tab -->
<div class="tab-pane fade" id="customers-tab">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Top Kunder</h5>
<small class="text-muted">Sorteret efter total forbrug</small>
</div>
<div class="card-body">
{% if customers %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Kunde</th>
<th class="text-end">Aftaler</th>
<th class="text-end">Total Timer</th>
<th class="text-end">Overtid</th>
<th class="text-end">Total Værdi</th>
<th class="text-end">Avg Margin</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr>
<td><strong>{{ customer.customer_name }}</strong></td>
<td class="text-end">{{ customer.agreement_count }}</td>
<td class="text-end">{{ '%.1f'|format(customer.total_used_hours or 0) }}t</td>
<td class="text-end">
{% if customer.total_overtime_hours and customer.total_overtime_hours > 0 %}
<span class="text-warning">{{ '%.1f'|format(customer.total_overtime_hours) }}t</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-end">{{ "{:,.0f}".format(customer.total_revenue or 0) }} kr</td>
<td class="text-end">
{% if customer.avg_profit_margin and customer.avg_profit_margin >= 30 %}
<span class="badge bg-success">{{ '%.1f'|format(customer.avg_profit_margin) }}%</span>
{% elif customer.avg_profit_margin and customer.avg_profit_margin >= 15 %}
<span class="badge bg-warning">{{ '%.1f'|format(customer.avg_profit_margin) }}%</span>
{% else %}
<span class="badge bg-danger">{{ '%.1f'|format(customer.avg_profit_margin or 0) }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-people fs-1 mb-3"></i>
<p>Ingen kunde data tilgængelig</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,220 @@
"""
Fixed-Price Agreement Frontend Views
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/fixed-price-agreements", response_class=HTMLResponse)
async def list_agreements(request: Request):
"""List all fixed-price agreements"""
# Load customers for the create modal
try:
customers_query = """
SELECT
id,
name,
cvr_number,
email,
phone,
city,
is_active
FROM customers
WHERE deleted_at IS NULL
AND is_active = true
ORDER BY name
LIMIT 1000
"""
customers = execute_query(customers_query)
logger.info(f"📋 Loaded {len(customers)} customers for modal")
except Exception as e:
logger.error(f"❌ Error loading customers: {e}")
customers = []
return templates.TemplateResponse("fixed_price/frontend/list.html", {
"request": request,
"customers": customers
})
@router.get("/fixed-price-agreements/{agreement_id}", response_class=HTMLResponse)
async def agreement_detail(request: Request, agreement_id: int):
"""Agreement detail page with periods and related sager"""
from fastapi import HTTPException
try:
# Fetch agreement
agr_query = """
SELECT a.*,
COALESCE(p.used_hours, 0) as current_used_hours,
COALESCE(p.remaining_hours, a.monthly_hours) as current_remaining_hours,
p.status as current_period_status
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods p ON p.agreement_id = a.id
AND p.period_start <= CURRENT_DATE
AND p.period_end >= CURRENT_DATE
WHERE a.id = %s
"""
agreement = execute_query(agr_query, (agreement_id,))
if not agreement:
raise HTTPException(status_code=404, detail="Aftale ikke fundet")
agreement = agreement[0]
# Fetch all billing periods
periods_query = """
SELECT * FROM fixed_price_billing_periods
WHERE agreement_id = %s
ORDER BY period_start DESC
"""
periods = execute_query(periods_query, (agreement_id,))
# Fetch related sager
sager_query = """
SELECT DISTINCT s.id, s.titel, s.status, s.created_at
FROM sag_sager s
INNER JOIN tmodule_times t ON t.sag_id = s.id
WHERE t.fixed_price_agreement_id = %s
AND s.deleted_at IS NULL
ORDER BY s.created_at DESC
LIMIT 50
"""
sager = execute_query(sager_query, (agreement_id,))
# Fetch time entries
time_query = """
SELECT t.*, s.titel as sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.fixed_price_agreement_id = %s
ORDER BY t.created_at DESC
LIMIT 100
"""
time_entries = execute_query(time_query, (agreement_id,))
return templates.TemplateResponse("fixed_price/frontend/detail.html", {
"request": request,
"agreement": agreement,
"periods": periods,
"sager": sager,
"time_entries": time_entries
})
except Exception as e:
logger.error(f"❌ Error loading agreement detail: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/fixed-price-agreements/reports/dashboard", response_class=HTMLResponse)
async def reports_dashboard(request: Request):
"""Reporting dashboard with profitability analysis"""
try:
# Get summary stats
stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_agreements,
COUNT(*) as total_agreements,
SUM(monthly_hours * hourly_rate) as total_revenue,
SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit
FROM customer_fixed_price_agreements
"""
stats = execute_query(stats_query)
# Get performance data from view
performance_query = """
SELECT
*,
CASE
WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
ELSE 0
END as profit_margin
FROM fixed_price_agreement_performance
ORDER BY total_profit DESC
LIMIT 50
"""
performance = execute_query(performance_query)
# Get monthly trends
trends_query = """
SELECT
*,
month as period_month,
CASE
WHEN monthly_total_revenue > 0 THEN (monthly_profit / monthly_total_revenue * 100)
ELSE 0
END as avg_profit_margin
FROM fixed_price_monthly_trends
ORDER BY month DESC
LIMIT 12
"""
trends = execute_query(trends_query)
# Get customer breakdown
customer_query = """
SELECT
*,
total_hours_used as total_used_hours,
CASE
WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
ELSE 0
END as avg_profit_margin
FROM fixed_price_customer_summary
ORDER BY total_used_hours DESC
LIMIT 20
"""
customers = execute_query(customer_query)
return templates.TemplateResponse("fixed_price/frontend/reports.html", {
"request": request,
"stats": stats[0] if stats else {},
"performance": performance,
"trends": trends,
"customers": customers
})
except Exception as e:
logger.error(f"❌ Error loading reports: {e}")
return templates.TemplateResponse("fixed_price/frontend/reports.html", {
"request": request,
"stats": {},
"performance": [],
"trends": [],
"customers": [],
"error": str(e)
})
@router.get("/api/fixed-price-agreements/customers")
async def get_customers_for_agreements():
"""Get all active customers for fixed-price agreement creation"""
try:
query = """
SELECT
id,
name,
cvr_number,
email,
phone,
city,
is_active
FROM customers
WHERE deleted_at IS NULL
AND is_active = true
ORDER BY name
LIMIT 1000
"""
customers = execute_query(query)
logger.info(f"📋 Loaded {len(customers)} customers for fixed-price agreements")
return customers
except Exception as e:
logger.error(f"❌ Error loading customers: {e}")
return []

View File

@ -0,0 +1,223 @@
"""
Backfill organization_name/contact_name on archived vTiger tickets.
Usage:
python -m app.jobs.backfill_vtiger_archived_contacts --limit 200 --sleep 0.35 --retries 8
"""
import argparse
import asyncio
import logging
from typing import Any, Optional
from app.core.database import init_db, execute_query, execute_update
from app.services.vtiger_service import get_vtiger_service
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("vtiger_backfill")
def _first_value(data: dict, keys: list[str]) -> Optional[str]:
for key in keys:
value = data.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return None
def _looks_like_external_id(value: Optional[str]) -> bool:
if not value:
return False
text = str(value)
return "x" in text and len(text) >= 4
async def _query_with_retry(vtiger: Any, query_string: str, retries: int, base_delay: float) -> list[dict]:
for attempt in range(retries + 1):
result = await vtiger.query(query_string)
status_code = getattr(vtiger, "last_query_status", None)
error = getattr(vtiger, "last_query_error", None) or {}
error_code = error.get("code") if isinstance(error, dict) else None
if status_code != 429 and error_code != "TOO_MANY_REQUESTS":
return result
if attempt < retries:
await asyncio.sleep(base_delay * (2**attempt))
return []
async def run(limit: int, sleep_seconds: float, retries: int, base_delay: float, only_missing_both: bool) -> None:
init_db()
vtiger = get_vtiger_service()
missing_clause = (
"COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL "
"AND COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
if only_missing_both
else "COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL OR COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
)
rows = execute_query(
f"""
SELECT id, external_id, organization_name, contact_name
FROM tticket_archived_tickets
WHERE source_system = 'vtiger'
AND ({missing_clause})
ORDER BY id ASC
LIMIT %s
""",
(limit,),
) or []
logger.info("Candidates: %s", len(rows))
account_cache: dict[str, Optional[str]] = {}
contact_cache: dict[str, Optional[str]] = {}
contact_account_cache: dict[str, Optional[str]] = {}
stats = {
"candidates": len(rows),
"updated": 0,
"unchanged": 0,
"case_missing": 0,
"errors": 0,
}
for row in rows:
archived_id = row["id"]
external_id = row["external_id"]
existing_org = (row.get("organization_name") or "").strip()
existing_contact = (row.get("contact_name") or "").strip()
try:
case_rows = await _query_with_retry(
vtiger,
f"SELECT * FROM Cases WHERE id='{external_id}' LIMIT 1;",
retries=retries,
base_delay=base_delay,
)
if not case_rows:
stats["case_missing"] += 1
continue
case_data = case_rows[0]
organization_name = _first_value(case_data, ["accountname", "account_name", "organization", "company"])
contact_name = _first_value(case_data, ["contactname", "contact_name", "contact", "firstname", "lastname"])
account_id = _first_value(case_data, ["parent_id", "account_id", "accountid", "account"])
if not organization_name and _looks_like_external_id(account_id):
if account_id not in account_cache:
account_rows = await _query_with_retry(
vtiger,
f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;",
retries=retries,
base_delay=base_delay,
)
account_cache[account_id] = _first_value(
account_rows[0] if account_rows else {},
["accountname", "account_name", "name"],
)
organization_name = account_cache.get(account_id)
contact_id = _first_value(case_data, ["contact_id", "contactid"])
if _looks_like_external_id(contact_id):
if contact_id not in contact_cache or contact_id not in contact_account_cache:
contact_rows = await _query_with_retry(
vtiger,
f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;",
retries=retries,
base_delay=base_delay,
)
contact_data = contact_rows[0] if contact_rows else {}
first_name = _first_value(contact_data, ["firstname", "first_name", "first"])
last_name = _first_value(contact_data, ["lastname", "last_name", "last"])
combined_name = " ".join([name for name in [first_name, last_name] if name]).strip()
contact_cache[contact_id] = combined_name or _first_value(
contact_data,
["contactname", "contact_name", "name"],
)
related_account_id = _first_value(
contact_data,
["account_id", "accountid", "account", "parent_id"],
)
contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None
if not contact_name:
contact_name = contact_cache.get(contact_id)
if not organization_name:
related_account_id = contact_account_cache.get(contact_id)
if related_account_id:
if related_account_id not in account_cache:
account_rows = await _query_with_retry(
vtiger,
f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;",
retries=retries,
base_delay=base_delay,
)
account_cache[related_account_id] = _first_value(
account_rows[0] if account_rows else {},
["accountname", "account_name", "name"],
)
organization_name = account_cache.get(related_account_id)
next_org = organization_name if (not existing_org and organization_name) else None
next_contact = contact_name if (not existing_contact and contact_name) else None
if not next_org and not next_contact:
stats["unchanged"] += 1
else:
execute_update(
"""
UPDATE tticket_archived_tickets
SET organization_name = CASE
WHEN COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL THEN COALESCE(%s, organization_name)
ELSE organization_name
END,
contact_name = CASE
WHEN COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL THEN COALESCE(%s, contact_name)
ELSE contact_name
END,
last_synced_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(next_org, next_contact, archived_id),
)
stats["updated"] += 1
await asyncio.sleep(sleep_seconds)
except Exception as exc:
stats["errors"] += 1
logger.warning("Row %s (%s) failed: %s", archived_id, external_id, exc)
logger.info("Backfill result: %s", stats)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=200)
parser.add_argument("--sleep", type=float, default=0.35)
parser.add_argument("--retries", type=int, default=8)
parser.add_argument("--base-delay", type=float, default=1.2)
parser.add_argument("--only-missing-both", action="store_true")
args = parser.parse_args()
asyncio.run(
run(
limit=args.limit,
sleep_seconds=args.sleep,
retries=args.retries,
base_delay=args.base_delay,
only_missing_both=args.only_missing_both,
)
)
if __name__ == "__main__":
main()

277
app/jobs/check_reminders.py Normal file
View File

@ -0,0 +1,277 @@
"""
Reminder Scheduler Job
Processes pending time-based reminders and queue-based trigger events
Runs every 5 minutes (configurable)
"""
import logging
from datetime import datetime, timedelta
import json
from app.core.config import settings
from app.core.database import execute_query, execute_insert
from app.services.reminder_notification_service import reminder_notification_service
logger = logging.getLogger(__name__)
async def check_reminders():
"""
Main job: Check for pending reminders and trigger notifications
- Process time-based reminders (scheduled_at or next_check_at <= NOW())
- Process queued trigger events from database triggers
- Handle recurring reminders (calculate next_check_at)
- Respect rate limiting (max 5 per user per hour)
"""
if not settings.REMINDERS_ENABLED:
return
try:
logger.info("🔔 Checking for pending reminders...")
# Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue()
# Step 2: Process time-based reminders
time_based_count = await _process_time_based_reminders()
logger.info(f"✅ Reminder check complete: {queue_count} queue events, {time_based_count} time-based")
except Exception as e:
logger.error(f"❌ Reminder check failed: {e}")
async def _process_reminder_queue():
"""Process queued reminder events from status change triggers"""
count = 0
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
try:
# Get pending queue events
query = """
SELECT
q.id, q.reminder_id, q.sag_id, q.event_data,
r.title, r.message, r.priority,
r.recipient_user_ids, r.recipient_emails,
r.notify_mattermost, r.notify_email, r.notify_frontend,
r.override_user_preferences,
s.titel as case_title, c.name as customer_name,
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
FROM v_pending_reminder_queue q
JOIN sag_reminders r ON q.reminder_id = r.id
JOIN sag_sager s ON q.sag_id = s.id
JOIN customers c ON s.customer_id = c.id
LIMIT %s
"""
events = execute_query(query, (batch_size,))
for event in events:
try:
# Update queue status to processing
update_query = "UPDATE sag_reminder_queue SET status = 'processing' WHERE id = %s"
execute_insert(update_query, (event['id'],))
# Get assigned user name
assigned_user = None
if event['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE id = %s"
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None
# Send reminder
result = await reminder_notification_service.send_reminder(
reminder_id=event['reminder_id'],
sag_id=event['sag_id'],
case_title=event['case_title'],
customer_name=event['customer_name'],
reminder_title=event['title'],
reminder_message=event['message'],
recipient_user_ids=event['recipient_user_ids'] or [],
recipient_emails=event['recipient_emails'] or [],
priority=event['priority'],
notify_mattermost=event['notify_mattermost'],
notify_email=event['notify_email'],
notify_frontend=event['notify_frontend'],
override_user_preferences=event['override_user_preferences'],
case_status=event['case_status'],
deadline=event['deadline'].isoformat() if event['deadline'] else None,
assigned_user=assigned_user
)
# Update queue status
if result['success']:
status = 'sent'
log_msg = None
elif result['rate_limited_users']:
status = 'rate_limited'
log_msg = f"Rate limited: {len(result['rate_limited_users'])} users"
else:
status = 'failed'
log_msg = ', '.join(result['errors'])[:500]
update_query = """
UPDATE sag_reminder_queue
SET status = %s, processed_at = CURRENT_TIMESTAMP, error_message = %s
WHERE id = %s
"""
execute_insert(update_query, (status, log_msg, event['id']))
count += 1
logger.info(f"✅ Processed queue event {event['id']} (reminder {event['reminder_id']})")
except Exception as e:
logger.error(f"❌ Failed to process queue event {event['id']}: {e}")
update_query = """
UPDATE sag_reminder_queue
SET status = 'failed', error_message = %s, processed_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_insert(update_query, (str(e)[:500], event['id']))
except Exception as e:
logger.error(f"❌ Error processing reminder queue: {e}")
return count
async def _process_time_based_reminders():
"""Process time-based reminders with scheduling"""
count = 0
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
try:
# Get pending time-based reminders
query = """
SELECT
r.id, r.sag_id, r.title, r.message, r.priority,
r.recipient_user_ids, r.recipient_emails,
r.notify_mattermost, r.notify_email, r.notify_frontend,
r.override_user_preferences,
r.recurrence_type, r.recurrence_day_of_week, r.recurrence_day_of_month,
r.next_check_at,
s.titel as case_title, c.name as customer_name,
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
FROM sag_reminders r
JOIN sag_sager s ON r.sag_id = s.id
JOIN customers c ON s.customer_id = c.id
WHERE r.is_active = true
AND r.deleted_at IS NULL
AND r.trigger_type = 'time_based'
AND r.next_check_at IS NOT NULL
AND r.next_check_at <= CURRENT_TIMESTAMP
ORDER BY r.priority DESC, r.next_check_at ASC
LIMIT %s
"""
reminders = execute_query(query, (batch_size,))
for reminder in reminders:
try:
# Get assigned user name
assigned_user = None
if reminder['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE id = %s"
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None
# Send reminder
result = await reminder_notification_service.send_reminder(
reminder_id=reminder['id'],
sag_id=reminder['sag_id'],
case_title=reminder['case_title'],
customer_name=reminder['customer_name'],
reminder_title=reminder['title'],
reminder_message=reminder['message'],
recipient_user_ids=reminder['recipient_user_ids'] or [],
recipient_emails=reminder['recipient_emails'] or [],
priority=reminder['priority'],
notify_mattermost=reminder['notify_mattermost'],
notify_email=reminder['notify_email'],
notify_frontend=reminder['notify_frontend'],
override_user_preferences=reminder['override_user_preferences'],
case_status=reminder['case_status'],
deadline=reminder['deadline'].isoformat() if reminder['deadline'] else None,
assigned_user=assigned_user
)
# Calculate next check time for recurring reminders
next_check_at = _calculate_next_check(
reminder['recurrence_type'],
reminder['recurrence_day_of_week'],
reminder['recurrence_day_of_month']
)
# Update reminder
update_query = """
UPDATE sag_reminders
SET last_sent_at = CURRENT_TIMESTAMP,
next_check_at = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_insert(update_query, (next_check_at, reminder['id']))
count += 1
logger.info(f"✅ Processed reminder {reminder['id']} (next: {next_check_at})")
except Exception as e:
logger.error(f"❌ Failed to process reminder {reminder['id']}: {e}")
except Exception as e:
logger.error(f"❌ Error processing time-based reminders: {e}")
return count
def _calculate_next_check(recurrence_type: str, day_of_week: int = None, day_of_month: int = None):
"""Calculate when reminder should be checked next"""
now = datetime.now()
if recurrence_type == 'once':
# One-time reminder - no next check
return None
elif recurrence_type == 'daily':
# Next day at same time
return now + timedelta(days=1)
elif recurrence_type == 'weekly':
# Same day next week
if day_of_week is not None:
# If specific day set, calculate days until that day
days_ahead = day_of_week - now.weekday()
if days_ahead <= 0: # Target day already happened this week
days_ahead += 7
return now + timedelta(days=days_ahead)
else:
# Next week same day
return now + timedelta(days=7)
elif recurrence_type == 'monthly':
# Same day next month
if day_of_month is not None:
try:
# Try to set day in next month
if now.month == 12:
next_month = now.replace(year=now.year + 1, month=1, day=min(day_of_month, 28))
else:
next_month = now.replace(month=now.month + 1, day=min(day_of_month, 28))
if next_month <= now:
# Already passed this month, go to next
next_month = next_month + timedelta(days=28)
return next_month
except ValueError:
# Invalid date (e.g., Feb 30), use last day of month
pass
# Fallback: 30 days from now
return now + timedelta(days=30)
return None

308
app/jobs/eset_sync.py Normal file
View File

@ -0,0 +1,308 @@
"""
ESET sync jobs
"""
import logging
from typing import Any, Dict, List, Optional
from psycopg2.extras import Json
from app.core.config import settings
from app.core.database import execute_query
from app.services.eset_service import eset_service
logger = logging.getLogger(__name__)
def _extract_first_str(payload: Any, keys: List[str]) -> Optional[str]:
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
current = stack.pop()
if isinstance(current, dict):
for k, v in current.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(current, list):
for item in current:
if isinstance(item, (dict, list)):
stack.append(item)
return None
def _extract_devices(payload: Any) -> List[Dict[str, Any]]:
if isinstance(payload, list):
return [d for d in payload if isinstance(d, dict)]
if isinstance(payload, dict):
for key in ("devices", "items", "results", "data"):
value = payload.get(key)
if isinstance(value, list):
return [d for d in value if isinstance(d, dict)]
return []
def _extract_company(payload: Any) -> Optional[str]:
company = _extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
if company:
return company
group_path = _extract_group_path(payload)
if group_path and "/" in group_path:
return group_path.split("/")[-1].strip() or None
return None
def _extract_group_path(payload: Any) -> Optional[str]:
return _extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
def _extract_group_name(payload: Any) -> Optional[str]:
group_path = _extract_group_path(payload)
if group_path and "/" in group_path:
name = group_path.split("/")[-1].strip()
return name or None
return group_path
def _extract_full_name(payload: Any) -> Optional[str]:
name = _extract_first_str(payload, ["realName", "displayName", "userName", "owner", "user", "lastLoggedInUser"])
if name:
return name
first = _extract_first_str(payload, ["firstName", "givenName"])
last = _extract_first_str(payload, ["lastName", "surname", "familyName"])
if first and last:
return f"{first} {last}".strip()
return None
def _detect_asset_type(payload: Any) -> str:
device_type = _extract_first_str(payload, ["deviceType", "type"])
if device_type:
val = device_type.lower()
if "server" in val:
return "server"
if "laptop" in val or "notebook" in val:
return "laptop"
return "pc"
def _match_contact(full_name: str, company: str) -> Optional[int]:
query = """
SELECT id
FROM contacts
WHERE LOWER(TRIM(first_name || ' ' || last_name)) = LOWER(%s)
AND LOWER(COALESCE(user_company, '')) = LOWER(%s)
LIMIT 1
"""
result = execute_query(query, (full_name, company))
if result:
return result[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, id ASC
LIMIT 1
"""
result = execute_query(query, (contact_id,))
if result:
return result[0]["customer_id"]
return None
def _match_customer_exact(name: str) -> Optional[int]:
if not name:
return None
query = "SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)"
result = execute_query(query, (name,))
if len(result or []) == 1:
return result[0]["id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
"""
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
def _upsert_incident(incident: Dict[str, Any]) -> None:
incident_uuid = incident.get("incidentUuid") or incident.get("uuid") or incident.get("id")
if not incident_uuid:
return
severity = incident.get("severity") or incident.get("level")
status = incident.get("status")
device_uuid = incident.get("deviceUuid") or incident.get("device")
detected_at = incident.get("detectedAt") or incident.get("firstSeen")
last_seen = incident.get("lastSeen") or incident.get("lastUpdate")
query = """
INSERT INTO eset_incidents (
incident_uuid, severity, status, device_uuid, detected_at, last_seen, payload, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (incident_uuid) DO UPDATE SET
severity = EXCLUDED.severity,
status = EXCLUDED.status,
device_uuid = EXCLUDED.device_uuid,
detected_at = EXCLUDED.detected_at,
last_seen = EXCLUDED.last_seen,
payload = EXCLUDED.payload,
updated_at = NOW()
"""
execute_query(query, (
incident_uuid,
severity,
status,
device_uuid,
detected_at,
last_seen,
Json(incident)
))
async def sync_eset_hardware() -> None:
if not settings.ESET_ENABLED or not settings.ESET_SYNC_ENABLED:
return
payload = await eset_service.list_devices()
if not payload:
logger.warning("ESET device list empty")
return
devices = _extract_devices(payload)
logger.info("ESET devices fetched: %d", len(devices))
for device in devices:
device_uuid = device.get("deviceUuid") or device.get("uuid") or device.get("id")
if not device_uuid:
continue
details = await eset_service.get_device_details(device_uuid)
if not details:
continue
serial = _extract_first_str(details, ["serialNumber", "serial", "serial_number"])
model = _extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
brand = _extract_first_str(details, ["manufacturer", "brand", "vendor"])
group_path = _extract_group_path(details)
group_name = _extract_group_name(details)
conditions = []
params = []
conditions.append("eset_uuid = %s")
params.append(device_uuid)
if serial:
conditions.append("serial_number = %s")
params.append(serial)
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
existing = execute_query(lookup_query, tuple(params))
full_name = _extract_full_name(details)
company = _extract_company(details)
contact_id = _match_contact(full_name, company) if full_name and company else None
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company) if (group_name or company) else None
if existing:
hardware_id = existing[0]["id"]
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
update_params = [device_uuid, Json(details)]
if group_path:
update_fields.append("eset_group = %s")
update_params.append(group_path)
if not existing[0].get("serial_number") and serial:
update_fields.append("serial_number = %s")
update_params.append(serial)
if not existing[0].get("model") and model:
update_fields.append("model = %s")
update_params.append(model)
if not existing[0].get("brand") and brand:
update_fields.append("brand = %s")
update_params.append(brand)
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s
"""
execute_query(update_query, tuple(update_params))
else:
owner_type = "customer" if customer_id else "bmc"
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
current_owner_type, current_owner_customer_id,
notes, eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
insert_params = (
_detect_asset_type(details),
brand,
model,
serial,
owner_type,
customer_id,
"Auto-created from ESET",
device_uuid,
Json(details),
group_path
)
created = execute_query(insert_query, insert_params)
hardware_id = created[0]["id"] if created else None
if contact_id and hardware_id:
_upsert_hardware_contact(hardware_id, contact_id)
if customer_id:
owner_query = """
UPDATE hardware_assets
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(owner_query, ("customer", customer_id, hardware_id))
async def sync_eset_incidents() -> None:
if not settings.ESET_ENABLED or not settings.ESET_INCIDENTS_ENABLED:
return
payload = await eset_service.list_incidents()
if not payload:
logger.warning("ESET incidents list empty")
return
incidents = _extract_devices(payload)
critical = 0
for incident in incidents:
_upsert_incident(incident)
severity = (incident.get("severity") or incident.get("level") or "").lower()
if severity in {"critical", "high", "severe"}:
critical += 1
if critical:
logger.warning("ESET critical incidents: %d", critical)
async def run_eset_sync() -> None:
await sync_eset_hardware()
await sync_eset_incidents()

View File

@ -0,0 +1,216 @@
"""
Subscription Invoice Processing Job
Processes active subscriptions when next_invoice_date is reached
Creates ordre drafts and advances subscription periods
Runs daily at 04:00
"""
import logging
from datetime import datetime, date
from decimal import Decimal
import json
from dateutil.relativedelta import relativedelta
from app.core.config import settings
from app.core.database import execute_query, get_db_connection
logger = logging.getLogger(__name__)
async def process_subscriptions():
"""
Main job: Process subscriptions due for invoicing
- Find active subscriptions where next_invoice_date <= TODAY
- Create ordre draft with line items from subscription
- Advance period_start and next_invoice_date based on billing_interval
- Log all actions for audit trail
"""
try:
logger.info("💰 Processing subscription invoices...")
# Find subscriptions due for invoicing
query = """
SELECT
s.id,
s.sag_id,
sg.titel AS sag_name,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.price,
s.next_invoice_date,
s.period_start,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', si.id,
'description', si.description,
'quantity', si.quantity,
'unit_price', si.unit_price,
'line_total', si.line_total,
'product_id', si.product_id
) ORDER BY si.id
)
FROM sag_subscription_items si
WHERE si.subscription_id = s.id
),
'[]'::json
) as line_items
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active'
AND s.next_invoice_date <= CURRENT_DATE
ORDER BY s.next_invoice_date, s.id
"""
subscriptions = execute_query(query)
if not subscriptions:
logger.info("✅ No subscriptions due for invoicing")
return
logger.info(f"📋 Found {len(subscriptions)} subscription(s) to process")
processed_count = 0
error_count = 0
for sub in subscriptions:
try:
await _process_single_subscription(sub)
processed_count += 1
except Exception as e:
logger.error(f"❌ Failed to process subscription {sub['id']}: {e}", exc_info=True)
error_count += 1
logger.info(f"✅ Subscription processing complete: {processed_count} processed, {error_count} errors")
except Exception as e:
logger.error(f"❌ Subscription processing job failed: {e}", exc_info=True)
async def _process_single_subscription(sub: dict):
"""Process a single subscription: create ordre draft and advance period"""
subscription_id = sub['id']
logger.info(f"Processing subscription #{subscription_id}: {sub['product_name']} for {sub['customer_name']}")
conn = get_db_connection()
cursor = conn.cursor()
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 = []
for item in line_items:
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
})
# Create ordre draft title with period information
period_start = sub.get('period_start') or sub.get('next_invoice_date')
next_period_start = _calculate_next_period_start(period_start, sub['billing_interval'])
title = f"Abonnement: {sub['product_name']}"
notes = f"Periode: {period_start} til {next_period_start}\nAbonnement ID: {subscription_id}"
if sub.get('sag_id'):
notes += f"\nSag: {sub['sag_name']}"
# Insert ordre draft
insert_query = """
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id
"""
cursor.execute(insert_query, (
title,
sub['customer_id'],
json.dumps(ordre_lines, ensure_ascii=False),
notes,
1, # Default layout
None, # System-created
json.dumps({"source": "subscription", "subscription_id": subscription_id}, ensure_ascii=False)
))
ordre_id = cursor.fetchone()[0]
logger.info(f"✅ Created ordre draft #{ordre_id} for subscription #{subscription_id}")
# Calculate new period dates
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
new_period_start = next_period_start
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
# Update subscription with new period dates
update_query = """
UPDATE sag_subscriptions
SET period_start = %s,
next_invoice_date = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
cursor.execute(update_query, (new_period_start, new_next_invoice_date, subscription_id))
conn.commit()
logger.info(f"✅ Advanced subscription #{subscription_id}: next invoice {new_next_invoice_date}")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
def _calculate_next_period_start(current_date, billing_interval: str) -> date:
"""Calculate next period start date based on billing interval"""
# Parse current_date if it's a string
if isinstance(current_date, str):
current_date = datetime.strptime(current_date, '%Y-%m-%d').date()
elif isinstance(current_date, datetime):
current_date = current_date.date()
# Calculate delta based on interval
if billing_interval == 'daily':
delta = relativedelta(days=1)
elif billing_interval == 'biweekly':
delta = relativedelta(weeks=2)
elif billing_interval == 'monthly':
delta = relativedelta(months=1)
elif billing_interval == 'quarterly':
delta = relativedelta(months=3)
elif billing_interval == 'yearly':
delta = relativedelta(years=1)
else:
# Default to monthly if unknown
logger.warning(f"Unknown billing interval '{billing_interval}', defaulting to monthly")
delta = relativedelta(months=1)
next_date = current_date + delta
return next_date

View File

@ -2,9 +2,10 @@
Pydantic Models and Schemas
"""
from enum import Enum
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from typing import Optional, List
from datetime import datetime, date
class CustomerBase(BaseModel):
@ -139,3 +140,239 @@ class Conversation(ConversationBase):
deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SolutionBase(BaseModel):
"""Base schema for Case Solutions"""
title: str
description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
class SolutionCreate(SolutionBase):
"""Schema for creating a solution"""
sag_id: int
created_by_user_id: Optional[int] = None
class SolutionUpdate(BaseModel):
"""Schema for updating a solution"""
title: Optional[str] = None
description: Optional[str] = None
solution_type: Optional[str] = None
result: Optional[str] = None
class Solution(SolutionBase):
"""Full solution schema"""
id: int
sag_id: int
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class UserAdminCreate(BaseModel):
username: str
email: str
password: str
full_name: Optional[str] = None
is_superadmin: bool = False
is_active: bool = True
group_ids: Optional[List[int]] = None
class UserGroupsUpdate(BaseModel):
group_ids: List[int]
class GroupCreate(BaseModel):
name: str
description: Optional[str] = None
class GroupPermissionsUpdate(BaseModel):
permission_ids: List[int]
class UserTwoFactorResetRequest(BaseModel):
reason: Optional[str] = None
# =====================================================
# AnyDesk Remote Support Integration Schemas
# =====================================================
class AnyDeskSessionCreate(BaseModel):
"""Schema for creating a new AnyDesk session"""
customer_id: int
contact_id: Optional[int] = None
sag_id: Optional[int] = None # Case/ticket ID
description: Optional[str] = None
created_by_user_id: Optional[int] = None
class AnyDeskSession(BaseModel):
"""Full AnyDesk session schema"""
id: int
anydesk_session_id: str
customer_id: int
contact_id: Optional[int] = None
sag_id: Optional[int] = None
session_link: Optional[str] = None
status: str # active, completed, failed, cancelled
started_at: str
ended_at: Optional[str] = None
duration_minutes: Optional[int] = None
created_by_user_id: Optional[int] = None
created_at: str
updated_at: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class AnyDeskSessionDetail(AnyDeskSession):
"""AnyDesk session with additional details"""
contact_name: Optional[str] = None
customer_name: Optional[str] = None
sag_title: Optional[str] = None
created_by_user_name: Optional[str] = None
device_info: Optional[dict] = None
metadata: Optional[dict] = None
class AnyDeskWorklogSuggestion(BaseModel):
"""Suggested worklog entry from a completed session"""
session_id: int
duration_hours: float
duration_minutes: int
description: str
start_time: str
end_time: str
billable: bool = True
work_type: str = "remote_support"
class AnyDeskSessionWithWorklog(BaseModel):
"""AnyDesk session with suggested worklog entry"""
session: AnyDeskSession
suggested_worklog: AnyDeskWorklogSuggestion
class TodoStepBase(BaseModel):
"""Base schema for case todo steps"""
title: str
description: Optional[str] = None
due_date: Optional[date] = None
class TodoStepCreate(TodoStepBase):
"""Schema for creating a todo step"""
pass
class TodoStepUpdate(BaseModel):
"""Schema for updating a todo step"""
is_done: Optional[bool] = None
class TodoStep(TodoStepBase):
"""Full todo step schema"""
id: int
sag_id: int
is_done: bool
created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None
created_at: datetime
completed_by_user_id: Optional[int] = None
completed_by_name: Optional[str] = None
completed_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class AnyDeskSessionHistory(BaseModel):
"""Session history response"""
sessions: List[AnyDeskSessionDetail]
total: int
limit: int
offset: int
class AnyDeskSessionUpdate(BaseModel):
"""Schema for updating a session (mainly status updates)"""
status: str
ended_at: Optional[str] = None
duration_minutes: Optional[int] = None
# ============================================================================
# SAG MODULE (Cases) - QuickCreate and Priority Support
# ============================================================================
class SagPriority(str, Enum):
"""Case priority levels matching database enum"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class QuickCreateAnalysis(BaseModel):
"""AI analysis result for QuickCreate feature"""
suggested_title: str
suggested_description: str
suggested_priority: SagPriority = SagPriority.NORMAL
suggested_customer_id: Optional[int] = None
suggested_customer_name: Optional[str] = None
suggested_technician_id: Optional[int] = None
suggested_technician_name: Optional[str] = None
suggested_group_id: Optional[int] = None
suggested_group_name: Optional[str] = None
suggested_tags: List[str] = []
hardware_references: List[dict] = [] # [{id, brand, model, serial_number}]
confidence: float = 0.0
ai_reasoning: Optional[str] = None # Debug info for low confidence
model_config = ConfigDict(from_attributes=True)
class SagBase(BaseModel):
"""Base schema for SAG (cases)"""
titel: str
beskrivelse: Optional[str] = None
priority: SagPriority = SagPriority.NORMAL
customer_id: Optional[int] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
deadline: Optional[datetime] = None
class SagCreate(SagBase):
"""Schema for creating a case"""
template_key: Optional[str] = None
tags: Optional[List[str]] = None
class SagUpdate(BaseModel):
"""Schema for updating a case"""
titel: Optional[str] = None
beskrivelse: Optional[str] = None
priority: Optional[SagPriority] = None
status: Optional[str] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
deadline: Optional[datetime] = None
class Sag(SagBase):
"""Full case schema"""
id: int
status: str
template_key: Optional[str] = None
created_by_user_id: int
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,341 @@
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
if not value:
return fallback
try:
return datetime.fromisoformat(value)
except ValueError:
return fallback
def _get_user_id(request: Request, user_id: int | None, only_mine: bool) -> int | None:
if user_id is not None:
return user_id
state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None:
return int(state_user_id)
if only_mine:
raise HTTPException(status_code=401, detail="User not authenticated")
return None
def _build_event(event_type: str, title: str, start_dt: datetime, url: str, extra: dict) -> dict:
payload = {
"id": f"{event_type}:{extra.get('reference_id')}",
"title": title,
"start": start_dt.isoformat(),
"url": url,
"event_type": event_type,
}
payload.update(extra)
return payload
def _escape_ical(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _format_ical_dt(value: datetime) -> str:
return value.strftime("%Y%m%dT%H%M%S")
def _get_calendar_events(
request: Request,
start_dt: datetime,
end_dt: datetime,
only_mine: bool,
user_id: int | None,
customer_id: int | None,
types: str | None,
) -> list[dict]:
allowed_types = {
"case_deadline",
"case_deferred",
"case_reminder",
"deadline",
"deferred",
"reminder",
"meeting",
"technician_visit",
"obs",
}
requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip()
}
if requested_types and not requested_types.issubset(allowed_types):
raise HTTPException(status_code=400, detail="Invalid event types")
type_map = {
"case_deadline": "deadline",
"case_deferred": "deferred",
"case_reminder": "reminder",
}
normalized_types = {type_map.get(t, t) for t in requested_types}
reminder_kinds = {"reminder", "meeting", "technician_visit", "obs", "deadline"}
include_deadline = not normalized_types or "deadline" in normalized_types
include_deferred = not normalized_types or "deferred" in normalized_types
include_reminder = not normalized_types or bool(reminder_kinds.intersection(normalized_types))
resolved_user_id = _get_user_id(request, user_id, only_mine)
events: list[dict] = []
if include_deadline:
query = """
SELECT s.id, s.titel, s.deadline, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN %s AND %s
"""
params: list = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deadline")
if not start_value:
continue
title = f"Deadline: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deadline",
title,
start_value,
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deadline",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deadline",
},
)
)
if include_deferred:
query = """
SELECT s.id, s.titel, s.deferred_until, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deferred_until IS NOT NULL
AND s.deferred_until BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deferred_until")
if not start_value:
continue
title = f"Defer: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deferred",
title,
start_value,
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deferred",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deferred",
},
)
)
if include_reminder:
query = """
SELECT r.id, r.title, r.message, r.priority, r.event_type, r.next_check_at, r.scheduled_at,
r.sag_id, s.titel as sag_title, s.customer_id, c.name as customer_name,
r.recipient_user_ids, r.created_by_user_id
FROM sag_reminders r
JOIN sag_sager s ON s.id = r.sag_id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE r.deleted_at IS NULL
AND r.is_active = true
AND s.deleted_at IS NULL
AND COALESCE(r.next_check_at, r.scheduled_at) BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
requested_reminder_types = sorted(reminder_kinds.intersection(normalized_types))
if requested_reminder_types:
query += " AND r.event_type = ANY(%s)"
params.append(requested_reminder_types)
if only_mine and resolved_user_id is not None:
query += " AND ((r.recipient_user_ids IS NOT NULL AND %s = ANY(r.recipient_user_ids)) OR r.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
title = f"Reminder: {row.get('title', 'Reminder')}"
case_title = row.get("sag_title")
if case_title:
title = f"{title} · {case_title}"
events.append(
_build_event(
"case_reminder",
title,
start_value,
f"/sag/{row.get('sag_id')}",
{
"reference_id": row.get("id"),
"reference_type": "reminder",
"case_id": row.get("sag_id"),
"event_kind": row.get("event_type") or "reminder",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"event_type": row.get("event_type"),
"priority": row.get("priority"),
},
)
)
events.sort(key=lambda item: item.get("start") or "")
return events
@router.get("/calendar/events")
async def get_calendar_events(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Aggregate calendar events from sag deadlines, deferred dates, and reminders."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return {"events": events}
@router.get("/calendar/ical")
async def get_calendar_ical(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Serve calendar events as an iCal feed."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
]
for event in events:
start_value = datetime.fromisoformat(event.get("start"))
summary = _escape_ical(event.get("title", ""))
description_parts = []
if event.get("customer_name"):
description_parts.append(f"Kunde: {event.get('customer_name')}")
if event.get("event_type"):
description_parts.append(f"Type: {event.get('event_type')}")
if event.get("url"):
description_parts.append(f"Link: {event.get('url')}")
description = _escape_ical("\n".join(description_parts))
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
return Response(
content="\r\n".join(lines),
media_type="text/calendar; charset=utf-8",
)

View File

@ -0,0 +1,26 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi import Depends
from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/calendar", response_class=HTMLResponse)
async def calendar_overview(
request: Request,
current_user: dict | None = Depends(get_optional_user),
):
return templates.TemplateResponse(
"modules/calendar/templates/index.html",
{
"request": request,
"current_user": current_user,
},
)

View File

@ -0,0 +1,914 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Kalender - BMC Hub{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
--calendar-bg: #f1f5f9;
--calendar-ink: #0f172a;
--calendar-subtle: #5b6b80;
--calendar-glow: rgba(15, 76, 117, 0.18);
--calendar-card: #ffffff;
--calendar-border: rgba(15, 23, 42, 0.12);
--calendar-sun: #ffb703;
--calendar-sea: #0f4c75;
--calendar-mint: #2a9d8f;
--calendar-ember: #e63946;
--calendar-violet: #5f0f40;
--calendar-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.calendar-page {
font-family: 'IBM Plex Sans', sans-serif;
color: var(--calendar-ink);
}
.calendar-hero {
background: radial-gradient(circle at top left, rgba(15, 76, 117, 0.15), transparent 45%),
linear-gradient(135deg, #e3edf7, #fdfbff 55%, #edf2f7);
border-radius: 20px;
padding: 2rem clamp(1.5rem, 3vw, 3rem);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 2rem;
align-items: center;
box-shadow: var(--calendar-shadow);
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.calendar-hero::after {
content: "";
position: absolute;
top: -120px;
right: -140px;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(255, 183, 3, 0.35), transparent 70%);
filter: blur(0px);
opacity: 0.8;
}
.hero-kicker {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
color: var(--calendar-sea);
font-weight: 600;
}
.calendar-hero h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2rem, 2.5vw, 3rem);
margin: 0.5rem 0 0.8rem;
}
.calendar-hero p {
color: var(--calendar-subtle);
margin-bottom: 1rem;
max-width: 46ch;
}
.hero-meta {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.9rem;
color: var(--calendar-subtle);
}
.hero-meta span {
font-weight: 600;
color: var(--calendar-ink);
}
.hero-ical {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--calendar-subtle);
word-break: break-all;
}
.hero-ical a {
color: var(--calendar-sea);
font-weight: 600;
text-decoration: none;
}
.calendar-filter-card {
background: var(--calendar-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--calendar-border);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
position: relative;
z-index: 1;
}
.filter-title {
font-weight: 600;
margin-bottom: 1rem;
font-size: 1rem;
}
.toggle-group {
display: inline-flex;
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.25rem;
gap: 0.25rem;
}
.toggle-group button {
border: none;
background: transparent;
padding: 0.45rem 1rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
color: var(--calendar-subtle);
}
.toggle-group button.active {
background: var(--calendar-sea);
color: #ffffff;
box-shadow: 0 6px 12px rgba(15, 76, 117, 0.3);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.filter-block label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--calendar-subtle);
margin-bottom: 0.35rem;
}
.filter-block select,
.filter-block input {
border-radius: 10px;
border: 1px solid var(--calendar-border);
padding: 0.5rem 0.75rem;
width: 100%;
background: #ffffff;
color: var(--calendar-ink);
}
.type-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.type-tag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #ffffff;
border: 1px solid var(--calendar-border);
border-radius: 8px;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
color: var(--calendar-ink);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.type-tag:hover {
background: var(--calendar-bg);
border-color: rgba(15, 76, 117, 0.2);
}
.type-tag input {
accent-color: var(--calendar-sea);
width: 1rem;
height: 1rem;
cursor: pointer;
margin: 0;
}
.type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
}
.btn-calendar-action {
border: none;
border-radius: 999px;
background: var(--calendar-ink);
color: #ffffff;
padding: 0.5rem 1.2rem;
font-weight: 600;
}
.case-search-box {
position: relative;
}
.case-search-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: #ffffff;
border: 1px solid var(--calendar-border);
border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
max-height: 280px;
overflow-y: auto;
z-index: 20;
display: none;
}
.case-search-results.show {
display: block;
}
.case-search-item {
padding: 0.65rem 0.85rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.case-search-item strong {
font-size: 0.9rem;
}
.case-search-item small {
color: var(--calendar-subtle);
font-size: 0.75rem;
}
.case-search-item:hover,
.case-search-item.active {
background: var(--accent-light);
}
.calendar-shell {
background: var(--calendar-card);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid var(--calendar-border);
box-shadow: var(--calendar-shadow);
}
.calendar-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.view-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.view-buttons button {
border: 1px solid var(--calendar-border);
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--calendar-subtle);
}
.view-buttons button.active {
background: var(--calendar-ink);
color: #ffffff;
border-color: transparent;
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
color: var(--calendar-subtle);
font-size: 0.85rem;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-bar {
font-size: 0.85rem;
color: var(--calendar-subtle);
}
.fc {
font-family: 'IBM Plex Sans', sans-serif;
}
.fc .fc-toolbar-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.3rem;
}
.fc .fc-daygrid-day-number {
color: var(--calendar-subtle);
}
.fc .fc-day-today {
background: rgba(255, 183, 3, 0.12) !important;
}
.fc-event {
border: none;
border-radius: 10px;
padding: 2px 6px;
font-size: 0.78rem;
}
.event-case_deadline,
.event-deadline {
background: rgba(230, 57, 70, 0.18);
color: #8b1d29;
}
.event-case_deferred,
.event-deferred {
background: rgba(95, 15, 64, 0.15);
color: #5f0f40;
}
.event-case_reminder,
.event-reminder {
background: rgba(42, 157, 143, 0.2);
color: #1f6f66;
}
.event-meeting {
background: rgba(15, 76, 117, 0.18);
color: #0f4c75;
}
.event-technician_visit {
background: rgba(255, 183, 3, 0.22);
color: #8a5b00;
}
.event-obs {
background: rgba(90, 96, 168, 0.2);
color: #3d3f7a;
}
.fade-up {
animation: fadeUp 0.6s ease both;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.calendar-hero {
padding: 1.5rem;
}
.calendar-shell {
padding: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4 calendar-page">
<div class="calendar-hero fade-up">
<div>
<div class="hero-kicker">BMC Hub Kalender</div>
<h1>Kalender for drift og overblik</h1>
<p>Samlet visning af sag-deadlines, deferred datoer og reminders. Brug det som et kontrolpanel, ikke som pynt.</p>
<div class="hero-meta">
<div>Events i interval: <span id="eventCount">0</span></div>
<div>|</div>
<div>Status: <span id="calendarStatus">Klar</span></div>
</div>
<div class="hero-ical">
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
</div>
</div>
<div class="calendar-filter-card">
<div class="filter-title">Filtre og fokus</div>
<div class="toggle-group" role="group" aria-label="Mine eller alle">
<button type="button" id="mineToggle" class="active">Mine</button>
<button type="button" id="allToggle">Alle</button>
</div>
<div class="filter-grid">
<div class="filter-block">
<label for="customerSelect">Kunde</label>
<input type="text" id="customerSearch" placeholder="Sog kunde..." class="form-control mb-2">
<select id="customerSelect">
<option value="">Alle kunder</option>
</select>
</div>
<div class="filter-block">
<label>Event typer</label>
<div class="type-tags">
<label class="type-tag" title="Vis deadlines">
<input type="checkbox" class="type-filter" value="deadline" checked>
<span class="type-dot" style="background: var(--calendar-ember);"></span>
Deadline
</label>
<label class="type-tag" title="Vis udsatte sager">
<input type="checkbox" class="type-filter" value="deferred" checked>
<span class="type-dot" style="background: var(--calendar-violet);"></span>
Deferred
</label>
<label class="type-tag" title="Vis møder">
<input type="checkbox" class="type-filter" value="meeting" checked>
<span class="type-dot" style="background: var(--calendar-sea);"></span>
Møde
</label>
<label class="type-tag" title="Vis teknikerbesøg">
<input type="checkbox" class="type-filter" value="technician_visit" checked>
<span class="type-dot" style="background: var(--calendar-sun);"></span>
Tekniker
</label>
<label class="type-tag" title="Vis OBS punkter">
<input type="checkbox" class="type-filter" value="obs" checked>
<span class="type-dot" style="background: #5a60a8;"></span>
OBS
</label>
<label class="type-tag" title="Vis reminders">
<input type="checkbox" class="type-filter" value="reminder" checked>
<span class="type-dot" style="background: var(--calendar-mint);"></span>
Reminder
</label>
</div>
</div>
</div>
<div class="action-row">
<div class="text-muted small">Opret aftaler direkte i kalenderen.</div>
<button class="btn-calendar-action" type="button" onclick="openCalendarModal()">Opret aftale</button>
</div>
</div>
</div>
<div class="calendar-shell fade-up">
<div class="calendar-toolbar">
<div class="view-buttons" id="viewButtons">
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
<button type="button" data-view="timeGridWeek">Uge</button>
<button type="button" data-view="timeGridDay">Dag</button>
<button type="button" data-view="listWeek">Agenda</button>
</div>
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
</div>
<div id="calendar"></div>
<div class="calendar-legend">
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesøg</div>
<div class="legend-item"><span class="legend-dot" style="background: #5a60a8;"></span>OBS</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
</div>
</div>
</div>
<div class="modal fade" id="calendarCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret kalenderaftale</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 case-search-box">
<label class="form-label">Sag *</label>
<input type="text" class="form-control" id="caseSearch" placeholder="Sog sag...">
<div id="caseResults" class="case-search-results"></div>
<div class="form-text" id="caseSelectedHint">Ingen sag valgt</div>
</div>
<div class="col-md-6">
<label class="form-label">Type *</label>
<select class="form-select" id="calendarEventType">
<option value="meeting">Møde</option>
<option value="technician_visit">Teknikerbesøg</option>
<option value="obs">OBS</option>
<option value="reminder">Reminder</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Tidspunkt *</label>
<input type="datetime-local" class="form-control" id="calendarEventTime">
</div>
<div class="col-12">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Møde om status">
</div>
<div class="col-12">
<label class="form-label">Besked</label>
<textarea class="form-control" id="calendarEventMessage" rows="3"></textarea>
</div>
<div class="col-12">
<div class="alert alert-warning small d-none" id="calendarEventWarning">
Mangler bruger-id. Log ind igen eller opdater siden.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveCalendarEvent()">Gem aftale</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
<script>
const calendarEl = document.getElementById('calendar');
const eventCountEl = document.getElementById('eventCount');
const calendarStatusEl = document.getElementById('calendarStatus');
const rangeLabelEl = document.getElementById('rangeLabel');
const customerSelect = document.getElementById('customerSelect');
const customerSearch = document.getElementById('customerSearch');
const mineToggle = document.getElementById('mineToggle');
const allToggle = document.getElementById('allToggle');
const viewButtons = document.getElementById('viewButtons');
const typeFilters = Array.from(document.querySelectorAll('.type-filter'));
const customerOptions = [];
const caseResults = document.getElementById('caseResults');
const caseSearch = document.getElementById('caseSearch');
const caseSelectedHint = document.getElementById('caseSelectedHint');
const calendarEventWarning = document.getElementById('calendarEventWarning');
let selectedCaseId = null;
let caseOptions = [];
let caseActiveIndex = -1;
let onlyMine = true;
function setToggle(activeMine) {
onlyMine = activeMine;
mineToggle.classList.toggle('active', activeMine);
allToggle.classList.toggle('active', !activeMine);
calendar.refetchEvents();
}
function getSelectedTypes() {
return typeFilters.filter(input => input.checked).map(input => input.value);
}
function getCalendarUserId() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub || payload.user_id;
} catch (e) {
console.warn('Could not decode token for calendar user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) return metaTag.getAttribute('content');
return null;
}
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (!response.ok) {
return;
}
const data = await response.json();
const customers = data.customers || [];
customers.forEach(customer => {
customerOptions.push({ id: customer.id, name: customer.name });
const option = document.createElement('option');
option.value = customer.id;
option.textContent = customer.name;
customerSelect.appendChild(option);
});
} catch (err) {
console.warn('Customer load failed', err);
}
}
function filterCustomerOptions() {
const query = customerSearch.value.trim().toLowerCase();
customerSelect.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = 'Alle kunder';
customerSelect.appendChild(allOption);
customerOptions
.filter(item => !query || item.name.toLowerCase().includes(query))
.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
customerSelect.appendChild(option);
});
}
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
height: 'auto',
dayMaxEvents: true,
nowIndicator: true,
locale: 'da',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
events: async (info, successCallback, failureCallback) => {
calendarStatusEl.textContent = 'Henter data...';
const params = new URLSearchParams();
params.set('start', info.startStr);
params.set('end', info.endStr);
params.set('only_mine', onlyMine ? 'true' : 'false');
const selectedTypes = getSelectedTypes();
if (selectedTypes.length) {
params.set('types', selectedTypes.join(','));
}
if (customerSelect.value) {
params.set('customer_id', customerSelect.value);
}
try {
const response = await fetch(`/api/v1/calendar/events?${params.toString()}`);
if (!response.ok) {
throw new Error('Request failed');
}
const data = await response.json();
calendarStatusEl.textContent = 'Opdateret';
successCallback(data.events || []);
} catch (error) {
calendarStatusEl.textContent = 'Fejl';
failureCallback(error);
}
},
eventClassNames: (arg) => {
const kind = arg.event.extendedProps.event_kind || arg.event.extendedProps.event_type;
return [`event-${kind || arg.event.extendedProps.event_type}`];
},
eventDidMount: (info) => {
const customerName = info.event.extendedProps.customer_name;
if (customerName) {
info.el.title = `${info.event.title} - ${customerName}`;
}
},
datesSet: (info) => {
rangeLabelEl.textContent = `${info.startStr} til ${info.endStr}`;
},
eventsSet: (events) => {
eventCountEl.textContent = events.length;
}
});
calendar.render();
loadCustomers();
mineToggle.addEventListener('click', () => setToggle(true));
allToggle.addEventListener('click', () => setToggle(false));
typeFilters.forEach(input => {
input.addEventListener('change', () => calendar.refetchEvents());
});
customerSelect.addEventListener('change', () => calendar.refetchEvents());
customerSearch.addEventListener('input', () => {
filterCustomerOptions();
});
function renderCaseResults() {
caseResults.innerHTML = '';
if (!caseOptions.length) {
caseResults.classList.remove('show');
return;
}
caseOptions.forEach((item, index) => {
const div = document.createElement('div');
div.className = `case-search-item${index === caseActiveIndex ? ' active' : ''}`;
div.innerHTML = `<strong>${item.title}</strong><small>Sag #${item.id}</small>`;
div.addEventListener('click', () => selectCase(item));
caseResults.appendChild(div);
});
caseResults.classList.add('show');
}
function selectCase(item) {
selectedCaseId = item.id;
caseSearch.value = item.title;
caseSelectedHint.textContent = `Valgt sag #${item.id}`;
caseResults.classList.remove('show');
caseOptions = [];
caseActiveIndex = -1;
}
async function searchCases(query) {
if (!query || query.length < 1) {
caseOptions = [];
renderCaseResults();
return;
}
try {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', '12');
const response = await fetch(`/api/v1/sag?${params.toString()}`);
if (!response.ok) {
throw new Error('Request failed');
}
const data = await response.json();
caseOptions = (data || []).map(item => ({ id: item.id, title: item.titel || item.title || `Sag #${item.id}` }))
.sort((a, b) => a.title.localeCompare(b.title, 'da'))
.slice(0, 12);
caseActiveIndex = -1;
renderCaseResults();
} catch (err) {
console.warn('Case search failed', err);
caseOptions = [];
renderCaseResults();
}
}
let caseSearchTimer = null;
caseSearch.addEventListener('input', () => {
const query = caseSearch.value.trim();
selectedCaseId = null;
caseSelectedHint.textContent = 'Ingen sag valgt';
if (caseSearchTimer) {
clearTimeout(caseSearchTimer);
}
caseSearchTimer = setTimeout(() => searchCases(query), 200);
});
caseSearch.addEventListener('keydown', (event) => {
if (!caseOptions.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
caseActiveIndex = Math.min(caseActiveIndex + 1, caseOptions.length - 1);
renderCaseResults();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
caseActiveIndex = Math.max(caseActiveIndex - 1, 0);
renderCaseResults();
} else if (event.key === 'Enter') {
event.preventDefault();
if (caseActiveIndex >= 0) {
selectCase(caseOptions[caseActiveIndex]);
}
}
});
function openCalendarModal() {
const userId = getCalendarUserId();
if (calendarEventWarning) {
calendarEventWarning.classList.toggle('d-none', !!userId);
}
selectedCaseId = null;
caseSearch.value = '';
caseSelectedHint.textContent = 'Ingen sag valgt';
document.getElementById('calendarEventType').value = 'meeting';
document.getElementById('calendarEventTime').value = '';
document.getElementById('calendarEventTitle').value = '';
document.getElementById('calendarEventMessage').value = '';
caseOptions = [];
caseActiveIndex = -1;
caseResults.classList.remove('show');
new bootstrap.Modal(document.getElementById('calendarCreateModal')).show();
}
async function saveCalendarEvent() {
const userId = getCalendarUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
const eventType = document.getElementById('calendarEventType').value;
const eventTime = document.getElementById('calendarEventTime').value;
const title = document.getElementById('calendarEventTitle').value.trim();
const message = document.getElementById('calendarEventMessage').value.trim();
if (!selectedCaseId) {
alert('Vælg en sag');
return;
}
if (!eventTime) {
alert('Vælg tidspunkt');
return;
}
if (!title) {
alert('Titel er påkrævet');
return;
}
const payload = {
title,
message: message || null,
priority: 'normal',
event_type: eventType,
trigger_type: 'time_based',
trigger_config: {},
recipient_user_ids: [Number(userId)],
recipient_emails: [],
notify_mattermost: false,
notify_email: false,
notify_frontend: true,
override_user_preferences: false,
recurrence_type: 'once',
recurrence_day_of_week: null,
recurrence_day_of_month: null,
scheduled_at: new Date(eventTime).toISOString()
};
try {
const res = await fetch(`/api/v1/sag/${selectedCaseId}/reminders?user_id=${userId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette aftale');
}
bootstrap.Modal.getInstance(document.getElementById('calendarCreateModal')).hide();
calendar.refetchEvents();
} catch (err) {
alert('Fejl: ' + err.message);
}
}
viewButtons.addEventListener('click', (event) => {
const target = event.target.closest('button[data-view]');
if (!target) return;
const view = target.dataset.view;
calendar.changeView(view);
Array.from(viewButtons.querySelectorAll('button')).forEach(btn => {
btn.classList.toggle('active', btn === target);
});
});
const autoRefreshMs = 5 * 60 * 1000;
setInterval(() => {
if (document.visibilityState === 'visible') {
calendar.refetchEvents();
}
}, autoRefreshMs);
window.addEventListener('focus', () => {
calendar.refetchEvents();
});
</script>
{% endblock %}

View File

@ -2,6 +2,8 @@ import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from app.core.database import execute_query
from app.services.eset_service import eset_service
from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
@ -9,6 +11,92 @@ import uuid
logger = logging.getLogger(__name__)
router = APIRouter()
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None
key_set = {k.lower() for k in keys}
stack = [payload]
while stack:
current = stack.pop()
if isinstance(current, dict):
for k, v in current.items():
if k.lower() in key_set and isinstance(v, str) and v.strip():
return v.strip()
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(current, list):
for item in current:
if isinstance(item, (dict, list)):
stack.append(item)
return None
def _eset_extract_group_path(payload: dict) -> Optional[str]:
return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
def _eset_extract_group_name(payload: dict) -> Optional[str]:
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
name = group_path.split("/")[-1].strip()
return name or None
return group_path
def _eset_extract_company(payload: dict) -> Optional[str]:
company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
if company:
return company
group_path = _eset_extract_group_path(payload)
if group_path and "/" in group_path:
return group_path.split("/")[-1].strip() or None
return None
def _eset_detect_asset_type(payload: dict) -> str:
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
if device_type:
val = device_type.lower()
if "server" in val:
return "server"
if "laptop" in val or "notebook" in val:
return "laptop"
return "pc"
def _match_customer_exact(name: str) -> Optional[int]:
if not name:
return None
result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,))
if len(result or []) == 1:
return result[0]["id"]
return None
def _get_contact_customer(contact_id: int) -> Optional[int]:
query = """
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
ORDER BY is_primary DESC, id ASC
LIMIT 1
"""
result = execute_query(query, (contact_id,))
if result:
return result[0]["customer_id"]
return None
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
"""
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
# ============================================================================
# CRUD Endpoints for Hardware Assets
# ============================================================================
@ -45,6 +133,131 @@ async def list_hardware(
return result or []
@router.get("/hardware/by-customer/{customer_id}", response_model=List[dict])
async def list_hardware_by_customer(customer_id: int):
"""List hardware assets owned by a customer."""
query = """
SELECT * FROM hardware_assets
WHERE deleted_at IS NULL AND current_owner_customer_id = %s
ORDER BY created_at DESC
"""
result = execute_query(query, (customer_id,))
return result or []
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int):
"""
List hardware assets linked directly to a contact.
Supports both hardware_assets (new) and hardware (legacy) tables.
"""
# Try new hardware_assets table via hardware_contacts
query_new = """
SELECT DISTINCT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
h.created_at,
'hardware_assets' as source_table
FROM hardware_assets h
JOIN hardware_contacts hc ON hc.hardware_id = h.id
WHERE hc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_new = execute_query(query_new, (contact_id,))
# Also check legacy hardware table via customer_id (if contact has companies)
query_legacy = """
SELECT DISTINCT
h.id,
NULL as asset_type,
NULL as brand,
h.model,
h.serial_number,
NULL as anydesk_id,
NULL as anydesk_link,
'active' as status,
NULL as notes,
h.created_at,
'hardware' as source_table
FROM hardware h
WHERE h.customer_id IN (
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
)
AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_legacy = execute_query(query_legacy, (contact_id,))
# Merge results, prioritizing new table
all_results = (result_new or []) + (result_legacy or [])
return all_results
@router.get("/hardware/unassigned", response_model=List[dict])
async def list_unassigned_hardware(limit: int = 200):
"""List hardware assets not linked to any contact."""
query = """
SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number, h.status
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM hardware_contacts hc WHERE hc.hardware_id = h.id
)
ORDER BY h.created_at DESC
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.post("/hardware/{hardware_id}/assign-contact", response_model=dict)
async def assign_hardware_to_contact(hardware_id: int, payload: dict):
"""Link hardware asset to a contact."""
contact_id = payload.get("contact_id")
if not contact_id:
raise HTTPException(status_code=422, detail="contact_id is required")
hardware = execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
if not hardware:
raise HTTPException(status_code=404, detail="Hardware not found")
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
execute_query(
"""
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
""",
(hardware_id, contact_id, "primary", "manual"),
)
customer_id = _get_contact_customer(int(contact_id))
if customer_id:
execute_query(
"""
UPDATE hardware_assets
SET current_owner_customer_id = COALESCE(current_owner_customer_id, %s)
WHERE id = %s
""",
(customer_id, hardware_id),
)
return {"status": "ok"}
@router.post("/hardware", response_model=dict)
async def create_hardware(data: dict):
"""Create a new hardware asset."""
@ -53,11 +266,18 @@ async def create_hardware(data: dict):
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id,
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
status, status_reason, warranty_until, end_of_life
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
specs = data.get("hardware_specs")
if specs:
specs = Json(specs)
params = (
data.get("asset_type"),
data.get("brand"),
@ -72,6 +292,11 @@ async def create_hardware(data: dict):
data.get("status_reason"),
data.get("warranty_until"),
data.get("end_of_life"),
data.get("anydesk_id"),
data.get("anydesk_link"),
data.get("eset_uuid"),
specs,
data.get("eset_group")
)
result = execute_query(query, params)
if not result:
@ -103,6 +328,50 @@ async def create_hardware(data: dict):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/hardware/quick", response_model=dict)
async def quick_create_hardware(data: dict):
"""Quick create hardware with minimal fields (name + AnyDesk info)."""
try:
name = (data.get("name") or "").strip()
customer_id = data.get("customer_id")
anydesk_id = (data.get("anydesk_id") or "").strip() or None
anydesk_link = (data.get("anydesk_link") or "").strip() or None
if not name:
raise HTTPException(status_code=400, detail="Name is required")
if not customer_id:
raise HTTPException(status_code=400, detail="Customer ID is required")
query = """
INSERT INTO hardware_assets (
asset_type, model, current_owner_type, current_owner_customer_id,
status, anydesk_id, anydesk_link, notes
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
"andet",
name,
"customer",
customer_id,
"active",
anydesk_id,
anydesk_link,
"Quick created from case/ticket flow",
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create hardware")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Failed to quick-create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/hardware/{hardware_id}", response_model=dict)
async def get_hardware(hardware_id: int):
"""Get hardware details by ID."""
@ -127,13 +396,17 @@ async def update_hardware(hardware_id: int, data: dict):
"asset_type", "brand", "model", "serial_number", "customer_asset_id",
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
"status", "status_reason", "warranty_until", "end_of_life",
"follow_up_date", "follow_up_owner_user_id"
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
"eset_uuid", "hardware_specs", "eset_group"
]
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
params.append(data[field])
val = data[field]
if field == "hardware_specs" and val:
val = Json(val)
params.append(val)
if not update_fields:
raise HTTPException(status_code=400, detail="No valid fields to update")
@ -513,3 +786,212 @@ async def search_hardware(q: str = Query(..., min_length=1)):
logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
return result or []
@router.post("/hardware/{hardware_id}/sync-eset", response_model=dict)
async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)):
"""Sync hardware data from ESET."""
# Get current hardware
check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
result = execute_query(check_query, (hardware_id,))
if not result:
raise HTTPException(status_code=404, detail="Hardware not found")
current = result[0]
# Determine UUID
uuid_to_use = eset_uuid or current.get("eset_uuid")
if not uuid_to_use:
raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.")
# Fetch from ESET
details = await eset_service.get_device_details(uuid_to_use)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
# Update hardware asset
update_data = {
"eset_uuid": uuid_to_use,
"hardware_specs": details
}
# We can perform the update directly here or call update_hardware if available
return await update_hardware(hardware_id, update_data)
@router.get("/hardware/eset/test", response_model=dict)
async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
"""Test ESET device lookup by UUID."""
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
return details
@router.get("/hardware/eset/devices", response_model=dict)
async def list_eset_devices(
page_size: Optional[int] = Query(None, ge=1, le=1000),
page_token: Optional[str] = Query(None)
):
"""List devices directly from ESET Device Management."""
payload = await eset_service.list_devices(page_size=page_size, page_token=page_token)
if not payload:
raise HTTPException(status_code=404, detail="No devices returned from ESET")
return payload
@router.post("/hardware/eset/import", response_model=dict)
async def import_eset_device(data: dict):
"""Import ESET device into hardware assets and optionally link to contact."""
device_uuid = (data.get("device_uuid") or "").strip()
contact_id = data.get("contact_id")
if not device_uuid:
raise HTTPException(status_code=400, detail="device_uuid is required")
details = await eset_service.get_device_details(device_uuid)
if not details:
raise HTTPException(status_code=404, detail="Device not found in ESET")
serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"])
model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"])
group_path = _eset_extract_group_path(details)
group_name = _eset_extract_group_name(details)
company = _eset_extract_company(details)
if contact_id:
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact_check:
raise HTTPException(status_code=404, detail="Contact not found")
customer_id = _get_contact_customer(contact_id) if contact_id else None
if not customer_id:
customer_id = _match_customer_exact(group_name or company)
owner_type = "customer" if customer_id else "bmc"
conditions = ["eset_uuid = %s"]
params = [device_uuid]
if serial:
conditions.append("serial_number = %s")
params.append(serial)
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
existing = execute_query(lookup_query, tuple(params))
if existing:
hardware_id = existing[0]["id"]
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
update_params = [device_uuid, Json(details)]
if group_path:
update_fields.append("eset_group = %s")
update_params.append(group_path)
if not existing[0].get("serial_number") and serial:
update_fields.append("serial_number = %s")
update_params.append(serial)
if not existing[0].get("model") and model:
update_fields.append("model = %s")
update_params.append(model)
if not existing[0].get("brand") and brand:
update_fields.append("brand = %s")
update_params.append(brand)
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)
update_params.append(hardware_id)
update_query = f"""
UPDATE hardware_assets
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
hardware = execute_query(update_query, tuple(update_params))
hardware = hardware[0] if hardware else None
else:
insert_query = """
INSERT INTO hardware_assets (
asset_type, brand, model, serial_number,
current_owner_type, current_owner_customer_id,
notes, eset_uuid, hardware_specs, eset_group
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
insert_params = (
_eset_detect_asset_type(details),
brand,
model,
serial,
owner_type,
customer_id,
"Imported from ESET",
device_uuid,
Json(details),
group_path
)
hardware = execute_query(insert_query, insert_params)
hardware = hardware[0] if hardware else None
if hardware and contact_id:
_upsert_hardware_contact(hardware["id"], contact_id)
return hardware or {}
@router.get("/hardware/eset/matches", response_model=List[dict])
async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)):
"""List ESET-matched hardware with contact/customer info."""
query = """
SELECT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.eset_uuid,
h.eset_group,
h.updated_at,
hc.contact_id,
c.first_name,
c.last_name,
c.user_company,
cc.customer_id,
cust.name AS customer_name
FROM hardware_assets h
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
LEFT JOIN contacts c ON c.id = hc.contact_id
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
LEFT JOIN customers cust ON cust.id = cc.customer_id
WHERE h.deleted_at IS NULL
ORDER BY h.updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.get("/hardware/eset/incidents", response_model=List[dict])
async def list_eset_incidents(
severity: Optional[str] = Query("critical"),
limit: int = Query(200, ge=1, le=2000)
):
"""List cached ESET incidents by severity."""
severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()]
if not severity_list:
severity_list = ["critical"]
query = """
SELECT *
FROM eset_incidents
WHERE LOWER(COALESCE(severity, '')) = ANY(%s)
ORDER BY updated_at DESC NULLS LAST
LIMIT %s
"""
result = execute_query(query, (severity_list, limit))
return result or []

View File

@ -1,10 +1,12 @@
import logging
from fastapi import APIRouter, HTTPException, Query, Request, Form
from typing import Optional, Any
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from datetime import date
from app.core.database import execute_query
from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__)
router = APIRouter()
@ -50,6 +52,170 @@ def build_location_tree(items: list) -> list:
return roots
def extract_eset_specs_summary(hardware: dict) -> dict:
specs = hardware.get("hardware_specs") or {}
if not isinstance(specs, dict):
return {}
if "device" in specs and isinstance(specs.get("device"), dict):
specs = specs["device"]
def format_bytes(raw_value: Optional[str]) -> Optional[str]:
if not raw_value:
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
return None
if value <= 0:
return None
gb = value / (1024 ** 3)
return f"{gb:.1f} GB"
os_info = specs.get("operatingSystem") or {}
os_name = os_info.get("displayName")
os_version = (os_info.get("version") or {}).get("name")
hardware_profiles = specs.get("hardwareProfiles") or []
profile = hardware_profiles[0] if hardware_profiles else {}
bios = profile.get("bios") or {}
processors = profile.get("processors") or []
hard_drives = profile.get("hardDrives") or []
network_adapters = profile.get("networkAdapters") or []
cpu_models = [p.get("caption") for p in processors if isinstance(p, dict) and p.get("caption")]
disk_models = [d.get("displayName") for d in hard_drives if isinstance(d, dict) and d.get("displayName")]
disk_sizes = [format_bytes(d.get("capacityBytes")) for d in hard_drives if isinstance(d, dict)]
disk_summaries = []
for drive in hard_drives:
if not isinstance(drive, dict):
continue
name = drive.get("displayName")
size = format_bytes(drive.get("capacityBytes"))
if name and size:
disk_summaries.append(f"{name} ({size})")
elif name:
disk_summaries.append(name)
adapter_names = [n.get("caption") for n in network_adapters if isinstance(n, dict) and n.get("caption")]
macs = [n.get("macAddress") for n in network_adapters if isinstance(n, dict) and n.get("macAddress")]
installed_software = []
def _normalize_version(value: Any) -> str:
if isinstance(value, dict):
name = str(value.get("name") or "").strip()
if name:
return name
version_id = str(value.get("id") or "").strip()
if version_id:
return version_id
major = value.get("major")
minor = value.get("minor")
patch = value.get("patch")
if major is not None and minor is not None and patch is not None:
return f"{major}.{minor}.{patch}"
return ""
if value is None:
return ""
return str(value).strip()
def _add_software_item(name: Optional[str], version: Any = None) -> None:
if not name:
return
item_name = str(name).strip()
item_version = _normalize_version(version)
if not item_name:
return
if item_version:
installed_software.append(f"{item_name} {item_version}")
else:
installed_software.append(item_name)
# ESET standard format
for comp in specs.get("deployedComponents") or []:
if not isinstance(comp, dict):
continue
_add_software_item(
comp.get("displayName") or comp.get("name"),
comp.get("version"),
)
# Alternative common payload names
for comp in specs.get("installedSoftware") or []:
if isinstance(comp, dict):
_add_software_item(comp.get("displayName") or comp.get("name") or comp.get("softwareName"), comp.get("version"))
elif isinstance(comp, str):
_add_software_item(comp)
for comp in specs.get("applications") or []:
if isinstance(comp, dict):
_add_software_item(comp.get("displayName") or comp.get("name") or comp.get("applicationName"), comp.get("version"))
elif isinstance(comp, str):
_add_software_item(comp)
for comp in specs.get("activeProducts") or []:
if isinstance(comp, dict):
_add_software_item(
comp.get("displayName") or comp.get("name") or comp.get("productName") or comp.get("product"),
comp.get("version") or comp.get("productVersion"),
)
elif isinstance(comp, str):
_add_software_item(comp)
for key in ("applicationInventory", "softwareInventory"):
for comp in specs.get(key) or []:
if isinstance(comp, dict):
_add_software_item(
comp.get("displayName") or comp.get("name") or comp.get("applicationName") or comp.get("softwareName"),
comp.get("version") or comp.get("applicationVersion") or comp.get("softwareVersion"),
)
elif isinstance(comp, str):
_add_software_item(comp)
# Keep ordering but remove duplicates
seen = set()
deduped_installed_software = []
for item in installed_software:
key = item.lower()
if key in seen:
continue
seen.add(key)
deduped_installed_software.append(item)
installed_software_details = []
for item in deduped_installed_software:
match = item.rsplit(" ", 1)
if len(match) == 2 and any(ch.isdigit() for ch in match[1]):
installed_software_details.append({"name": match[0], "version": match[1]})
else:
installed_software_details.append({"name": item, "version": ""})
return {
"os_name": os_name,
"os_version": os_version,
"primary_local_ip": specs.get("primaryLocalIpAddress"),
"public_ip": specs.get("publicIpAddress"),
"device_name": specs.get("displayName"),
"manufacturer": profile.get("manufacturer"),
"model": profile.get("model"),
"bios_serial": bios.get("serialNumber"),
"bios_vendor": bios.get("manufacturer"),
"cpu_models": cpu_models,
"disk_models": disk_models,
"disk_summaries": disk_summaries,
"disk_sizes": disk_sizes,
"adapter_names": adapter_names,
"macs": macs,
"functionality_status": specs.get("functionalityStatus"),
"last_sync_time": specs.get("lastSyncTime"),
"device_type": specs.get("deviceType"),
"deployed_components": deduped_installed_software,
"installed_software_details": installed_software_details,
}
@router.get("/hardware", response_class=HTMLResponse)
async def hardware_list(
request: Request,
@ -83,7 +249,7 @@ async def hardware_list(
if hardware:
customer_ids = [h['current_owner_customer_id'] for h in hardware if h.get('current_owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
@ -105,7 +271,7 @@ async def hardware_list(
async def create_hardware_form(request: Request):
"""Display create hardware form."""
# Get customers for dropdown
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
return templates.TemplateResponse("modules/hardware/templates/create.html", {
"request": request,
@ -113,6 +279,85 @@ async def create_hardware_form(request: Request):
})
@router.get("/hardware/eset", response_class=HTMLResponse)
async def hardware_eset_overview(
request: Request,
current_user: dict = Depends(get_optional_user),
):
"""Display ESET sync overview (matches + incidents)."""
matches_query = """
SELECT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.eset_uuid,
h.eset_group,
h.updated_at,
hc.contact_id,
c.first_name,
c.last_name,
c.user_company,
cc.customer_id,
cust.name AS customer_name
FROM hardware_assets h
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
LEFT JOIN contacts c ON c.id = hc.contact_id
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
LEFT JOIN customers cust ON cust.id = cc.customer_id
WHERE h.deleted_at IS NULL
ORDER BY h.updated_at DESC NULLS LAST
LIMIT 500
"""
matches = execute_query(matches_query)
incidents_query = """
SELECT
i.*,
hw.id AS hardware_id,
hw.current_owner_customer_id AS customer_id,
cust.name AS customer_name
FROM eset_incidents i
LEFT JOIN LATERAL (
SELECT h.id, h.current_owner_customer_id
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND LOWER(COALESCE(h.eset_uuid, '')) = LOWER(COALESCE(i.device_uuid, ''))
ORDER BY h.updated_at DESC NULLS LAST, h.id DESC
LIMIT 1
) hw ON TRUE
LEFT JOIN customers cust ON cust.id = hw.current_owner_customer_id
WHERE LOWER(COALESCE(i.severity, '')) IN ('critical', 'high', 'severe')
ORDER BY i.updated_at DESC NULLS LAST
LIMIT 5
"""
incidents = execute_query(incidents_query)
return templates.TemplateResponse("modules/hardware/templates/eset_overview.html", {
"request": request,
"matches": matches or [],
"incidents": incidents or [],
"current_user": current_user,
})
@router.get("/hardware/eset/test", response_class=HTMLResponse)
async def hardware_eset_test(request: Request):
"""Display ESET API test page."""
return templates.TemplateResponse("modules/hardware/templates/eset_test.html", {
"request": request
})
@router.get("/hardware/eset/import", response_class=HTMLResponse)
async def hardware_eset_import(request: Request):
"""Display ESET import page."""
return templates.TemplateResponse("modules/hardware/templates/eset_import.html", {
"request": request
})
@router.get("/hardware/{hardware_id}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details."""
@ -126,7 +371,7 @@ async def hardware_detail(request: Request, hardware_id: int):
# Get customer name if applicable
if hardware.get('current_owner_customer_id'):
customer_query = "SELECT navn FROM customers WHERE id = %s"
customer_query = "SELECT name AS navn FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (hardware['current_owner_customer_id'],))
if customer_result:
hardware['customer_name'] = customer_result[0]['navn']
@ -143,7 +388,7 @@ async def hardware_detail(request: Request, hardware_id: int):
if ownership:
customer_ids = [o['owner_customer_id'] for o in ownership if o.get('owner_customer_id')]
if customer_ids:
customer_query = "SELECT id, navn FROM customers WHERE id = ANY(%s)"
customer_query = "SELECT id, name AS navn FROM customers WHERE id = ANY(%s)"
customers = execute_query(customer_query, (customer_ids,))
customer_map = {c['id']: c['navn'] for c in customers} if customers else {}
@ -185,6 +430,56 @@ async def hardware_detail(request: Request, hardware_id: int):
"""
tags = execute_query(tag_query, (hardware_id,))
# Get linked contacts
contacts_query = """
SELECT hc.*, c.first_name, c.last_name, c.email, c.phone, c.mobile
FROM hardware_contacts hc
JOIN contacts c ON c.id = hc.contact_id
WHERE hc.hardware_id = %s
ORDER BY hc.role = 'primary' DESC, c.first_name ASC
"""
contacts = execute_query(contacts_query, (hardware_id,))
# Get available contacts for linking (from current owner)
available_contacts = []
if hardware.get('current_owner_customer_id'):
avail_query = """
SELECT c.id, c.first_name, c.last_name, c.email
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
WHERE cc.customer_id = %s
AND c.is_active = TRUE
AND c.id NOT IN (
SELECT contact_id FROM hardware_contacts WHERE hardware_id = %s
)
ORDER BY cc.is_primary DESC, c.first_name ASC
"""
available_contacts = execute_query(avail_query, (hardware['current_owner_customer_id'], hardware_id))
# Get customers for ownership selector
owner_customers_query = """
SELECT id, name AS navn
FROM customers
ORDER BY name
"""
owner_customers = execute_query(owner_customers_query)
owner_contacts_query = """
SELECT
cc.customer_id,
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
cc.is_primary
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE c.is_active = TRUE
ORDER BY cc.customer_id, cc.is_primary DESC, c.first_name ASC, c.last_name ASC
"""
owner_contacts = execute_query(owner_contacts_query)
# Get all active locations for the tree (including parent_id for structure)
all_locations_query = """
SELECT id, name, location_type, parent_location_id
@ -203,7 +498,12 @@ async def hardware_detail(request: Request, hardware_id: int):
"attachments": attachments or [],
"cases": cases or [],
"tags": tags or [],
"location_tree": location_tree or []
"contacts": contacts or [],
"available_contacts": available_contacts or [],
"owner_customers": owner_customers or [],
"owner_contacts": owner_contacts or [],
"location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware)
})
@ -219,7 +519,7 @@ async def edit_hardware_form(request: Request, hardware_id: int):
hardware = result[0]
# Get customers for dropdown
customers = execute_query("SELECT id, navn FROM customers WHERE deleted_at IS NULL ORDER BY navn")
customers = execute_query("SELECT id, name AS navn FROM customers WHERE deleted_at IS NULL ORDER BY name")
return templates.TemplateResponse("modules/hardware/templates/edit.html", {
"request": request,
@ -272,3 +572,113 @@ async def update_hardware_location(
execute_query(update_asset_query, (location_id, hardware_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/owner")
async def update_hardware_owner(
request: Request,
hardware_id: int,
owner_customer_id: int = Form(...),
owner_contact_id: Optional[int] = Form(None),
notes: Optional[str] = Form(None)
):
"""Update hardware ownership."""
# Verify hardware exists
check_query = "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
if not execute_query(check_query, (hardware_id,)):
raise HTTPException(status_code=404, detail="Hardware not found")
# Verify customer exists
customer_check = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
if not execute_query(customer_check, (owner_customer_id,)):
raise HTTPException(status_code=404, detail="Customer not found")
# Verify owner contact belongs to selected customer
if owner_contact_id:
contact_check_query = """
SELECT 1
FROM contact_companies
WHERE customer_id = %s AND contact_id = %s
LIMIT 1
"""
if not execute_query(contact_check_query, (owner_customer_id, owner_contact_id)):
raise HTTPException(status_code=400, detail="Selected contact does not belong to customer")
# Close active ownership history
close_history_query = """
UPDATE hardware_ownership_history
SET end_date = %s
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
"""
execute_query(close_history_query, (date.today(), hardware_id))
# Insert new ownership history
add_history_query = """
INSERT INTO hardware_ownership_history (
hardware_id, owner_type, owner_customer_id, start_date, notes
)
VALUES (%s, %s, %s, %s, %s)
"""
execute_query(add_history_query, (hardware_id, "customer", owner_customer_id, date.today(), notes))
# Update current owner on hardware
update_asset_query = """
UPDATE hardware_assets
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(update_asset_query, ("customer", owner_customer_id, hardware_id))
# Optionally set owner contact as primary for this hardware
if owner_contact_id:
demote_query = """
UPDATE hardware_contacts
SET role = 'user'
WHERE hardware_id = %s AND role = 'primary'
"""
execute_query(demote_query, (hardware_id,))
owner_contact_query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, 'primary', 'manual')
ON CONFLICT (hardware_id, contact_id)
DO UPDATE SET role = EXCLUDED.role, source = EXCLUDED.source
"""
execute_query(owner_contact_query, (hardware_id, owner_contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/add")
async def add_hardware_contact(
request: Request,
hardware_id: int,
contact_id: int = Form(...)
):
"""Link a contact to hardware."""
# Check if exists
exists_query = "SELECT id FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
if execute_query(exists_query, (hardware_id, contact_id)):
# Already exists, just redirect
pass
else:
insert_query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, 'user', 'manual')
"""
execute_query(insert_query, (hardware_id, contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
async def remove_hardware_contact(
request: Request,
hardware_id: int,
contact_id: int
):
"""Unlink a contact from hardware."""
delete_query = "DELETE FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
execute_query(delete_query, (hardware_id, contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)

View File

@ -1,4 +1,4 @@
{% extends "shared/frontend/base.html" %}
js{% extends "shared/frontend/base.html" %}
{% block title %}Opret Hardware - BMC Hub{% endblock %}
@ -203,6 +203,35 @@
</div>
</div>
<!-- AnyDesk -->
<div class="form-section">
<h3 class="form-section-title">🧷 AnyDesk</h3>
<div class="form-grid">
<div class="form-group">
<label for="anydesk_id">AnyDesk ID</label>
<input type="text" id="anydesk_id" name="anydesk_id" placeholder="123-456-789">
</div>
<div class="form-group">
<label for="anydesk_link">AnyDesk Link</label>
<input type="text" id="anydesk_link" name="anydesk_link" placeholder="anydesk://...">
</div>
</div>
<div class="mt-2 small" id="anydesk_preview" style="display: none;">
<a href="#" id="anydesk_preview_link" target="_blank">Test forbindelse</a>
</div>
</div>
<!-- ESET -->
<div class="form-section">
<h3 class="form-section-title">🛡️ ESET</h3>
<div class="form-grid">
<div class="form-group">
<label for="eset_uuid">ESET UUID</label>
<input type="text" id="eset_uuid" name="eset_uuid" placeholder="ESET Device UUID">
</div>
</div>
</div>
<!-- Ownership -->
<div class="form-section">
<h3 class="form-section-title">👥 Ejerskab</h3>
@ -289,6 +318,33 @@
}
}
function updateAnyDeskPreview() {
const idValue = document.getElementById('anydesk_id').value.trim();
const preview = document.getElementById('anydesk_preview');
const previewLink = document.getElementById('anydesk_preview_link');
const linkInput = document.getElementById('anydesk_link');
if (!preview || !previewLink) {
return;
}
if (!idValue) {
preview.style.display = 'none';
previewLink.href = '#';
if (linkInput) {
linkInput.value = '';
}
return;
}
const deepLink = `anydesk://${idValue}`;
previewLink.href = deepLink;
if (linkInput) {
linkInput.value = deepLink;
}
preview.style.display = 'block';
}
async function submitForm(event) {
event.preventDefault();
@ -330,5 +386,9 @@
// Initialize customer select visibility
toggleCustomerSelect();
// AnyDesk live preview
document.getElementById('anydesk_id').addEventListener('input', updateAnyDeskPreview);
updateAnyDeskPreview();
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -203,6 +203,30 @@
</div>
</div>
<!-- AnyDesk -->
<div class="form-section">
<h3 class="form-section-title">🧷 AnyDesk</h3>
<div class="form-grid">
<div class="form-group">
<label for="anydesk_id">AnyDesk ID</label>
<input type="text" id="anydesk_id" name="anydesk_id" value="{{ hardware.anydesk_id or '' }}" placeholder="123-456-789">
{% if hardware.anydesk_id %}
<div class="small mt-2">
{% if hardware.anydesk_link %}
<a href="{{ hardware.anydesk_link }}" target="_blank">Test forbindelse</a>
{% else %}
<a href="anydesk://{{ hardware.anydesk_id }}" target="_blank">Test forbindelse</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="anydesk_link">AnyDesk Link</label>
<input type="text" id="anydesk_link" name="anydesk_link" value="{{ hardware.anydesk_link or '' }}" placeholder="anydesk://...">
</div>
</div>
</div>
<!-- Ownership -->
<div class="form-section">
<h3 class="form-section-title">👥 Ejerskab</h3>

View File

@ -0,0 +1,636 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Import - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.table thead th {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.device-uuid {
max-width: 240px;
word-break: break-all;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
}
.contact-results {
max-height: 220px;
overflow: auto;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
}
.contact-result {
padding: 0.6rem 0.8rem;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.contact-result:hover {
background: var(--accent-light);
}
.contact-muted {
color: var(--text-secondary);
font-size: 0.85rem;
}
.contact-result.active,
.contact-result:focus {
outline: none;
background: var(--accent-light);
}
.contact-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.devices-table {
display: block;
}
.devices-cards {
display: none;
}
.show-cards .devices-table {
display: none;
}
.show-cards .devices-cards {
display: block;
}
.device-card {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px;
padding: 1rem;
background: var(--bg-body);
margin-bottom: 1rem;
}
.device-card-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.device-card-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.inline-results {
max-height: 180px;
overflow: auto;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
margin-top: 0.35rem;
}
.inline-result {
padding: 0.5rem 0.7rem;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.inline-result:hover,
.inline-result.active {
background: var(--accent-light);
}
@media (max-width: 991px) {
.devices-table {
display: none;
}
.devices-cards {
display: block;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1400px;">
<div class="page-header">
<h1>⬇️ ESET Import</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn btn-outline-secondary">ESET Oversigt</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
<div class="section-card" id="devicesSection">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
</div>
</div>
<div class="table-responsive devices-table">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Navn</th>
<th>Serial</th>
<th>Gruppe</th>
<th>Device UUID</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="devicesTable">
<tr>
<td colspan="5" class="text-center text-muted">Klik "Hent devices" for at hente ESET-listen.</td>
</tr>
</tbody>
</table>
</div>
<div id="devicesCards" class="devices-cards"></div>
</div>
</div>
<!-- Import Modal -->
<div class="modal fade" id="esetImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import fra ESET</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Device UUID</label>
<input type="text" class="form-control" id="importDeviceUuid" readonly>
</div>
<div class="mb-3">
<label class="form-label">Find kontakt</label>
<div class="input-group mb-2">
<input type="text" class="form-control" id="contactSearch" placeholder="Navn eller email" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="searchContacts()">Sog</button>
</div>
<div id="contactResults" class="contact-results"></div>
<div id="contactHint" class="contact-muted">Tip: Skriv 2+ tegn og tryk Enter for at vaelge den forste.</div>
</div>
<div class="mb-3">
<label class="form-label">Valgt kontakt</label>
<input type="text" class="form-control" id="selectedContact" placeholder="Ingen valgt" readonly>
<div id="selectedContactChip" class="contact-chip d-none"></div>
</div>
<div id="importStatus" class="contact-muted"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="importDevice()">Importer</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const devicesTable = document.getElementById('devicesTable');
const deviceStatus = document.getElementById('deviceStatus');
const devicesCards = document.getElementById('devicesCards');
const devicesSection = document.getElementById('devicesSection');
const tabletToggle = document.getElementById('tabletToggle');
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
let selectedContactId = null;
let contactResults = [];
let contactSearchTimer = null;
const inlineSelections = {};
const inlineTimers = {};
let isTabletView = false;
function toggleTabletView() {
isTabletView = !isTabletView;
if (devicesSection) {
devicesSection.classList.toggle('show-cards', isTabletView);
}
if (tabletToggle) {
tabletToggle.textContent = isTabletView ? 'Tabel visning' : 'Tablet visning';
}
}
function parseDevices(payload) {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
return payload.devices || payload.items || payload.results || payload.data || [];
}
function getNextPageToken(payload) {
if (!payload || typeof payload !== 'object') return null;
return payload.nextPageToken || payload.next_page_token || payload.nextPage || null;
}
function getField(device, keys) {
for (const key of keys) {
if (device[key]) return device[key];
}
return '';
}
function renderDevices(devices) {
if (!devices.length) {
devicesTable.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen devices fundet.</td></tr>';
if (devicesCards) {
devicesCards.innerHTML = '<div class="text-center text-muted">Ingen devices fundet.</div>';
}
return;
}
devicesTable.innerHTML = devices.map(device => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
return `
<tr>
<td>${name || '-'}</td>
<td>${serial || '-'}</td>
<td>${group || '-'}</td>
<td class="device-uuid">${uuid || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openImportModal('${uuid || ''}')">Importer</button>
</td>
</tr>
`;
}).join('');
if (devicesCards) {
devicesCards.innerHTML = devices.map((device, index) => {
const uuid = getField(device, ['deviceUuid', 'uuid', 'id']);
const name = getField(device, ['displayName', 'deviceName', 'name']);
const serial = getField(device, ['serialNumber', 'serial', 'serial_number']);
const group = getField(device, ['parentGroup', 'groupPath', 'group', 'path']);
const safeName = name || '-';
const safeSerial = serial || '-';
const safeGroup = group || '-';
const safeUuid = uuid || '';
return `
<div class="device-card" data-index="${index}" data-uuid="${safeUuid}">
<div class="device-card-title">${safeName}</div>
<div class="device-card-meta">Serial: ${safeSerial}</div>
<div class="device-card-meta">Gruppe: ${safeGroup}</div>
<div class="device-card-meta">UUID: ${safeUuid || '-'}</div>
<div class="mt-3">
<label class="form-label small">Vaelg kontakt (ejer)</label>
<input type="text" class="form-control form-control-sm inline-contact-search" data-index="${index}" placeholder="Sog kontakt..." autocomplete="off">
<div id="inlineResults-${index}" class="inline-results"></div>
<div id="inlineSelected-${index}" class="contact-muted">Ingen valgt</div>
<div id="inlineStatus-${index}" class="contact-muted"></div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-sm btn-primary" onclick="importDeviceInline(${index})">Importer</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openImportModal('${safeUuid || ''}')">Detaljer</button>
</div>
</div>
</div>
`;
}).join('');
bindInlineSearch();
}
}
function bindInlineSearch() {
document.querySelectorAll('.inline-contact-search').forEach(input => {
input.addEventListener('input', (event) => {
const index = event.target.dataset.index;
const query = event.target.value.trim();
if (inlineTimers[index]) {
clearTimeout(inlineTimers[index]);
}
if (query.length < 2) {
clearInlineResults(index);
return;
}
inlineTimers[index] = setTimeout(() => {
searchInlineContacts(query, index);
}, 250);
});
});
}
function clearInlineResults(index) {
const results = document.getElementById(`inlineResults-${index}`);
if (results) results.innerHTML = '';
}
async function searchInlineContacts(query, index) {
const results = document.getElementById(`inlineResults-${index}`);
if (!results) return;
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=10`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const contacts = data.contacts || [];
if (!contacts.length) {
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map((c, idx) => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
return `
<div class="inline-result" onclick="selectInlineContact(${index}, ${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
<div>
<div>${name || 'Ukendt'}</div>
<div class="contact-muted">${c.email || ''}</div>
</div>
<div class="contact-muted">${company || '-'}</div>
</div>
`;
}).join('');
} catch (err) {
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
}
}
function selectInlineContact(index, id, name, company) {
inlineSelections[index] = { id, label: company ? `${name} (${company})` : name };
const selectedEl = document.getElementById(`inlineSelected-${index}`);
if (selectedEl) selectedEl.textContent = inlineSelections[index].label || 'Ingen valgt';
clearInlineResults(index);
}
async function importDeviceInline(index) {
const card = document.querySelector(`.device-card[data-index="${index}"]`);
const statusEl = document.getElementById(`inlineStatus-${index}`);
if (!card || !statusEl) return;
const uuid = card.dataset.uuid || '';
if (!uuid) {
statusEl.textContent = 'Manglende UUID';
return;
}
statusEl.textContent = 'Importer...';
try {
const payload = { device_uuid: uuid };
if (inlineSelections[index]?.id) {
payload.contact_id = inlineSelections[index].id;
}
const response = await fetch('/api/v1/hardware/eset/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
statusEl.textContent = `Importeret hardware #${data.id}`;
} catch (err) {
statusEl.textContent = `Fejl: ${err.message}`;
}
}
async function loadDevices() {
deviceStatus.textContent = 'Henter...';
const allDevices = [];
let pageToken = null;
let pageIndex = 0;
const pageSize = 200;
const maxPages = 20;
try {
while (pageIndex < maxPages) {
const params = new URLSearchParams();
params.set('page_size', String(pageSize));
if (pageToken) {
params.set('page_token', pageToken);
}
deviceStatus.textContent = `Henter side ${pageIndex + 1}...`;
const response = await fetch(`/api/v1/hardware/eset/devices?${params.toString()}`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const devices = parseDevices(data);
allDevices.push(...devices);
pageToken = getNextPageToken(data);
if (!pageToken || !devices.length) {
break;
}
pageIndex += 1;
}
if (pageIndex >= maxPages) {
deviceStatus.textContent = `${allDevices.length} devices hentet (afkortet)`;
} else {
deviceStatus.textContent = `${allDevices.length} devices hentet`;
}
renderDevices(allDevices);
} catch (err) {
deviceStatus.textContent = 'Fejl ved hentning';
devicesTable.innerHTML = `<tr><td colspan="5" class="text-center text-danger">${err.message}</td></tr>`;
}
}
function openImportModal(uuid) {
document.getElementById('importDeviceUuid').value = uuid;
document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').innerHTML = '';
document.getElementById('selectedContact').value = '';
document.getElementById('selectedContactChip').classList.add('d-none');
document.getElementById('selectedContactChip').textContent = '';
document.getElementById('importStatus').textContent = '';
selectedContactId = null;
contactResults = [];
importModal.show();
setTimeout(() => {
const input = document.getElementById('contactSearch');
if (input) input.focus();
}, 150);
}
async function searchContacts() {
const query = document.getElementById('contactSearch').value.trim();
const results = document.getElementById('contactResults');
if (!query) {
results.innerHTML = '<div class="p-2 text-muted">Indtast soegning.</div>';
return;
}
results.innerHTML = '<div class="p-2 text-muted">Soeger...</div>';
try {
const response = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=20`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
const contacts = data.contacts || [];
contactResults = contacts;
if (!contacts.length) {
results.innerHTML = '<div class="p-2 text-muted">Ingen kontakter fundet.</div>';
return;
}
results.innerHTML = contacts.map(c => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
return `
<div class="contact-result" tabindex="0" onclick="selectContact(${c.id}, '${name.replace(/'/g, "\\'")}', '${company.replace(/'/g, "\\'")}')">
<div>
<div>${name || 'Ukendt'}</div>
<div class="contact-muted">${c.email || ''}</div>
</div>
<div class="contact-muted">${company || '-'}</div>
</div>
`;
}).join('');
highlightFirstContact();
} catch (err) {
results.innerHTML = `<div class="p-2 text-danger">${err.message}</div>`;
}
}
function selectContact(id, name, company) {
selectedContactId = id;
const label = company ? `${name} (${company})` : name;
document.getElementById('selectedContact').value = label;
const chip = document.getElementById('selectedContactChip');
chip.textContent = `Ejer: ${label}`;
chip.classList.remove('d-none');
document.getElementById('contactResults').innerHTML = '';
}
function highlightFirstContact() {
const first = document.querySelector('#contactResults .contact-result');
if (!first) return;
document.querySelectorAll('#contactResults .contact-result').forEach(el => el.classList.remove('active'));
first.classList.add('active');
}
function selectHighlightedContact() {
const active = document.querySelector('#contactResults .contact-result.active');
if (active) {
active.click();
} else if (contactResults.length > 0) {
const c = contactResults[0];
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const company = (c.company_names || []).join(', ');
selectContact(c.id, name, company);
}
}
function moveHighlight(step) {
const items = Array.from(document.querySelectorAll('#contactResults .contact-result'));
if (!items.length) return;
let index = items.findIndex(el => el.classList.contains('active'));
if (index === -1) index = 0;
index = (index + step + items.length) % items.length;
items.forEach(el => el.classList.remove('active'));
items[index].classList.add('active');
items[index].scrollIntoView({ block: 'nearest' });
}
document.getElementById('contactSearch').addEventListener('input', () => {
if (contactSearchTimer) {
clearTimeout(contactSearchTimer);
}
contactSearchTimer = setTimeout(() => {
const query = document.getElementById('contactSearch').value.trim();
if (query.length >= 2) {
searchContacts();
}
}, 250);
});
document.getElementById('contactSearch').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
selectHighlightedContact();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveHighlight(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
moveHighlight(-1);
}
});
async function importDevice() {
const uuid = document.getElementById('importDeviceUuid').value.trim();
const statusEl = document.getElementById('importStatus');
statusEl.textContent = 'Importer...';
try {
const payload = { device_uuid: uuid };
if (selectedContactId) {
payload.contact_id = selectedContactId;
}
const response = await fetch('/api/v1/hardware/eset/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
statusEl.textContent = `Importeret hardware #${data.id}`;
} catch (err) {
statusEl.textContent = `Fejl: ${err.message}`;
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,187 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Oversigt - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.table thead th {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.badge-severity {
font-size: 0.75rem;
padding: 0.35rem 0.6rem;
border-radius: 999px;
color: white;
background: #dc3545;
}
.badge-high {
background: #fd7e14;
}
.badge-severe {
background: #6f42c1;
}
.empty-state {
padding: 1.5rem;
border: 1px dashed rgba(0,0,0,0.2);
border-radius: 10px;
text-align: center;
color: var(--text-secondary);
background: var(--bg-body);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1400px;">
<div class="page-header">
<h1>🛡️ ESET Oversigt</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset/import" class="btn btn-outline-secondary">Import</a>
<a href="/hardware/eset/test" class="btn btn-outline-secondary">ESET Test</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
{% if current_user %}
<div class="mb-3 text-muted small">
Logget ind som {{ current_user.full_name or current_user.username or current_user.email }}
</div>
{% endif %}
<div class="section-card">
<div class="section-title">🔗 Matchede enheder</div>
{% if matches %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Hardware</th>
<th>Serial</th>
<th>ESET UUID</th>
<th>ESET Gruppe</th>
<th>Kontakt</th>
<th>Company</th>
<th>Kunde</th>
<th>Opdateret</th>
</tr>
</thead>
<tbody>
{% for row in matches %}
<tr>
<td>
<a href="/hardware/{{ row.id }}">
{{ row.brand or '' }} {{ row.model or '' }}
</a>
</td>
<td>{{ row.serial_number or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ row.eset_uuid or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ row.eset_group or '-' }}</td>
<td>
{% if row.contact_id %}
{{ row.first_name or '' }} {{ row.last_name or '' }}
{% else %}
-
{% endif %}
</td>
<td>{{ row.user_company or '-' }}</td>
<td>{{ row.customer_name or '-' }}</td>
<td>{{ row.updated_at or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">Ingen matchede enheder fundet endnu.</div>
{% endif %}
</div>
<div class="section-card">
<div class="section-title">🚨 Kritiske incidents (seneste 5)</div>
{% if incidents %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Status</th>
<th>Device UUID</th>
<th>Detected</th>
<th>Last Seen</th>
<th>Handling</th>
</tr>
</thead>
<tbody>
{% for inc in incidents %}
<tr>
<td>
{% set sev = (inc.severity or '')|lower %}
<span class="badge-severity {% if sev == 'high' %}badge-high{% elif sev == 'severe' %}badge-severe{% endif %}">
{{ inc.severity or 'critical' }}
</span>
</td>
<td>{{ inc.status or '-' }}</td>
<td style="max-width: 220px; word-break: break-all;">{{ inc.device_uuid or '-' }}</td>
<td>{{ inc.detected_at or '-' }}</td>
<td>{{ inc.last_seen or '-' }}</td>
<td>
{% set case_title = 'ESET incident ' ~ (inc.severity or 'critical') ~ ' - ' ~ (inc.device_uuid or 'ukendt enhed') %}
{% set case_description =
'Kilde: ESET incidents | ' ~
'Severity: ' ~ (inc.severity or '-') ~ ' | ' ~
'Status: ' ~ (inc.status or '-') ~ ' | ' ~
'Device UUID: ' ~ (inc.device_uuid or '-') ~ ' | ' ~
'Incident UUID: ' ~ (inc.incident_uuid or '-') ~ ' | ' ~
'Detected: ' ~ (inc.detected_at or '-') ~ ' | ' ~
'Last Seen: ' ~ (inc.last_seen or '-')
%}
<a
href="/sag/new?title={{ case_title | urlencode }}&description={{ case_description | urlencode }}{% if inc.customer_id %}&customer_id={{ inc.customer_id }}{% endif %}"
class="btn btn-sm btn-outline-primary"
>
Opret sag
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">Ingen kritiske incidents lige nu.</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends "shared/frontend/base.html" %}
{% block title %}ESET Test - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.section-card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 2rem;
}
.result-box {
background: var(--bg-body);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 10px;
padding: 1rem;
max-height: 420px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
}
.btn-wide {
min-width: 160px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; max-width: 1200px;">
<div class="page-header">
<h1>🧪 ESET Test</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn btn-outline-secondary">ESET Oversigt</a>
<a href="/hardware" class="btn btn-outline-secondary">Tilbage til hardware</a>
</div>
</div>
<div class="section-card">
<h5 class="mb-3">Test device list (raw)</h5>
<div class="d-flex gap-2 flex-wrap mb-3">
<button class="btn btn-primary btn-wide" onclick="loadDevices()">Hent devices</button>
<button class="btn btn-outline-secondary btn-wide" onclick="clearOutput()">Ryd</button>
</div>
<div id="devicesOutput" class="result-box">Tryk "Hent devices" for at teste forbindelsen.</div>
</div>
<div class="section-card">
<h5 class="mb-3">Test single device</h5>
<div class="input-group mb-3">
<input type="text" class="form-control" id="deviceUuid" placeholder="Device UUID">
<button class="btn btn-primary" onclick="loadDevice()">Hent device</button>
</div>
<div id="deviceOutput" class="result-box">Indtast en UUID og klik "Hent device".</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function setOutput(el, content) {
el.textContent = content;
}
function clearOutput() {
setOutput(document.getElementById('devicesOutput'), 'Rykket.');
setOutput(document.getElementById('deviceOutput'), 'Rykket.');
}
async function loadDevices() {
const output = document.getElementById('devicesOutput');
setOutput(output, 'Henter devices...');
try {
const response = await fetch('/api/v1/hardware/eset/devices');
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
setOutput(output, JSON.stringify(data, null, 2));
} catch (err) {
setOutput(output, `Fejl: ${err.message}`);
}
}
async function loadDevice() {
const output = document.getElementById('deviceOutput');
const uuid = (document.getElementById('deviceUuid').value || '').trim();
if (!uuid) {
setOutput(output, 'Indtast en device UUID.');
return;
}
setOutput(output, 'Henter device...');
try {
const response = await fetch(`/api/v1/hardware/eset/test?device_uuid=${encodeURIComponent(uuid)}`);
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
const data = await response.json();
setOutput(output, JSON.stringify(data, null, 2));
} catch (err) {
setOutput(output, `Fejl: ${err.message}`);
}
}
</script>
{% endblock %}

View File

@ -242,10 +242,16 @@
{% block content %}
<div class="page-header">
<h1>🖥️ Hardware Assets</h1>
<h1>🖥️ Hardware Oversigt</h1>
<div class="d-flex gap-2">
<a href="/hardware/eset" class="btn-new-hardware" style="background-color: #0f4c75;">
<i class="bi bi-shield-check"></i>
ESET Oversigt
</a>
<a href="/hardware/new" class="btn-new-hardware">
Nyt Hardware
</a>
</div>
</div>
<div class="filter-section">
@ -293,7 +299,15 @@
</div>
{% if hardware and hardware|length > 0 %}
<div class="hardware-grid">
<div class="d-flex justify-content-end mb-3">
<div class="btn-group btn-group-sm" role="group" aria-label="Visning">
<button type="button" class="btn btn-outline-secondary active" id="viewCardsBtn" onclick="setHardwareView('cards')">Kort</button>
<button type="button" class="btn btn-outline-secondary" id="viewTableBtn" onclick="setHardwareView('table')">Tabel</button>
</div>
</div>
<div id="hardwareCardsView">
<div class="hardware-grid">
{% for item in hardware %}
<div class="hardware-card" onclick="window.location.href='/hardware/{{ item.id }}'">
<div class="hardware-header">
@ -319,6 +333,18 @@
<span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span>
<span class="hardware-detail-value">
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% endif %}
</span>
</div>
{% endif %}
{% if item.customer_name %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span>
@ -349,6 +375,53 @@
</div>
</div>
{% endfor %}
</div>
</div>
<div id="hardwareTableView" class="d-none">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Ejer</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
{% for item in hardware %}
<tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td>
<span class="status-badge status-{{ item.status }}">
{{ item.status|replace('_', ' ')|title }}
</span>
</td>
<td>
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% else %}
{% endif %}
</td>
<td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state">
@ -368,5 +441,25 @@
select.form.submit();
});
});
function setHardwareView(view) {
const cards = document.getElementById('hardwareCardsView');
const table = document.getElementById('hardwareTableView');
const cardsBtn = document.getElementById('viewCardsBtn');
const tableBtn = document.getElementById('viewTableBtn');
if (!cards || !table || !cardsBtn || !tableBtn) return;
const showTable = view === 'table';
cards.classList.toggle('d-none', showTable);
table.classList.toggle('d-none', !showTable);
cardsBtn.classList.toggle('active', !showTable);
tableBtn.classList.toggle('active', showTable);
localStorage.setItem('hardwareViewMode', showTable ? 'table' : 'cards');
}
const savedView = localStorage.getItem('hardwareViewMode');
if (savedView === 'table') {
setHardwareView('table');
}
</script>
{% endblock %}

View File

@ -31,6 +31,7 @@ import json
from pydantic import ValidationError
from app.core.database import execute_query
from app.core.contact_utils import get_contact_customer_ids
from app.modules.locations.models.schemas import (
Location, LocationCreate, LocationUpdate, LocationDetail,
AuditLogEntry, LocationSearchResponse,
@ -38,7 +39,8 @@ from app.modules.locations.models.schemas import (
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse
)
router = APIRouter()
@ -71,7 +73,7 @@ def _normalize_form_data(form_data: Any) -> dict:
@router.get("/locations", response_model=List[Location])
async def list_locations(
location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, vehicle)"),
location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=1000)
@ -80,7 +82,7 @@ async def list_locations(
List all locations with optional filters and pagination.
Query Parameters:
- location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, vehicle)
- location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
- is_active: Filter by active status (true/false)
- skip: Pagination offset (default 0)
- limit: Results per page (default 50, max 1000)
@ -255,6 +257,339 @@ async def create_location(request: Request):
)
# ============================================================================
# 11b. GET /api/v1/locations/by-customer/{customer_id} - Filter by customer
# ============================================================================
@router.get("/locations/by-customer/{customer_id}", response_model=List[Location])
async def get_locations_by_customer(customer_id: int):
"""
Get all locations linked to a customer.
Path parameter: customer_id
Returns: List of Location objects ordered by name
"""
try:
query = """
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.customer_id = %s AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, (customer_id,))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting customer locations: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by customer"
)
@router.get("/locations/by-contact/{contact_id}", response_model=List[Location])
async def get_locations_by_contact(contact_id: int):
"""
Get all locations linked to a contact via the contact's companies.
Path parameter: contact_id
Returns: List of Location objects ordered by name
"""
try:
customer_ids = get_contact_customer_ids(contact_id)
if not customer_ids:
return []
placeholders = ",".join(["%s"] * len(customer_ids))
query = f"""
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.customer_id IN ({placeholders}) AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, tuple(customer_ids))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting contact locations: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by contact"
)
# ============================================================================
# 11c. GET /api/v1/locations/by-ids - Fetch by IDs
# ============================================================================
@router.get("/locations/by-ids", response_model=List[Location])
async def get_locations_by_ids(ids: str = Query(..., description="Comma-separated location IDs")):
"""
Get locations by a comma-separated list of IDs.
"""
try:
id_values = [int(value) for value in ids.split(',') if value.strip().isdigit()]
if not id_values:
return []
placeholders = ",".join(["%s"] * len(id_values))
query = f"""
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.id IN ({placeholders}) AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, tuple(id_values))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting locations by ids: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by ids"
)
# =========================================================================
# 2b. POST /api/v1/locations/bulk-create - Create location with floors/rooms
# =========================================================================
@router.post("/locations/bulk-create", response_model=LocationWizardCreateResponse)
async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
"""
Create a root location with floors and rooms in a single request.
Request body: LocationWizardCreateRequest
Returns: IDs of created locations
"""
try:
root = data.root
auto_suffix = data.auto_suffix
payload_names = [root.name]
for floor in data.floors:
payload_names.append(floor.name)
for room in floor.rooms:
payload_names.append(room.name)
normalized_names = [name.strip().lower() for name in payload_names if name]
if not auto_suffix and len(normalized_names) != len(set(normalized_names)):
raise HTTPException(
status_code=400,
detail="Duplicate names found in wizard payload"
)
if not auto_suffix:
placeholders = ",".join(["%s"] * len(payload_names))
existing_query = f"""
SELECT name FROM locations_locations
WHERE name IN ({placeholders}) AND deleted_at IS NULL
"""
existing = execute_query(existing_query, tuple(payload_names))
if existing:
existing_names = ", ".join(sorted({row.get("name") for row in existing if row.get("name")}))
raise HTTPException(
status_code=400,
detail=f"Locations already exist with names: {existing_names}"
)
if root.customer_id is not None:
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
customer = execute_query(customer_query, (root.customer_id,))
if not customer:
raise HTTPException(status_code=400, detail="customer_id does not exist")
if root.parent_location_id is not None:
parent_query = "SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
parent = execute_query(parent_query, (root.parent_location_id,))
if not parent:
raise HTTPException(status_code=400, detail="parent_location_id does not exist")
insert_query = """
INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude,
phone, email, notes, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING *
"""
reserved_names = set()
def _normalize_name(value: str) -> str:
return (value or "").strip().lower()
def _name_exists(value: str) -> bool:
normalized = _normalize_name(value)
if normalized in reserved_names:
return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (value,))
return bool(existing)
def _reserve_name(value: str) -> None:
normalized = _normalize_name(value)
if normalized:
reserved_names.add(normalized)
def _resolve_unique_name(base_name: str) -> str:
if not auto_suffix:
_reserve_name(base_name)
return base_name
base_name = base_name.strip()
if not _name_exists(base_name):
_reserve_name(base_name)
return base_name
suffix = 2
while True:
candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate):
_reserve_name(candidate)
return candidate
suffix += 1
def insert_location_record(
name: str,
location_type: str,
parent_location_id: Optional[int],
customer_id: Optional[int],
address_street: Optional[str],
address_city: Optional[str],
address_postal_code: Optional[str],
address_country: Optional[str],
latitude: Optional[float],
longitude: Optional[float],
phone: Optional[str],
email: Optional[str],
notes: Optional[str],
is_active: bool
) -> Location:
params = (
name,
location_type,
parent_location_id,
customer_id,
address_street,
address_city,
address_postal_code,
address_country,
latitude,
longitude,
phone,
email,
notes,
is_active
)
result = execute_query(insert_query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0])
resolved_root_name = _resolve_unique_name(root.name)
root_location = insert_location_record(
name=resolved_root_name,
location_type=root.location_type,
parent_location_id=root.parent_location_id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=root.notes,
is_active=root.is_active
)
audit_query = """
INSERT INTO locations_audit_log (location_id, event_type, user_id, changes, created_at)
VALUES (%s, %s, %s, %s, NOW())
"""
root_changes = root.model_dump()
root_changes["name"] = resolved_root_name
execute_query(audit_query, (root_location.id, 'created', None, json.dumps({"after": root_changes})))
floor_ids: List[int] = []
room_ids: List[int] = []
for floor in data.floors:
resolved_floor_name = _resolve_unique_name(floor.name)
floor_location = insert_location_record(
name=resolved_floor_name,
location_type=floor.location_type,
parent_location_id=root_location.id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=None,
is_active=floor.is_active
)
floor_ids.append(floor_location.id)
execute_query(audit_query, (
floor_location.id,
'created',
None,
json.dumps({"after": {"name": resolved_floor_name, "location_type": floor.location_type, "parent_location_id": root_location.id}})
))
for room in floor.rooms:
resolved_room_name = _resolve_unique_name(room.name)
room_location = insert_location_record(
name=resolved_room_name,
location_type=room.location_type,
parent_location_id=floor_location.id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=None,
is_active=room.is_active
)
room_ids.append(room_location.id)
execute_query(audit_query, (
room_location.id,
'created',
None,
json.dumps({"after": {"name": resolved_room_name, "location_type": room.location_type, "parent_location_id": floor_location.id}})
))
created_total = 1 + len(floor_ids) + len(room_ids)
logger.info("✅ Wizard created %s locations (root=%s)", created_total, root_location.id)
return LocationWizardCreateResponse(
root_id=root_location.id,
floor_ids=floor_ids,
room_ids=room_ids,
created_total=created_total
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating location hierarchy: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create location hierarchy")
# ============================================================================
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================
@ -467,7 +802,7 @@ async def update_location(id: int, data: LocationUpdate):
detail="customer_id does not exist"
)
if key == 'location_type':
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if value not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {value}")
raise HTTPException(
@ -2587,7 +2922,7 @@ async def bulk_update_locations(data: BulkUpdateRequest):
# Validate location_type if provided
if 'location_type' in data.updates:
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if data.updates['location_type'] not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
raise HTTPException(
@ -2804,7 +3139,7 @@ async def get_locations_by_type(
"""
Get all locations of a specific type with pagination.
Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, vehicle)
Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
Query parameters: skip, limit for pagination
Returns: Paginated list of Location objects ordered by name
@ -2815,7 +3150,7 @@ async def get_locations_by_type(
"""
try:
# Validate location_type is one of allowed values
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if location_type not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {location_type}")
raise HTTPException(

View File

@ -18,12 +18,12 @@ Each view:
"""
from fastapi import APIRouter, Query, HTTPException, Path, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from pathlib import Path as PathlibPath
import requests
import logging
from typing import Optional
from app.core.database import execute_query, execute_update
router = APIRouter()
logger = logging.getLogger(__name__)
@ -43,9 +43,7 @@ env = Environment(
lstrip_blocks=True
)
# Backend API base URL
# Inside container: localhost:8000, externally: localhost:8001
API_BASE_URL = "http://localhost:8000"
# Use direct database access instead of API calls to avoid auth issues
# Location type options for dropdowns
LOCATION_TYPES = [
@ -54,6 +52,8 @@ LOCATION_TYPES = [
{"value": "etage", "label": "Etage"},
{"value": "customer_site", "label": "Kundesite"},
{"value": "rum", "label": "Rum"},
{"value": "kantine", "label": "Kantine"},
{"value": "moedelokale", "label": "Mødelokale"},
{"value": "vehicle", "label": "Køretøj"},
]
@ -83,36 +83,6 @@ def render_template(template_name: str, **context) -> str:
raise HTTPException(status_code=500, detail=f"Error rendering template: {str(e)}")
def call_api(method: str, endpoint: str, **kwargs) -> dict:
"""
Call backend API endpoint using requests (synchronous).
Args:
method: HTTP method (GET, POST, PATCH, DELETE)
endpoint: API endpoint path (e.g., "/api/v1/locations")
**kwargs: Additional arguments for requests call (params, json, etc.)
Returns:
Response JSON or dict
Raises:
HTTPException: If API call fails
"""
try:
url = f"{API_BASE_URL}{endpoint}" if not endpoint.startswith("http") else endpoint
response = requests.request(method, url, timeout=30, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
logger.warning(f"⚠️ API 404: {method} {endpoint}")
raise HTTPException(status_code=404, detail="Resource not found")
logger.error(f"❌ API error {e.response.status_code}: {method} {endpoint}")
raise HTTPException(status_code=500, detail=f"API error: {e.response.status_code}")
except requests.exceptions.RequestException as e:
logger.error(f"❌ API call failed {method} {endpoint}: {str(e)}")
raise HTTPException(status_code=500, detail=f"API connection error: {str(e)}")
def calculate_pagination(total: int, limit: int, skip: int) -> dict:
"""
@ -147,7 +117,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict:
@router.get("/app/locations", response_class=HTMLResponse)
def list_locations_view(
location_type: Optional[str] = Query(None, description="Filter by type"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_active: Optional[str] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
@ -169,18 +139,35 @@ def list_locations_view(
try:
logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})")
# Build API call parameters
params = {
"skip": skip,
"limit": limit,
}
if location_type:
params["location_type"] = location_type
if is_active is not None:
params["is_active"] = is_active
# Convert is_active from string to boolean or None
is_active_bool = None
if is_active and is_active.lower() in ('true', '1', 'yes'):
is_active_bool = True
elif is_active and is_active.lower() in ('false', '0', 'no'):
is_active_bool = False
# Call backend API to get locations
locations = call_api("GET", "/api/v1/locations", params=params)
# Query locations directly from database
where_clauses = ["deleted_at IS NULL"]
query_params = []
if location_type:
where_clauses.append("location_type = %s")
query_params.append(location_type)
if is_active_bool is not None:
where_clauses.append("is_active = %s")
query_params.append(is_active_bool)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT * FROM locations_locations
WHERE {where_sql}
ORDER BY name
LIMIT %s OFFSET %s
"""
query_params.extend([limit, skip])
locations = execute_query(query, tuple(query_params))
def build_tree(items: list) -> list:
nodes = {}
@ -234,7 +221,7 @@ def list_locations_view(
skip=skip,
limit=limit,
location_type=location_type,
is_active=is_active,
is_active=is_active_bool, # Use boolean value for template
page_number=pagination["page_number"],
total_pages=pagination["total_pages"],
has_prev=pagination["has_prev"],
@ -281,53 +268,23 @@ def create_location_view():
try:
logger.info("🆕 Rendering create location form")
parent_locations = call_api(
"GET",
"/api/v1/locations",
params={"skip": 0, "limit": 1000}
)
# Query parent locations
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
)
# Query customers
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
# Render template with context
html = render_template(
@ -352,6 +309,52 @@ def create_location_view():
raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}")
# =========================================================================
# 2b. GET /app/locations/wizard - Wizard for floors and rooms
# =========================================================================
@router.get("/app/locations/wizard", response_class=HTMLResponse)
def location_wizard_view():
"""
Render the location wizard form.
"""
try:
logger.info("🧭 Rendering location wizard")
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
html = render_template(
"modules/locations/templates/wizard.html",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
cancel_url="/app/locations",
)
logger.info("✅ Rendered location wizard")
return HTMLResponse(content=html)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rendering wizard: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error rendering wizard: {str(e)}")
# ============================================================================
# 3. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================
@ -374,19 +377,53 @@ def detail_location_view(id: int = Path(..., gt=0)):
try:
logger.info(f"📍 Rendering detail view for location {id}")
# Call backend API to get location details
location = call_api("GET", f"/api/v1/locations/{id}")
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
# Query location details directly
location = execute_query(
"SELECT * FROM locations_locations WHERE id = %s",
(id,)
)
if not location:
logger.warning(f"⚠️ Location {id} not found")
raise HTTPException(status_code=404, detail=f"Location {id} not found")
location = location[0] # Get first result
hierarchy = []
current_parent_id = location.get("parent_location_id")
while current_parent_id:
parent = execute_query(
"SELECT id, name, location_type, parent_location_id FROM locations_locations WHERE id = %s",
(current_parent_id,)
)
if not parent:
break
parent_row = parent[0]
hierarchy.insert(0, parent_row)
current_parent_id = parent_row.get("parent_location_id")
children = execute_query(
"""
SELECT id, name, location_type
FROM locations_locations
WHERE parent_location_id = %s AND deleted_at IS NULL
ORDER BY name
""",
(id,)
)
location["hierarchy"] = hierarchy
location["children"] = children
# Query customers
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
# Optionally fetch related data if available from API
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
@ -429,30 +466,36 @@ def edit_location_view(id: int = Path(..., gt=0)):
try:
logger.info(f"✏️ Rendering edit form for location {id}")
# Call backend API to get current location data
location = call_api("GET", f"/api/v1/locations/{id}")
parent_locations = call_api(
"GET",
"/api/v1/locations",
params={"skip": 0, "limit": 1000}
)
parent_locations = [
loc for loc in parent_locations
if isinstance(loc, dict) and loc.get("id") != id
]
customers = call_api(
"GET",
"/api/v1/customers",
params={"offset": 0, "limit": 1000}
# Query location details
location = execute_query(
"SELECT * FROM locations_locations WHERE id = %s",
(id,)
)
if not location:
logger.warning(f"⚠️ Location {id} not found for edit")
raise HTTPException(status_code=404, detail=f"Location {id} not found")
location = location[0] # Get first result
# Query parent locations (exclude self)
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE is_active = true AND id != %s
ORDER BY name
LIMIT 1000
""", (id,))
# Query customers
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
# Render template with context
# Note: HTML forms don't support PATCH, so we use POST with a hidden _method field
html = render_template(
@ -487,24 +530,44 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
"""Handle edit form submission and redirect to detail page."""
try:
form = await request.form()
payload = {
"name": form.get("name"),
"location_type": form.get("location_type"),
"parent_location_id": int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
"customer_id": int(form.get("customer_id")) if form.get("customer_id") else None,
"is_active": form.get("is_active") == "on",
"address_street": form.get("address_street"),
"address_city": form.get("address_city"),
"address_postal_code": form.get("address_postal_code"),
"address_country": form.get("address_country"),
"phone": form.get("phone"),
"email": form.get("email"),
"latitude": float(form.get("latitude")) if form.get("latitude") else None,
"longitude": float(form.get("longitude")) if form.get("longitude") else None,
"notes": form.get("notes"),
}
call_api("PATCH", f"/api/v1/locations/{id}", json=payload)
# Update location directly in database
execute_update("""
UPDATE locations_locations SET
name = %s,
location_type = %s,
parent_location_id = %s,
customer_id = %s,
is_active = %s,
address_street = %s,
address_city = %s,
address_postal_code = %s,
address_country = %s,
phone = %s,
email = %s,
latitude = %s,
longitude = %s,
notes = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (
form.get("name"),
form.get("location_type"),
int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
int(form.get("customer_id")) if form.get("customer_id") else None,
form.get("is_active") == "on",
form.get("address_street"),
form.get("address_city"),
form.get("address_postal_code"),
form.get("address_country"),
form.get("phone"),
form.get("email"),
float(form.get("latitude")) if form.get("latitude") else None,
float(form.get("longitude")) if form.get("longitude") else None,
form.get("notes"),
id
))
return RedirectResponse(url=f"/app/locations/{id}", status_code=303)
except HTTPException:
@ -535,16 +598,24 @@ def map_locations_view(
try:
logger.info("🗺️ Rendering map view")
# Build API call parameters
params = {
"skip": 0,
"limit": 1000, # Get all locations for map
}
if location_type:
params["location_type"] = location_type
# Query all locations with filters
where_clauses = []
query_params = []
# Call backend API to get all locations
locations = call_api("GET", "/api/v1/locations", params=params)
if location_type:
where_clauses.append("location_type = %s")
query_params.append(location_type)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT * FROM locations_locations
WHERE {where_sql}
ORDER BY name
LIMIT 1000
"""
locations = execute_query(query, tuple(query_params) if query_params else None)
# Filter to locations with coordinates
locations_with_coords = [

View File

@ -20,7 +20,7 @@ class LocationBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
location_type: str = Field(
...,
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
)
parent_location_id: Optional[int] = Field(
None,
@ -45,7 +45,7 @@ class LocationBase(BaseModel):
@classmethod
def validate_location_type(cls, v):
"""Validate location_type is one of allowed values"""
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
@ -61,7 +61,7 @@ class LocationUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
location_type: Optional[str] = Field(
None,
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
)
parent_location_id: Optional[int] = None
customer_id: Optional[int] = None
@ -81,7 +81,7 @@ class LocationUpdate(BaseModel):
def validate_location_type(cls, v):
if v is None:
return v
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
@ -291,6 +291,51 @@ class BulkDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete")
class LocationWizardRoom(BaseModel):
"""Room definition for location wizard"""
name: str = Field(..., min_length=1, max_length=255)
location_type: str = Field("rum", description="Type: rum | kantine | moedelokale")
is_active: bool = Field(True, description="Whether room is active")
@field_validator('location_type')
@classmethod
def validate_room_type(cls, v):
allowed = ['rum', 'kantine', 'moedelokale']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
class LocationWizardFloor(BaseModel):
"""Floor definition for location wizard"""
name: str = Field(..., min_length=1, max_length=255)
location_type: str = Field("etage", description="Type: etage")
rooms: List[LocationWizardRoom] = Field(default_factory=list)
is_active: bool = Field(True, description="Whether floor is active")
@field_validator('location_type')
@classmethod
def validate_floor_type(cls, v):
if v != 'etage':
raise ValueError('location_type must be etage for floors')
return v
class LocationWizardCreateRequest(BaseModel):
"""Request for creating a location with floors and rooms"""
root: LocationCreate
floors: List[LocationWizardFloor] = Field(..., min_items=1)
auto_suffix: bool = Field(True, description="Auto-suffix names if duplicates exist")
class LocationWizardCreateResponse(BaseModel):
"""Response for location wizard creation"""
root_id: int
floor_ids: List[int] = Field(default_factory=list)
room_ids: List[int] = Field(default_factory=list)
created_total: int
# ============================================================================
# 7. RESPONSE MODELS
# ============================================================================

View File

@ -50,7 +50,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}

View File

@ -39,6 +39,8 @@
'bygning': 'Bygning',
'etage': 'Etage',
'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'customer_site': 'Kundesite',
'vehicle': 'Køretøj'
}.get(location.location_type, location.location_type) %}
@ -48,6 +50,8 @@
'bygning': '#1abc9c',
'etage': '#3498db',
'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'customer_site': '#9b59b6',
'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %}
@ -512,7 +516,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}

View File

@ -51,7 +51,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location.location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}

View File

@ -41,7 +41,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}
@ -76,6 +76,9 @@
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard
</a>
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte
</button>
@ -115,6 +118,8 @@
'etage': 'Etage',
'customer_site': 'Kundesite',
'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'vehicle': 'Køretøj'
}.get(node.location_type, node.location_type) %}
@ -124,6 +129,8 @@
'etage': '#3498db',
'customer_site': '#9b59b6',
'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %}
@ -501,12 +508,18 @@ document.addEventListener('DOMContentLoaded', function() {
'Content-Type': 'application/json'
}
})
.then(response => {
.then(async response => {
if (response.ok) {
deleteModal.hide();
setTimeout(() => location.reload(), 300);
} else {
alert('Fejl ved sletning af lokation');
const err = await response.json().catch(() => ({}));
if (response.status === 404) {
alert(err.detail || 'Lokationen er allerede slettet. Siden opdateres.');
setTimeout(() => location.reload(), 300);
return;
}
alert(err.detail || 'Fejl ved sletning af lokation');
}
})
.catch(error => {

View File

@ -32,7 +32,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endfor %}
{% endif %}

View File

@ -0,0 +1,393 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Wizard: Lokationer - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/" class="text-decoration-none">Hjem</a></li>
<li class="breadcrumb-item"><a href="/app/locations" class="text-decoration-none">Lokaliteter</a></li>
<li class="breadcrumb-item active">Wizard</li>
</ol>
</nav>
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 fw-700 mb-2">Wizard: Opret lokation</h1>
<p class="text-muted small">Opret en adresse med etager og rum i en samlet arbejdsgang</p>
</div>
</div>
<div id="errorAlert" class="alert alert-danger alert-dismissible fade hide" role="alert">
<strong>Fejl!</strong> <span id="errorMessage"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Luk"></button>
</div>
<form id="wizardForm">
<div class="card border-0 mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h2 class="h5 fw-600 mb-0">Trin 1: Lokation</h2>
<span class="badge bg-primary">Adresse</span>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="rootName" class="form-label">Navn *</label>
<input type="text" class="form-control" id="rootName" name="root_name" required maxlength="255" placeholder="f.eks. Hovedkontor">
</div>
<div class="col-lg-6 mb-3">
<label for="rootType" class="form-label">Type *</label>
<select class="form-select" id="rootType" name="root_type" required>
<option value="">Vælg type</option>
{% if location_types %}
{% for type_option in location_types %}
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% if option_value not in ['rum', 'kantine', 'moedelokale'] %}
<option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endif %}
{% endfor %}
{% endif %}
</select>
<div class="form-text">Tip: Vælg "Bygning" for klassisk etage/rum-setup.</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option>
{% if parent_locations %}
{% for parent in parent_locations %}
<option value="{{ parent.id }}">
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-lg-6 mb-3">
<label for="customerId" class="form-label">Kunde (valgfri)</label>
<select class="form-select" id="customerId" name="customer_id">
<option value="">Ingen</option>
{% if customers %}
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="addressStreet" class="form-label">Vejnavn og nummer</label>
<input type="text" class="form-control" id="addressStreet" name="address_street" placeholder="f.eks. Hovedgaden 123">
</div>
<div class="col-lg-3 mb-3">
<label for="addressCity" class="form-label">By</label>
<input type="text" class="form-control" id="addressCity" name="address_city" placeholder="f.eks. København">
</div>
<div class="col-lg-3 mb-3">
<label for="addressPostal" class="form-label">Postnummer</label>
<input type="text" class="form-control" id="addressPostal" name="address_postal_code" placeholder="f.eks. 1000">
</div>
</div>
<div class="row">
<div class="col-lg-3 mb-3">
<label for="addressCountry" class="form-label">Land</label>
<input type="text" class="form-control" id="addressCountry" name="address_country" value="DK" placeholder="DK">
</div>
<div class="col-lg-3 mb-3">
<label for="phone" class="form-label">Telefon</label>
<input type="tel" class="form-control" id="phone" name="phone" placeholder="+45 12 34 56 78">
</div>
<div class="col-lg-6 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="kontakt@lokation.dk">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rootActive" name="root_active" checked>
<label class="form-check-label" for="rootActive">Lokation er aktiv</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="autoPrefix" checked>
<label class="form-check-label" for="autoPrefix">Prefiks etager/rum med lokationsnavn</label>
<div class="form-text">Hjælper mod navnekonflikter (navne skal være unikke globalt).</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="autoSuffix" checked>
<label class="form-check-label" for="autoSuffix">Tilføj automatisk suffix ved dubletter</label>
<div class="form-text">Eksempel: "Stue" bliver til "Stue (2)" hvis navnet findes.</div>
</div>
</div>
</div>
<div class="card border-0 mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h2 class="h5 fw-600 mb-0">Trin 2: Etager</h2>
<button type="button" class="btn btn-outline-primary btn-sm" id="addFloorBtn">
<i class="bi bi-plus-lg me-2"></i>Tilføj etage
</button>
</div>
<div id="floorsContainer" class="d-flex flex-column gap-3"></div>
</div>
</div>
<div class="d-flex justify-content-between gap-2">
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Annuller</a>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-check-lg me-2"></i>Opret lokation
</button>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const floorsContainer = document.getElementById('floorsContainer');
const addFloorBtn = document.getElementById('addFloorBtn');
const form = document.getElementById('wizardForm');
const submitBtn = document.getElementById('submitBtn');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
let floorIndex = 0;
function createRoomRow(roomIndex) {
const roomRow = document.createElement('div');
roomRow.className = 'row g-2 align-items-center room-row';
roomRow.innerHTML = `
<div class="col-md-6">
<input type="text" class="form-control form-control-sm room-name" placeholder="Rum ${roomIndex + 1}" required>
</div>
<div class="col-md-4">
<select class="form-select form-select-sm room-type">
<option value="rum">Rum</option>
<option value="kantine">Kantine</option>
<option value="moedelokale">Mødelokale</option>
</select>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-room">
<i class="bi bi-x-lg"></i>
</button>
</div>
`;
return roomRow;
}
function addRoom(floorCard) {
const roomsContainer = floorCard.querySelector('.rooms-container');
const roomIndex = roomsContainer.querySelectorAll('.room-row').length;
const roomRow = createRoomRow(roomIndex);
roomsContainer.appendChild(roomRow);
roomRow.querySelector('.remove-room').addEventListener('click', function() {
roomRow.remove();
});
}
function addFloor() {
const floorCard = document.createElement('div');
floorCard.className = 'border rounded-3 p-3 bg-light';
floorCard.dataset.floorIndex = String(floorIndex);
floorCard.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-600">Etage</div>
<button type="button" class="btn btn-outline-danger btn-sm remove-floor">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="row g-2 align-items-center mb-3">
<div class="col-md-8">
<input type="text" class="form-control floor-name" placeholder="Etage ${floorIndex + 1}" required>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input floor-active" type="checkbox" checked>
<label class="form-check-label">Aktiv</label>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="text-muted small">Rum</div>
<button type="button" class="btn btn-outline-primary btn-sm add-room">
<i class="bi bi-plus-lg me-2"></i>Tilføj rum
</button>
</div>
<div class="rooms-container d-flex flex-column gap-2"></div>
`;
floorCard.querySelector('.remove-floor').addEventListener('click', function() {
floorCard.remove();
});
floorCard.querySelector('.add-room').addEventListener('click', function() {
addRoom(floorCard);
});
floorsContainer.appendChild(floorCard);
addRoom(floorCard);
floorIndex += 1;
}
addFloorBtn.addEventListener('click', addFloor);
addFloor();
form.addEventListener('submit', async function(event) {
event.preventDefault();
errorAlert.classList.add('hide');
const rootName = document.getElementById('rootName').value.trim();
const rootType = document.getElementById('rootType').value;
const autoPrefix = document.getElementById('autoPrefix').checked;
const autoSuffix = document.getElementById('autoSuffix').checked;
if (!rootName || !rootType) {
errorMessage.textContent = 'Udfyld navn og type for lokationen.';
errorAlert.classList.remove('hide');
return;
}
const floorCards = Array.from(document.querySelectorAll('[data-floor-index]'));
if (floorCards.length === 0) {
errorMessage.textContent = 'Tilføj mindst én etage.';
errorAlert.classList.remove('hide');
return;
}
const floorsPayload = [];
const nameRegistry = new Set();
function registerName(name) {
const normalized = name.trim().toLowerCase();
if (!normalized) {
return false;
}
if (nameRegistry.has(normalized)) {
return false;
}
nameRegistry.add(normalized);
return true;
}
if (!autoSuffix && !registerName(rootName)) {
errorMessage.textContent = 'Der er dublerede navne i lokationen.';
errorAlert.classList.remove('hide');
return;
}
for (const floorCard of floorCards) {
const floorNameInput = floorCard.querySelector('.floor-name').value.trim();
if (!floorNameInput) {
errorMessage.textContent = 'Alle etager skal have et navn.';
errorAlert.classList.remove('hide');
return;
}
const floorName = autoPrefix ? `${rootName} - ${floorNameInput}` : floorNameInput;
if (!autoSuffix && !registerName(floorName)) {
errorMessage.textContent = 'Der er dublerede etagenavne. Skift navne eller brug prefiks.';
errorAlert.classList.remove('hide');
return;
}
const roomsContainer = floorCard.querySelector('.rooms-container');
const roomRows = Array.from(roomsContainer.querySelectorAll('.room-row'));
if (roomRows.length === 0) {
errorMessage.textContent = 'Tilføj mindst ét rum til hver etage.';
errorAlert.classList.remove('hide');
return;
}
const roomsPayload = [];
for (const roomRow of roomRows) {
const roomNameInput = roomRow.querySelector('.room-name').value.trim();
if (!roomNameInput) {
errorMessage.textContent = 'Alle rum skal have et navn.';
errorAlert.classList.remove('hide');
return;
}
const roomName = autoPrefix ? `${rootName} - ${floorNameInput} - ${roomNameInput}` : roomNameInput;
if (!autoSuffix && !registerName(roomName)) {
errorMessage.textContent = 'Der er dublerede rumnavne. Skift navne eller brug prefiks.';
errorAlert.classList.remove('hide');
return;
}
roomsPayload.push({
name: roomName,
location_type: roomRow.querySelector('.room-type').value,
is_active: true
});
}
floorsPayload.push({
name: floorName,
location_type: 'etage',
is_active: floorCard.querySelector('.floor-active').checked,
rooms: roomsPayload
});
}
const payload = {
root: {
name: rootName,
location_type: rootType,
parent_location_id: document.getElementById('parentLocation').value || null,
customer_id: document.getElementById('customerId').value || null,
address_street: document.getElementById('addressStreet').value || null,
address_city: document.getElementById('addressCity').value || null,
address_postal_code: document.getElementById('addressPostal').value || null,
address_country: document.getElementById('addressCountry').value || 'DK',
phone: document.getElementById('phone').value || null,
email: document.getElementById('email').value || null,
notes: null,
is_active: document.getElementById('rootActive').checked
},
floors: floorsPayload,
auto_suffix: autoSuffix
};
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Opretter...';
try {
const response = await fetch('/api/v1/locations/bulk-create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
window.location.href = `/app/locations/${result.root_id}`;
return;
}
const errorData = await response.json();
errorMessage.textContent = errorData.detail || 'Fejl ved oprettelse af lokationer.';
errorAlert.classList.remove('hide');
} catch (error) {
console.error('Error:', error);
errorMessage.textContent = 'En fejl opstod. Prøv igen senere.';
errorAlert.classList.remove('hide');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1 @@
"""Nextcloud module backend."""

View File

@ -0,0 +1,389 @@
"""
Nextcloud Module - API Router
"""
import json
import logging
import secrets
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from app.core.crypto import encrypt_secret
from app.core.database import execute_query
from app.core.contact_utils import get_primary_customer_id
from app.modules.nextcloud.backend.service import NextcloudService
from app.modules.nextcloud.models.schemas import (
NextcloudInstanceCreate,
NextcloudInstanceUpdate,
NextcloudUserCreate,
NextcloudPasswordReset,
)
logger = logging.getLogger(__name__)
router = APIRouter()
service = NextcloudService()
def _resolve_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]:
if customer_id is not None:
return customer_id
if contact_id is not None:
return get_primary_customer_id(contact_id)
return None
def _require_customer_id(customer_id: Optional[int], contact_id: Optional[int]) -> Optional[int]:
resolved = _resolve_customer_id(customer_id, contact_id)
if contact_id is not None and resolved is None:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return resolved
def _audit(customer_id: int, instance_id: int, event_type: str, request_meta: dict, response_meta: dict):
query = """
INSERT INTO nextcloud_audit_log
(customer_id, instance_id, event_type, request_meta, response_meta)
VALUES (%s, %s, %s, %s, %s)
"""
execute_query(
query,
(
customer_id,
instance_id,
event_type,
json.dumps(request_meta),
json.dumps(response_meta),
),
)
@router.get("/instances")
async def list_instances(
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
query = "SELECT * FROM nextcloud_instances WHERE deleted_at IS NULL"
params: List[int] = []
if resolved_customer_id is not None:
query += " AND customer_id = %s"
params.append(resolved_customer_id)
return execute_query(query, tuple(params)) or []
@router.get("/customers/{customer_id}/instance")
async def get_instance_for_customer(customer_id: int):
query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
result = execute_query(query, (customer_id,))
if not result:
return None
return result[0]
@router.get("/contacts/{contact_id}/instance")
async def get_instance_for_contact(contact_id: int):
customer_id = _resolve_customer_id(None, contact_id)
if not customer_id:
return None
query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
result = execute_query(query, (customer_id,))
if not result:
return None
return result[0]
@router.post("/instances")
async def create_instance(payload: NextcloudInstanceCreate):
try:
password_encrypted = encrypt_secret(payload.password)
query = """
INSERT INTO nextcloud_instances
(customer_id, base_url, auth_type, username, password_encrypted)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query(
query,
(
payload.customer_id,
payload.base_url,
payload.auth_type,
payload.username,
password_encrypted,
),
)
return result[0] if result else None
except Exception as exc:
logger.error("❌ Failed to create Nextcloud instance: %s", exc)
raise HTTPException(status_code=500, detail="Failed to create instance")
@router.patch("/instances/{instance_id}")
async def update_instance(instance_id: int, payload: NextcloudInstanceUpdate):
updates = []
params = []
if payload.base_url is not None:
updates.append("base_url = %s")
params.append(payload.base_url)
if payload.auth_type is not None:
updates.append("auth_type = %s")
params.append(payload.auth_type)
if payload.username is not None:
updates.append("username = %s")
params.append(payload.username)
if payload.password is not None:
updates.append("password_encrypted = %s")
params.append(encrypt_secret(payload.password))
if payload.is_enabled is not None:
updates.append("is_enabled = %s")
params.append(payload.is_enabled)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
params.append(instance_id)
query = f"UPDATE nextcloud_instances SET {', '.join(updates)} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/disable")
async def disable_instance(instance_id: int):
query = """
UPDATE nextcloud_instances
SET is_enabled = false, disabled_at = NOW(), updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (instance_id,))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/enable")
async def enable_instance(instance_id: int):
query = """
UPDATE nextcloud_instances
SET is_enabled = true, disabled_at = NULL, updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (instance_id,))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.post("/instances/{instance_id}/rotate-credentials")
async def rotate_credentials(instance_id: int, payload: NextcloudInstanceUpdate):
if not payload.password:
raise HTTPException(status_code=400, detail="Password is required")
query = """
UPDATE nextcloud_instances
SET password_encrypted = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
"""
result = execute_query(query, (encrypt_secret(payload.password), instance_id))
if not result:
raise HTTPException(status_code=404, detail="Instance not found")
return result[0]
@router.get("/instances/{instance_id}/status")
async def get_status(
instance_id: int,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.get_status(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "status", {"instance_id": instance_id}, response)
return response
@router.get("/instances/{instance_id}/groups")
async def list_groups(
instance_id: int,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.list_groups(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "groups", {"instance_id": instance_id}, response)
return response
@router.get("/instances/{instance_id}/users")
async def list_users(
instance_id: int,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
search: Optional[str] = Query(None),
include_details: bool = Query(False),
limit: int = Query(200, ge=1, le=500),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
if include_details:
response = await service.list_users_details(instance_id, resolved_customer_id, search, limit)
else:
response = await service.list_users(instance_id, resolved_customer_id, search)
if resolved_customer_id is not None:
_audit(
resolved_customer_id,
instance_id,
"users",
{
"instance_id": instance_id,
"search": search,
"include_details": include_details,
"limit": limit,
},
response,
)
return response
@router.get("/instances/{instance_id}/users/{uid}")
async def get_user_details(
instance_id: int,
uid: str,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.get_user_details(instance_id, uid, resolved_customer_id)
if resolved_customer_id is not None:
_audit(
resolved_customer_id,
instance_id,
"user_details",
{"instance_id": instance_id, "uid": uid},
response,
)
return response
@router.get("/instances/{instance_id}/shares")
async def list_shares(
instance_id: int,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.list_public_shares(instance_id, resolved_customer_id)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "shares", {"instance_id": instance_id}, response)
return response
@router.post("/instances/{instance_id}/users")
async def create_user(
instance_id: int,
payload: NextcloudUserCreate,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
password = secrets.token_urlsafe(12)
request_payload = {
"userid": payload.uid,
"password": password,
"email": payload.email,
"displayName": payload.display_name,
"groups[]": payload.groups,
}
response = await service.create_user(instance_id, resolved_customer_id, request_payload)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "create_user", {"uid": payload.uid}, response)
return {"result": response, "generated_password": password if payload.send_welcome else None}
@router.post("/instances/{instance_id}/users/{uid}/reset-password")
async def reset_password(
instance_id: int,
uid: str,
payload: NextcloudPasswordReset,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
password = secrets.token_urlsafe(12)
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.reset_password(instance_id, resolved_customer_id, uid, password)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "reset_password", {"uid": uid}, response)
return {"result": response, "generated_password": password if payload.send_email else None}
@router.post("/instances/{instance_id}/users/{uid}/disable")
async def disable_user(
instance_id: int,
uid: str,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = await service.disable_user(instance_id, resolved_customer_id, uid)
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "disable_user", {"uid": uid}, response)
return response
@router.post("/instances/{instance_id}/users/{uid}/resend-guide")
async def resend_guide(
instance_id: int,
uid: str,
customer_id: Optional[int] = Query(None),
contact_id: Optional[int] = Query(None),
):
resolved_customer_id = _require_customer_id(customer_id, contact_id)
response = {"status": "queued", "uid": uid}
if resolved_customer_id is not None:
_audit(resolved_customer_id, instance_id, "resend_guide", {"uid": uid}, response)
return response
@router.get("/audit")
async def list_audit(
customer_id: int = Query(...),
instance_id: Optional[int] = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
query = """
SELECT * FROM nextcloud_audit_log
WHERE customer_id = %s
"""
params: List[object] = [customer_id]
if instance_id is not None:
query += " AND instance_id = %s"
params.append(instance_id)
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
return execute_query(query, tuple(params)) or []
@router.post("/audit/purge")
async def purge_audit(data: dict):
customer_id = data.get("customer_id")
before_date = data.get("before_date")
if not customer_id or not before_date:
raise HTTPException(status_code=400, detail="customer_id and before_date are required")
query = """
DELETE FROM nextcloud_audit_log
WHERE customer_id = %s AND created_at < %s
"""
deleted = execute_query(query, (customer_id, before_date))
return {"deleted": deleted}

View File

@ -0,0 +1,301 @@
"""
Nextcloud Integration Service
Direct OCS API calls with DB cache and audit logging.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Optional
import aiohttp
from app.core.config import settings
from app.core.crypto import decrypt_secret
from app.core.database import execute_query
logger = logging.getLogger(__name__)
class NextcloudService:
def __init__(self) -> None:
self.read_only = settings.NEXTCLOUD_READ_ONLY
self.dry_run = settings.NEXTCLOUD_DRY_RUN
self.timeout = settings.NEXTCLOUD_TIMEOUT_SECONDS
self.cache_ttl = settings.NEXTCLOUD_CACHE_TTL_SECONDS
if self.read_only:
logger.warning("🔒 Nextcloud READ_ONLY MODE ENABLED")
elif self.dry_run:
logger.warning("🏃 Nextcloud DRY_RUN MODE ENABLED")
else:
logger.warning("⚠️ Nextcloud WRITE MODE ACTIVE")
def _get_instance(self, instance_id: int, customer_id: Optional[int] = None) -> Optional[dict]:
query = "SELECT * FROM nextcloud_instances WHERE id = %s AND deleted_at IS NULL"
params = [instance_id]
if customer_id is not None:
query += " AND customer_id = %s"
params.append(customer_id)
result = execute_query(query, tuple(params))
return result[0] if result else None
def _get_auth(self, instance: dict) -> Optional[aiohttp.BasicAuth]:
password = decrypt_secret(instance["password_encrypted"])
if not password:
return None
return aiohttp.BasicAuth(instance["username"], password)
def _cache_get(self, cache_key: str) -> Optional[dict]:
query = "SELECT payload FROM nextcloud_cache WHERE cache_key = %s AND expires_at > NOW()"
result = execute_query(query, (cache_key,))
if result:
return result[0]["payload"]
return None
def _cache_set(self, cache_key: str, payload: dict) -> None:
expires_at = datetime.utcnow() + timedelta(seconds=self.cache_ttl)
query = """
INSERT INTO nextcloud_cache (cache_key, payload, expires_at)
VALUES (%s, %s, %s)
ON CONFLICT (cache_key) DO UPDATE
SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at
"""
execute_query(query, (cache_key, json.dumps(payload), expires_at))
def _audit(
self,
customer_id: int,
instance_id: int,
event_type: str,
request_meta: dict,
response_meta: dict,
actor_user_id: Optional[int] = None,
) -> None:
query = """
INSERT INTO nextcloud_audit_log
(customer_id, instance_id, event_type, request_meta, response_meta, actor_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
"""
execute_query(
query,
(
customer_id,
instance_id,
event_type,
json.dumps(request_meta),
json.dumps(response_meta),
actor_user_id,
),
)
def _check_write_permission(self, operation: str) -> bool:
if self.read_only:
logger.error("🚫 BLOCKED: %s - READ_ONLY mode is enabled", operation)
return False
if self.dry_run:
logger.warning("🏃 DRY_RUN: %s - Operation will not be executed", operation)
return False
logger.warning("⚠️ EXECUTING WRITE OPERATION: %s", operation)
return True
async def _ocs_request(
self,
instance: dict,
endpoint: str,
method: str = "GET",
params: Optional[dict] = None,
data: Optional[dict] = None,
use_cache: bool = True,
) -> dict:
cache_key = None
if use_cache and method.upper() == "GET":
cache_key = f"nextcloud:{instance['id']}:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
cached = self._cache_get(cache_key)
if cached:
cached["cache_hit"] = True
return cached
auth = self._get_auth(instance)
if not auth:
return {"error": "credentials_invalid"}
base_url = instance["base_url"].rstrip("/")
url = f"{base_url}/{endpoint.lstrip('/')}"
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
async with session.request(
method=method.upper(),
url=url,
headers=headers,
auth=auth,
params=params,
data=data,
) as resp:
try:
payload = await resp.json()
except Exception:
payload = {"raw": await resp.text()}
response = {
"status": resp.status,
"payload": payload,
"cache_hit": False,
}
if cache_key and resp.status == 200:
self._cache_set(cache_key, response)
return response
async def get_status(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"status": "offline", "checked_at": datetime.utcnow().isoformat()}
response = await self._ocs_request(
instance,
"/ocs/v2.php/apps/serverinfo/api/v1/info",
method="GET",
use_cache=True,
)
return {
"status": "online" if response.get("status") == 200 else "unknown",
"checked_at": datetime.utcnow().isoformat(),
"raw": response,
}
async def list_groups(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"groups": []}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/groups",
method="GET",
use_cache=True,
)
async def list_users(
self,
instance_id: int,
customer_id: Optional[int] = None,
search: Optional[str] = None,
) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"users": []}
params = {"search": search} if search else None
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/users",
method="GET",
params=params,
use_cache=False,
)
async def get_user_details(
self,
instance_id: int,
uid: str,
customer_id: Optional[int] = None,
) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"user": None}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}",
method="GET",
use_cache=False,
)
async def list_users_details(
self,
instance_id: int,
customer_id: Optional[int] = None,
search: Optional[str] = None,
limit: int = 200,
) -> dict:
response = await self.list_users(instance_id, customer_id, search)
users = response.get("payload", {}).get("ocs", {}).get("data", {}).get("users", [])
if not isinstance(users, list):
users = []
users = users[: max(1, min(limit, 500))]
detailed = []
for uid in users:
detail_resp = await self.get_user_details(instance_id, uid, customer_id)
data = detail_resp.get("payload", {}).get("ocs", {}).get("data", {}) if isinstance(detail_resp, dict) else {}
detailed.append({
"uid": uid,
"display_name": data.get("displayname") if isinstance(data, dict) else None,
"email": data.get("email") if isinstance(data, dict) else None,
})
return {"users": detailed}
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"payload": {"ocs": {"data": []}}}
return await self._ocs_request(
instance,
"/ocs/v1.php/apps/files_sharing/api/v1/shares",
method="GET",
params={"share_type": 3},
use_cache=True,
)
async def create_user(self, instance_id: int, customer_id: Optional[int], payload: dict) -> dict:
if not self._check_write_permission("create_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/users",
method="POST",
data=payload,
use_cache=False,
)
async def reset_password(self, instance_id: int, customer_id: Optional[int], uid: str, password: str) -> dict:
if not self._check_write_permission("reset_nextcloud_password"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}",
method="PUT",
data={"password": password},
use_cache=False,
)
async def disable_user(self, instance_id: int, customer_id: Optional[int], uid: str) -> dict:
if not self._check_write_permission("disable_nextcloud_user"):
return {"blocked": True, "read_only": self.read_only, "dry_run": self.dry_run}
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"error": "instance_unavailable"}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}/disable",
method="PUT",
use_cache=False,
)

View File

@ -0,0 +1 @@
"""Nextcloud module frontend."""

View File

@ -0,0 +1 @@
"""Nextcloud module models."""

View File

@ -0,0 +1,63 @@
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class NextcloudInstanceBase(BaseModel):
customer_id: int
base_url: str
auth_type: str = "basic"
username: str
class NextcloudInstanceCreate(NextcloudInstanceBase):
password: str = Field(..., min_length=1)
class NextcloudInstanceUpdate(BaseModel):
base_url: Optional[str] = None
auth_type: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
is_enabled: Optional[bool] = None
class NextcloudInstance(NextcloudInstanceBase):
id: int
is_enabled: bool
disabled_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class NextcloudStatus(BaseModel):
status: str
checked_at: datetime
version: Optional[str] = None
php: Optional[str] = None
db: Optional[str] = None
metrics: Dict[str, Optional[str]] = {}
class NextcloudUserCreate(BaseModel):
uid: str
display_name: Optional[str] = None
email: Optional[str] = None
groups: List[str] = []
send_welcome: bool = True
class NextcloudPasswordReset(BaseModel):
send_email: bool = True
class NextcloudAuditLogEntry(BaseModel):
id: int
customer_id: int
instance_id: Optional[int] = None
event_type: str
request_meta: Optional[Dict] = None
response_meta: Optional[Dict] = None
actor_user_id: Optional[int] = None
created_at: datetime

View File

@ -0,0 +1,19 @@
{
"name": "nextcloud",
"version": "1.0.0",
"description": "Nextcloud integration: status, users, and audit log",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "nextcloud_",
"api_prefix": "/api/v1/nextcloud",
"tags": [
"Nextcloud"
],
"config": {
"safety_switches": {
"read_only": true,
"dry_run": true
}
}
}

View File

@ -0,0 +1,38 @@
<div class="row g-4" id="nextcloudTabContent">
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Systemstatus</h5>
<div id="ncStatusBadge" class="badge bg-secondary">Ukendt</div>
<div class="mt-2 small text-muted" id="ncLastUpdated">-</div>
<div class="mt-3">
<div class="info-row"><span class="info-label">CPU load</span><span class="info-value" id="ncCpuLoad">-</span></div>
<div class="info-row"><span class="info-label">Free disk</span><span class="info-value" id="ncFreeDisk">-</span></div>
<div class="info-row"><span class="info-label">RAM usage</span><span class="info-value" id="ncRamUsage">-</span></div>
<div class="info-row"><span class="info-label">OPCache hit rate</span><span class="info-value" id="ncOpcache">-</span></div>
</div>
<div class="mt-3 d-flex flex-wrap gap-2" id="ncAlerts"></div>
</div>
</div>
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Handlinger</h5>
<button class="btn btn-primary w-100 mb-2" onclick="openNextcloudCreateUser()">Tilføj ny bruger</button>
<button class="btn btn-outline-secondary w-100 mb-2" onclick="openNextcloudResetPassword()">Reset password</button>
<button class="btn btn-outline-danger w-100" onclick="openNextcloudDisableUser()">Luk bruger</button>
</div>
</div>
<div class="col-lg-6">
<div class="info-card">
<h5 class="fw-bold mb-3">Nøgletal</h5>
<div class="info-row"><span class="info-label">File count growth</span><span class="info-value" id="ncFileGrowth">-</span></div>
<div class="info-row"><span class="info-label">Public shares uden password</span><span class="info-value" id="ncPublicShares">-</span></div>
<div class="info-row"><span class="info-label">Active users</span><span class="info-value" id="ncActiveUsers">-</span></div>
</div>
</div>
<div class="col-12">
<div class="info-card">
<h5 class="fw-bold mb-3">Historik</h5>
<div id="ncHistory">Ingen events endnu.</div>
</div>
</div>
</div>

View File

@ -0,0 +1,197 @@
import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
class OrdreEconomicExportService:
"""e-conomic export service for global ordre page."""
def __init__(self):
self.api_url = settings.ECONOMIC_API_URL
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY
self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
if self.read_only:
logger.warning("🔒 ORDRE e-conomic READ-ONLY mode: Enabled")
if self.dry_run:
logger.warning("🏃 ORDRE e-conomic DRY-RUN mode: Enabled")
if not self.read_only:
logger.error("⚠️ WARNING: ORDRE e-conomic READ-ONLY disabled!")
def _headers(self) -> Dict[str, str]:
return {
"X-AppSecretToken": self.app_secret_token,
"X-AgreementGrantToken": self.agreement_grant_token,
"Content-Type": "application/json",
}
def _check_write_permission(self, operation: str) -> bool:
if self.read_only:
logger.error("🚫 BLOCKED: %s - READ_ONLY mode enabled", operation)
return False
if self.dry_run:
logger.warning("🏃 DRY-RUN: %s - Would execute but not sending", operation)
return False
logger.warning("⚠️ EXECUTING WRITE: %s", operation)
return True
async def export_order(
self,
customer_id: int,
lines: List[Dict[str, Any]],
notes: Optional[str] = None,
layout_number: Optional[int] = None,
user_id: Optional[int] = None,
) -> Dict[str, Any]:
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
if not customer.get("economic_customer_number"):
raise HTTPException(
status_code=400,
detail="Kunden mangler e-conomic kundenummer i Customers modulet",
)
selected_lines = [line for line in lines if bool(line.get("selected", True))]
if not selected_lines:
raise HTTPException(status_code=400, detail="Ingen linjer valgt til eksport")
product_ids = [int(line["product_id"]) for line in selected_lines if line.get("product_id")]
product_map: Dict[int, str] = {}
if product_ids:
product_rows = execute_query(
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
(product_ids,),
) or []
product_map = {
int(row["id"]): str(row["sku_internal"])
for row in product_rows
if row.get("sku_internal")
}
economic_lines: List[Dict[str, Any]] = []
for line in selected_lines:
try:
quantity = float(line.get("quantity") or 0)
unit_price = float(line.get("unit_price") or 0)
discount = float(line.get("discount_percentage") or 0)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Ugyldige tal i linjer")
if quantity <= 0:
raise HTTPException(status_code=400, detail="Linje quantity skal være > 0")
if unit_price < 0:
raise HTTPException(status_code=400, detail="Linje unit_price skal være >= 0")
line_payload: Dict[str, Any] = {
"description": line.get("description") or "Ordrelinje",
"quantity": quantity,
"unitNetPrice": unit_price,
}
product_id = line.get("product_id")
product_number = None
if product_id is not None:
try:
product_number = product_map.get(int(product_id))
except (TypeError, ValueError):
product_number = None
if not product_number:
product_number = self.default_product
if product_number:
line_payload["product"] = {"productNumber": str(product_number)}
if discount > 0:
line_payload["discountPercentage"] = discount
economic_lines.append(line_payload)
payload: Dict[str, Any] = {
"date": date.today().isoformat(),
"currency": "DKK",
"customer": {
"customerNumber": int(customer["economic_customer_number"]),
},
"layout": {
"layoutNumber": int(layout_number or self.default_layout),
},
"lines": economic_lines,
}
if notes:
payload["notes"] = {"textLine1": str(notes)[:250]}
operation = f"Export ordre for customer {customer_id} to e-conomic"
if not self._check_write_permission(operation):
return {
"success": True,
"dry_run": True,
"message": "DRY-RUN: Export blocked by safety flags",
"details": {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"read_only": self.read_only,
"dry_run": self.dry_run,
"user_id": user_id,
"payload": payload,
},
}
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.api_url}/orders/drafts",
headers=self._headers(),
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as response:
response_text = await response.text()
if response.status not in [200, 201]:
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
raise HTTPException(
status_code=502,
detail=f"e-conomic export fejlede ({response.status})",
)
export_result = await response.json(content_type=None)
draft_number = export_result.get("draftOrderNumber") or export_result.get("orderNumber")
logger.info("✅ Ordre exported to e-conomic draft %s", draft_number)
return {
"success": True,
"dry_run": False,
"message": f"Ordre eksporteret til e-conomic draft {draft_number}",
"economic_draft_id": draft_number,
"details": {
"customer_id": customer_id,
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"user_id": user_id,
"economic_response": export_result,
},
}
ordre_economic_export_service = OrdreEconomicExportService()

View File

@ -0,0 +1,280 @@
import logging
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.modules.orders.backend.economic_export import ordre_economic_export_service
from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__)
router = APIRouter()
class OrdreLineInput(BaseModel):
line_key: str
source_type: str
source_id: int
description: str
quantity: float = Field(gt=0)
unit_price: float = Field(ge=0)
discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None
product_id: Optional[int] = None
selected: bool = True
class OrdreExportRequest(BaseModel):
customer_id: int
lines: List[OrdreLineInput]
notes: Optional[str] = None
layout_number: Optional[int] = None
draft_id: Optional[int] = None
class OrdreDraftUpsertRequest(BaseModel):
title: str = Field(min_length=1, max_length=120)
customer_id: Optional[int] = None
lines: List[Dict[str, Any]] = Field(default_factory=list)
notes: Optional[str] = None
layout_number: Optional[int] = None
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def _get_user_id_from_request(http_request: Request) -> Optional[int]:
state_user_id = getattr(http_request.state, "user_id", None)
if state_user_id is None:
return None
try:
return int(state_user_id)
except (TypeError, ValueError):
return None
@router.get("/ordre/aggregate")
async def get_ordre_aggregate(
customer_id: Optional[int] = Query(None),
sag_id: Optional[int] = Query(None),
q: Optional[str] = Query(None),
):
"""Aggregate global ordre lines from subscriptions, hardware and sales."""
try:
return aggregate_order_lines(customer_id=customer_id, sag_id=sag_id, q=q)
except Exception as e:
logger.error("❌ Error aggregating ordre lines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to aggregate ordre lines")
@router.get("/ordre/config")
async def get_ordre_config():
"""Return ordre module safety config for frontend banner."""
return {
"economic_read_only": ordre_economic_export_service.read_only,
"economic_dry_run": ordre_economic_export_service.dry_run,
"default_layout": ordre_economic_export_service.default_layout,
"default_product": ordre_economic_export_service.default_product,
}
@router.post("/ordre/export")
async def export_ordre(request: OrdreExportRequest, http_request: Request):
"""Export selected ordre lines to e-conomic draft order."""
try:
user_id = _get_user_id_from_request(http_request)
line_payload = [line.model_dump() for line in request.lines]
export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id,
lines=line_payload,
notes=request.notes,
layout_number=request.layout_number,
user_id=user_id,
)
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
export_result["exported_line_keys"] = exported_line_keys
if request.draft_id:
from app.core.database import execute_query_single, execute_query
existing = execute_query_single("SELECT export_status_json FROM ordre_drafts WHERE id = %s", (request.draft_id,))
existing_status = _safe_json_field((existing or {}).get("export_status_json")) or {}
if not isinstance(existing_status, dict):
existing_status = {}
line_status = "dry-run" if export_result.get("dry_run") else "exported"
for line_key in exported_line_keys:
existing_status[line_key] = {
"status": line_status,
"timestamp": datetime.utcnow().isoformat(),
}
execute_query(
"""
UPDATE ordre_drafts
SET export_status_json = %s::jsonb,
last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(json.dumps(existing_status, ensure_ascii=False), request.draft_id),
)
return export_result
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to export ordre")
@router.get("/ordre/drafts")
async def list_ordre_drafts(
http_request: Request,
limit: int = Query(25, ge=1, le=100)
):
"""List all ordre drafts (no user filtering)."""
try:
query = """
SELECT id, title, customer_id, notes, layout_number, created_by_user_id,
created_at, updated_at, last_exported_at
FROM ordre_drafts
ORDER BY updated_at DESC, id DESC
LIMIT %s
"""
params = (limit,)
from app.core.database import execute_query
return execute_query(query, params) or []
except Exception as e:
logger.error("❌ Error listing ordre drafts: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to list ordre drafts")
@router.get("/ordre/drafts/{draft_id}")
async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering)."""
try:
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
params = (draft_id,)
from app.core.database import execute_query_single
draft = execute_query_single(query, params)
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
draft["lines_json"] = _safe_json_field(draft.get("lines_json")) or []
draft["export_status_json"] = _safe_json_field(draft.get("export_status_json")) or {}
return draft
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error fetching ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
try:
user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query
query = """
INSERT INTO ordre_drafts (
title,
customer_id,
lines_json,
notes,
layout_number,
created_by_user_id,
export_status_json,
updated_at
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING *
"""
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
user_id,
json.dumps({}, ensure_ascii=False),
)
result = execute_query(query, params)
return result[0]
except Exception as e:
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
@router.patch("/ordre/drafts/{draft_id}")
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft."""
try:
from app.core.database import execute_query
query = """
UPDATE ordre_drafts
SET title = %s,
customer_id = %s,
lines_json = %s::jsonb,
notes = %s,
layout_number = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
draft_id,
)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=404, detail="Draft not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to update ordre draft")
@router.delete("/ordre/drafts/{draft_id}")
async def delete_ordre_draft(draft_id: int, http_request: Request):
"""Delete ordre draft."""
try:
from app.core.database import execute_query
query = "DELETE FROM ordre_drafts WHERE id = %s RETURNING id"
params = (draft_id,)
result = execute_query(query, params)
if not result:
raise HTTPException(status_code=404, detail="Draft not found")
return {"status": "deleted", "id": draft_id}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete ordre draft")

View File

@ -0,0 +1,286 @@
import logging
from typing import Any, Dict, List, Optional
from app.core.database import execute_query
logger = logging.getLogger(__name__)
def _to_float(value: Any, default: float = 0.0) -> float:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _apply_common_filters(
base_query: str,
params: List[Any],
customer_id: Optional[int],
sag_id: Optional[int],
q: Optional[str],
customer_alias: str,
sag_alias: str,
description_alias: str,
) -> tuple[str, List[Any]]:
query = base_query
if customer_id:
query += f" AND {customer_alias}.id = %s"
params.append(customer_id)
if sag_id:
query += f" AND {sag_alias}.id = %s"
params.append(sag_id)
if q:
like = f"%{q.lower()}%"
query += (
f" AND (LOWER({description_alias}) LIKE %s"
f" OR LOWER(COALESCE({sag_alias}.titel, '')) LIKE %s"
f" OR LOWER(COALESCE({customer_alias}.name, '')) LIKE %s)"
)
params.extend([like, like, like])
return query, params
def _fetch_sales_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
si.id,
si.sag_id,
s.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
si.description,
si.quantity,
si.unit,
si.unit_price,
si.amount,
si.currency,
si.status,
si.line_date,
si.product_id
FROM sag_salgsvarer si
JOIN sag_sager s ON s.id = si.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND LOWER(si.type) = 'sale'
AND LOWER(si.status) != 'cancelled'
"""
params: List[Any] = []
query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "s", "si.description")
query += " ORDER BY si.line_date DESC NULLS LAST, si.id DESC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
qty = _to_float(row.get("quantity"), 0.0)
unit_price = _to_float(row.get("unit_price"), 0.0)
amount = _to_float(row.get("amount"), qty * unit_price)
lines.append(
{
"line_key": f"sale:{row['id']}",
"source_type": "sale",
"source_id": row["id"],
"reference_id": row["id"],
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": row.get("description") or "Salgslinje",
"quantity": qty if qty > 0 else 1.0,
"unit": row.get("unit") or "stk",
"unit_price": unit_price,
"discount_percentage": 0.0,
"amount": amount,
"currency": row.get("currency") or "DKK",
"status": row.get("status") or "draft",
"line_date": str(row.get("line_date")) if row.get("line_date") else None,
"product_id": row.get("product_id"),
"selected": True,
}
)
return lines
def _fetch_subscription_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
i.id,
i.subscription_id,
i.line_no,
i.product_id,
i.description,
i.quantity,
i.unit_price,
i.line_total,
s.id AS sub_id,
s.subscription_number,
s.status AS subscription_status,
s.billing_interval,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name
FROM sag_subscription_items i
JOIN sag_subscriptions s ON s.id = i.subscription_id
JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE sg.deleted_at IS NULL
AND LOWER(s.status) IN ('draft', 'active', 'paused')
"""
params: List[Any] = []
query, params = _apply_common_filters(query, params, customer_id, sag_id, q, "c", "sg", "i.description")
query += " ORDER BY s.id DESC, i.line_no ASC, i.id ASC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
qty = _to_float(row.get("quantity"), 1.0)
unit_price = _to_float(row.get("unit_price"), 0.0)
amount = _to_float(row.get("line_total"), qty * unit_price)
lines.append(
{
"line_key": f"subscription:{row['id']}",
"source_type": "subscription",
"source_id": row["id"],
"reference_id": row.get("subscription_id"),
"subscription_number": row.get("subscription_number"),
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": row.get("description") or "Abonnementslinje",
"quantity": qty if qty > 0 else 1.0,
"unit": "stk",
"unit_price": unit_price,
"discount_percentage": 0.0,
"amount": amount,
"currency": "DKK",
"status": row.get("subscription_status") or "draft",
"line_date": None,
"product_id": row.get("product_id"),
"selected": True,
"meta": {
"billing_interval": row.get("billing_interval"),
"line_no": row.get("line_no"),
},
}
)
return lines
def _fetch_hardware_lines(customer_id: Optional[int], sag_id: Optional[int], q: Optional[str]) -> List[Dict[str, Any]]:
query = """
SELECT
sh.id AS relation_id,
sh.sag_id,
sh.note,
s.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
h.id AS hardware_id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.status AS hardware_status
FROM sag_hardware sh
JOIN sag_sager s ON s.id = sh.sag_id
JOIN hardware_assets h ON h.id = sh.hardware_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE sh.deleted_at IS NULL
AND s.deleted_at IS NULL
AND h.deleted_at IS NULL
"""
params: List[Any] = []
query, params = _apply_common_filters(
query,
params,
customer_id,
sag_id,
q,
"c",
"s",
"CONCAT(COALESCE(h.brand, ''), ' ', COALESCE(h.model, ''), ' ', COALESCE(h.serial_number, ''))",
)
query += " ORDER BY sh.id DESC"
rows = execute_query(query, tuple(params)) or []
lines: List[Dict[str, Any]] = []
for row in rows:
serial = row.get("serial_number")
serial_part = f" (S/N: {serial})" if serial else ""
brand_model = " ".join([part for part in [row.get("brand"), row.get("model")] if part]).strip()
label = brand_model or row.get("asset_type") or "Hardware"
desc = f"Hardware: {label}{serial_part}"
if row.get("note"):
desc = f"{desc} - {row['note']}"
lines.append(
{
"line_key": f"hardware:{row['relation_id']}",
"source_type": "hardware",
"source_id": row["relation_id"],
"reference_id": row.get("hardware_id"),
"sag_id": row.get("sag_id"),
"sag_title": row.get("sag_title"),
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"description": desc,
"quantity": 1.0,
"unit": "stk",
"unit_price": 0.0,
"discount_percentage": 0.0,
"amount": 0.0,
"currency": "DKK",
"status": row.get("hardware_status") or "active",
"line_date": None,
"product_id": None,
"selected": True,
}
)
return lines
def aggregate_order_lines(
customer_id: Optional[int] = None,
sag_id: Optional[int] = None,
q: Optional[str] = None,
) -> Dict[str, Any]:
"""Aggregate order-ready lines across sale, subscription and hardware sources."""
sales_lines = _fetch_sales_lines(customer_id=customer_id, sag_id=sag_id, q=q)
subscription_lines = _fetch_subscription_lines(customer_id=customer_id, sag_id=sag_id, q=q)
hardware_lines = _fetch_hardware_lines(customer_id=customer_id, sag_id=sag_id, q=q)
all_lines = sales_lines + subscription_lines + hardware_lines
total_amount = sum(_to_float(line.get("amount")) for line in all_lines)
selected_amount = sum(_to_float(line.get("amount")) for line in all_lines if line.get("selected"))
customer_ids = sorted(
{
int(line["customer_id"])
for line in all_lines
if line.get("customer_id") is not None
}
)
return {
"lines": all_lines,
"summary": {
"line_count": len(all_lines),
"line_count_sales": len(sales_lines),
"line_count_subscriptions": len(subscription_lines),
"line_count_hardware": len(hardware_lines),
"customer_count": len(customer_ids),
"total_amount": round(total_amount, 2),
"selected_amount": round(selected_amount, 2),
"currency": "DKK",
},
}

View File

@ -0,0 +1,30 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/ordre/create/new", response_class=HTMLResponse)
async def ordre_create(request: Request):
"""Opret ny ordre (gammel funktionalitet)."""
return templates.TemplateResponse("modules/orders/templates/create.html", {"request": request})
@router.get("/ordre/{draft_id}", response_class=HTMLResponse)
async def ordre_detail(request: Request, draft_id: int):
"""Detaljeret visning af en specifik ordre."""
return templates.TemplateResponse("modules/orders/templates/detail.html", {
"request": request,
"draft_id": draft_id
})
@router.get("/ordre", response_class=HTMLResponse)
async def ordre_index(request: Request):
"""Liste over alle ordre drafts."""
return templates.TemplateResponse("modules/orders/templates/list.html", {"request": request})

View File

@ -0,0 +1,726 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.summary-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.summary-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
}
.line-source {
font-size: 0.75rem;
}
.customer-search-wrap {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 1100;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.search-item {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.search-item:hover {
background: var(--accent-light);
}
.search-item:last-child {
border-bottom: none;
}
.table-secondary {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
}
.table-secondary td {
padding: 0.75rem !important;
}
.order-header-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-header-row:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
}
.order-lines-container {
display: none;
}
.order-lines-container.show {
display: table-row-group;
}
.expand-icon {
transition: transform 0.3s;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Opret ny ordre</h2>
<div class="text-muted">Avanceret samlet ordrevisning (abonnement, hardware, salg)</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Tilbage til liste</a>
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-outline-secondary" onclick="expandAllOrders()"><i class="bi bi-arrows-expand me-1"></i>Fold alle ud</button>
<button class="btn btn-outline-secondary" onclick="collapseAllOrders()"><i class="bi bi-arrows-collapse me-1"></i>Fold alle sammen</button>
<button class="btn btn-outline-secondary" onclick="loadDrafts()"><i class="bi bi-folder2-open me-1"></i>Hent kladder</button>
<button class="btn btn-outline-secondary" onclick="saveDraft()"><i class="bi bi-save me-1"></i>Gem kladde</button>
<button class="btn btn-outline-primary" onclick="loadOrdreLines()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
</div>
<div id="safetyBanner" class="alert alert-warning d-none">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4 customer-search-wrap">
<label class="form-label">Kunde (kræves ved eksport)</label>
<input id="customerSearch" type="text" class="form-control" placeholder="Søg kunde (min. 2 tegn)">
<input id="customerId" type="hidden">
<div id="customerSearchResults" class="search-results d-none"></div>
<div id="selectedCustomerMeta" class="small text-muted mt-1"></div>
</div>
<div class="col-md-2">
<label class="form-label">Sag ID</label>
<input id="sagId" type="number" class="form-control" placeholder="fx 456">
</div>
<div class="col-md-3">
<label class="form-label">Søg</label>
<input id="searchText" type="text" class="form-control" placeholder="Beskrivelse, kunde eller sag">
</div>
<div class="col-md-3">
<label class="form-label">Layout nr.</label>
<input id="layoutNumber" type="number" class="form-control" placeholder="e-conomic layout">
</div>
<div class="col-md-4">
<label class="form-label">Kladde</label>
<select id="draftSelect" class="form-select">
<option value="">Vælg kladde...</option>
</select>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-outline-primary" onclick="loadSelectedDraft()"><i class="bi bi-box-arrow-in-down me-1"></i>Indlæs</button>
</div>
<div class="col-md-2 d-grid">
<button class="btn btn-outline-danger" onclick="deleteSelectedDraft()"><i class="bi bi-trash me-1"></i>Slet</button>
</div>
<div class="col-12">
<label class="form-label">Noter (til e-conomic)</label>
<textarea id="exportNotes" class="form-control" rows="2" placeholder="Valgfri note til ordren"></textarea>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Linjer total</div><div id="sumLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Valgte linjer</div><div id="sumSelectedLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Beløb total</div><div id="sumAmount" class="summary-value">0 kr.</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Valgt beløb</div><div id="sumSelectedAmount" class="summary-value">0 kr.</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th style="width: 30px;"></th>
<th style="width: 50px;">Valg</th>
<th>Kilde</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Pris</th>
<th>Rabat %</th>
<th>Beløb</th>
<th>Eksport</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="ordreLinesBody">
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-success" onclick="exportOrdre()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let ordreLines = [];
let customerSearchTimeout = null;
let customerSearchResultsCache = [];
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
if (type === 'manual') return '<span class="badge bg-info line-source">Manuel</span>';
return '<span class="badge bg-success line-source">Salg</span>';
}
function addManualLine() {
const customerId = Number(document.getElementById('customerId').value || 0) || null;
const customerName = document.getElementById('customerSearch').value || 'Manuel ordre';
const newLine = {
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: 'manual',
source_id: null,
customer_name: customerName,
customer_id: customerId,
sag_title: null,
sag_id: null,
description: 'Ny linje',
quantity: 1,
unit_price: 0,
discount_percentage: 0,
amount: 0,
unit: 'stk',
product_id: null,
selected: true,
export_status: null,
};
ordreLines.push(newLine);
renderLines();
}
function deleteLine(index) {
if (!confirm('Slet denne linje?')) return;
ordreLines.splice(index, 1);
renderLines();
}
function toggleGroupSelection(indices, selected) {
indices.forEach(index => {
ordreLines[index].selected = selected;
});
renderLines();
}
function recalcSummary() {
const totalAmount = ordreLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
const selected = ordreLines.filter(line => line.selected);
const selectedAmount = selected.reduce((sum, line) => sum + Number(line.amount || 0), 0);
document.getElementById('sumLines').textContent = ordreLines.length;
document.getElementById('sumSelectedLines').textContent = selected.length;
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
document.getElementById('sumSelectedAmount').textContent = formatCurrency(selectedAmount);
}
function updateLineAmount(index) {
const line = ordreLines[index];
const qty = Number(line.quantity || 0);
const price = Number(line.unit_price || 0);
const discount = Number(line.discount_percentage || 0);
const gross = qty * price;
const net = gross * (1 - (discount / 100));
line.amount = Number(net.toFixed(2));
const amountEl = document.getElementById(`lineAmount-${index}`);
if (amountEl) amountEl.textContent = formatCurrency(line.amount);
// Re-render to update group totals
renderLines();
}
function renderLines() {
const body = document.getElementById('ordreLinesBody');
if (!ordreLines.length) {
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen linjer fundet</td></tr>';
recalcSummary();
return;
}
// Group lines by customer_id (or use 'manual' for manual entries without customer)
const grouped = {};
ordreLines.forEach((line, index) => {
const groupKey = line.customer_id || line.customer_name || 'manual';
if (!grouped[groupKey]) {
grouped[groupKey] = {
customer_name: line.customer_name || 'Manuel ordre',
customer_id: line.customer_id || null,
lines: []
};
}
grouped[groupKey].lines.push({ ...line, originalIndex: index });
});
// Render grouped lines with collapsible rows
let html = '';
Object.keys(grouped).forEach((groupKey, groupIndex) => {
const group = grouped[groupKey];
const groupTotal = group.lines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
const groupSelected = group.lines.filter(line => line.selected).length;
const allSelected = groupSelected === group.lines.length;
const lineIndices = group.lines.map(line => line.originalIndex);
// Group header row (clickable to expand/collapse)
html += `
<tr class="table-secondary order-header-row" onclick="toggleOrderLines('order-${groupIndex}')">
<td>
<i class="bi bi-chevron-right expand-icon" id="icon-order-${groupIndex}"></i>
</td>
<td>
<input type="checkbox" ${allSelected ? 'checked' : ''}
onclick="event.stopPropagation();"
onchange="toggleGroupSelection([${lineIndices.join(',')}], this.checked);"
title="Vælg/fravælg alle">
</td>
<td colspan="8">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-folder2 me-2"></i><strong>${group.customer_name}</strong>
${group.customer_id ? ` <span class="badge bg-light text-dark border">Kunde ${group.customer_id}</span>` : ''}
</div>
<div class="text-end">
<span class="badge bg-primary me-2">${group.lines.length} ${group.lines.length === 1 ? 'linje' : 'linjer'}</span>
<span class="badge bg-success me-2">${groupSelected} valgt</span>
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
</div>
</div>
</td>
</tr>
`;
// Render lines in this group (hidden by default)
group.lines.forEach((line) => {
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
onchange="ordreLines[${index}].description = this.value;">`
: (line.description || '-');
html += `
<tr class="order-lines-container" data-order="order-${groupIndex}">
<td></td>
<td>
<input type="checkbox" ${line.selected ? 'checked' : ''} onchange="ordreLines[${index}].selected = this.checked; recalcSummary();">
</td>
<td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
onchange="ordreLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
onchange="ordreLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
onchange="ordreLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${renderExportStatusBadge(line)}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr>
`;
});
});
body.innerHTML = html;
recalcSummary();
}
function toggleOrderLines(orderId) {
const lines = document.querySelectorAll(`tr[data-order="${orderId}"]`);
const icon = document.getElementById(`icon-${orderId}`);
lines.forEach(line => {
line.classList.toggle('show');
});
if (icon) {
icon.classList.toggle('expanded');
}
}
function expandAllOrders() {
document.querySelectorAll('.order-lines-container').forEach(line => {
line.classList.add('show');
});
document.querySelectorAll('.expand-icon').forEach(icon => {
icon.classList.add('expanded');
});
}
function collapseAllOrders() {
document.querySelectorAll('.order-lines-container').forEach(line => {
line.classList.remove('show');
});
document.querySelectorAll('.expand-icon').forEach(icon => {
icon.classList.remove('expanded');
});
}
function renderExportStatusBadge(line) {
const status = line.export_status || '';
if (status === 'exported') {
return '<span class="badge bg-success">Eksporteret</span>';
}
if (status === 'dry-run') {
return '<span class="badge bg-warning text-dark">Dry-run</span>';
}
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
}
function selectCustomer(customer) {
document.getElementById('customerId').value = customer.id;
document.getElementById('customerSearch').value = customer.name || '';
document.getElementById('selectedCustomerMeta').textContent = `ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}`;
document.getElementById('customerSearchResults').classList.add('d-none');
}
function clearCustomerSelection() {
document.getElementById('customerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = '';
}
async function searchCustomers(query) {
const resultsEl = document.getElementById('customerSearchResults');
if (!query || query.length < 2) {
resultsEl.classList.add('d-none');
resultsEl.innerHTML = '';
return;
}
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Kundesøgning fejlede');
}
const customers = await response.json();
if (!Array.isArray(customers) || customers.length === 0) {
resultsEl.innerHTML = '<div class="search-item text-muted">Ingen kunder fundet</div>';
resultsEl.classList.remove('d-none');
return;
}
customerSearchResultsCache = customers;
resultsEl.innerHTML = customers.map((customer, index) => `
<div class="search-item" onclick="selectCustomerByIndex(${index})">
<div class="fw-semibold">${customer.name || '-'}</div>
<div class="small text-muted">ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}</div>
</div>
`).join('');
resultsEl.classList.remove('d-none');
} catch (err) {
resultsEl.innerHTML = '<div class="search-item text-danger">Fejl ved kundesøgning</div>';
resultsEl.classList.remove('d-none');
}
}
function selectCustomerByIndex(index) {
const customer = customerSearchResultsCache[index];
if (!customer) return;
selectCustomer(customer);
}
async function loadConfig() {
try {
const res = await fetch('/api/v1/ordre/config');
if (!res.ok) return;
const cfg = await res.json();
if (cfg.economic_read_only || cfg.economic_dry_run) {
document.getElementById('safetyBanner').classList.remove('d-none');
}
if (cfg.default_layout) {
document.getElementById('layoutNumber').value = cfg.default_layout;
}
} catch (err) {
console.error('Config load failed', err);
}
}
async function loadOrdreLines() {
const customerId = document.getElementById('customerId').value;
const sagId = document.getElementById('sagId').value;
const q = document.getElementById('searchText').value.trim();
const params = new URLSearchParams();
if (customerId) params.append('customer_id', customerId);
if (sagId) params.append('sag_id', sagId);
if (q) params.append('q', q);
const body = document.getElementById('ordreLinesBody');
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
try {
const res = await fetch(`/api/v1/ordre/aggregate?${params.toString()}`);
if (!res.ok) throw new Error('Failed to load aggregate');
const data = await res.json();
ordreLines = data.lines || [];
renderLines();
} catch (err) {
console.error(err);
body.innerHTML = '<tr><td colspan="10" class="text-danger text-center py-4">Kunne ikke hente ordrelinjer</td></tr>';
ordreLines = [];
recalcSummary();
}
}
async function loadDrafts() {
const select = document.getElementById('draftSelect');
select.innerHTML = '<option value="">Indlæser kladder...</option>';
try {
const res = await fetch('/api/v1/ordre/drafts');
if (!res.ok) throw new Error('Kunne ikke hente kladder');
const drafts = await res.json();
select.innerHTML = '<option value="">Vælg kladde...</option>' + (drafts || []).map(d =>
`<option value="${d.id}">${d.title} (#${d.id})</option>`
).join('');
} catch (err) {
select.innerHTML = '<option value="">Fejl ved indlæsning</option>';
}
}
async function saveDraft() {
const title = prompt('Navn på kladde:', 'Ordrekladde');
if (!title) return;
const selectedDraftId = Number(document.getElementById('draftSelect').value || 0);
const payload = {
title,
customer_id: Number(document.getElementById('customerId').value || 0) || null,
lines: ordreLines,
notes: document.getElementById('exportNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
};
try {
const isUpdate = selectedDraftId > 0;
const endpoint = isUpdate ? `/api/v1/ordre/drafts/${selectedDraftId}` : '/api/v1/ordre/drafts';
const method = isUpdate ? 'PATCH' : 'POST';
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme kladde');
if (data.id && !isUpdate) {
// Redirect to detail page after creating new order
window.location.href = `/ordre/${data.id}`;
return;
}
await loadDrafts();
if (data.id) {
document.getElementById('draftSelect').value = String(data.id);
}
alert('Kladde gemt');
} catch (err) {
alert(`Kunne ikke gemme kladde: ${err.message}`);
}
}
async function loadSelectedDraft() {
const draftId = Number(document.getElementById('draftSelect').value || 0);
if (!draftId) {
alert('Vælg en kladde først');
return;
}
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
const draft = await res.json();
if (!res.ok) throw new Error(draft.detail || 'Kunne ikke hente kladde');
ordreLines = Array.isArray(draft.lines_json) ? draft.lines_json : [];
document.getElementById('exportNotes').value = draft.notes || '';
document.getElementById('layoutNumber').value = draft.layout_number || '';
const exportStatus = (draft.export_status_json && typeof draft.export_status_json === 'object')
? draft.export_status_json
: {};
ordreLines = ordreLines.map((line) => {
const key = line.line_key;
const statusMeta = key ? exportStatus[key] : null;
if (statusMeta && statusMeta.status) {
return {
...line,
export_status: statusMeta.status,
exported_at: statusMeta.timestamp || null,
};
}
return line;
});
if (draft.customer_id) {
document.getElementById('customerId').value = draft.customer_id;
document.getElementById('customerSearch').value = `Kunde #${draft.customer_id}`;
document.getElementById('selectedCustomerMeta').textContent = `ID ${draft.customer_id}`;
} else {
document.getElementById('customerSearch').value = '';
clearCustomerSelection();
}
renderLines();
alert('Kladde indlæst');
} catch (err) {
alert(`Kunne ikke indlæse kladde: ${err.message}`);
}
}
async function deleteSelectedDraft() {
const draftId = Number(document.getElementById('draftSelect').value || 0);
if (!draftId) {
alert('Vælg en kladde først');
return;
}
if (!confirm('Slet denne kladde?')) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke slette kladde');
await loadDrafts();
alert('Kladde slettet');
} catch (err) {
alert(`Kunne ikke slette kladde: ${err.message}`);
}
}
async function exportOrdre() {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
alert('Vælg kunde før eksport');
return;
}
const selectedLines = ordreLines.filter(line => line.selected);
if (!selectedLines.length) {
alert('Vælg mindst én linje');
return;
}
const payload = {
customer_id: customerId,
lines: selectedLines.map(line => ({
line_key: line.line_key,
source_type: line.source_type,
source_id: line.source_id,
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
selected: true,
})),
notes: document.getElementById('exportNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: Number(document.getElementById('draftSelect').value || 0) || null,
};
try {
const res = await fetch('/api/v1/ordre/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Eksport fejlede');
}
const exportedLineKeys = data.exported_line_keys || [];
const status = data.dry_run ? 'dry-run' : 'exported';
ordreLines.forEach((line) => {
if (exportedLineKeys.includes(line.line_key)) {
line.export_status = status;
line.exported_at = new Date().toISOString();
}
});
renderLines();
alert(data.message || 'Eksport udført');
} catch (err) {
console.error(err);
alert(`Eksport fejlede: ${err.message}`);
}
}
document.addEventListener('DOMContentLoaded', async () => {
const customerSearchInput = document.getElementById('customerSearch');
if (customerSearchInput) {
customerSearchInput.addEventListener('input', () => {
const query = customerSearchInput.value.trim();
clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => {
if (!query) {
clearCustomerSelection();
}
searchCustomers(query);
}, 200);
});
}
document.addEventListener('click', (event) => {
const resultsEl = document.getElementById('customerSearchResults');
const searchInput = document.getElementById('customerSearch');
if (!resultsEl || !searchInput) return;
if (resultsEl.contains(event.target) || searchInput.contains(event.target)) return;
resultsEl.classList.add('d-none');
});
await loadConfig();
await loadDrafts();
await loadOrdreLines();
});
</script>
{% endblock %}

View File

@ -0,0 +1,416 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre #{{ draft_id }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.ordre-header {
background: var(--bg-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(0,0,0,0.06);
}
.info-item {
margin-bottom: 0.75rem;
}
.info-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.summary-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.summary-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: white;
background: var(--accent);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre #{{ draft_id }}</h2>
<div class="text-muted">Detaljeret visning</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Tilbage til liste</a>
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
</div>
</div>
<div id="safetyBanner" class="alert alert-warning d-none">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div class="ordre-header">
<div class="row g-3">
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Titel</div>
<input type="text" id="orderTitle" class="form-control" placeholder="Ordre titel">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Layout nr.</div>
<input type="number" id="layoutNumber" class="form-control" placeholder="e-conomic layout">
</div>
</div>
<div class="col-md-3">
<div class="info-item">
<div class="info-label">Status</div>
<div id="orderStatus" class="info-value">-</div>
</div>
</div>
<div class="col-12">
<div class="info-item">
<div class="info-label">Noter</div>
<textarea id="orderNotes" class="form-control" rows="2" placeholder="Valgfri noter til ordren"></textarea>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Antal linjer</div><div id="sumLines" class="summary-value">0</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Total beløb</div><div id="sumAmount" class="summary-value">0 kr.</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Oprettet</div><div id="createdAt" class="summary-value">-</div></div></div>
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kilde</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Enhedspris</th>
<th>Rabat %</th>
<th>Beløb</th>
<th>Enhed</th>
<th>Status</th>
<th>Handling</th>
</tr>
</thead>
<tbody id="linesTableBody">
<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const draftId = {{ draft_id }};
let orderData = null;
let orderLines = [];
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function sourceBadge(type) {
if (type === 'subscription') return '<span class="badge bg-primary">Abonnement</span>';
if (type === 'hardware') return '<span class="badge bg-secondary">Hardware</span>';
if (type === 'manual') return '<span class="badge bg-info">Manuel</span>';
return '<span class="badge bg-success">Salg</span>';
}
function renderLines() {
const tbody = document.getElementById('linesTableBody');
if (!orderLines.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen linjer</td></tr>';
updateSummary();
return;
}
tbody.innerHTML = orderLines.map((line, index) => {
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
onchange="orderLines[${index}].description = this.value;">`
: (line.description || '-');
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
? '<span class="badge bg-success">Eksporteret</span>'
: exportStatus === 'dry-run'
? '<span class="badge bg-warning text-dark">Dry-run</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
return `
<tr>
<td>${sourceBadge(line.source_type)}</td>
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
<td>${line.unit || 'stk'}</td>
<td>${statusBadge}</td>
<td>
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
</td>
</tr>
`;
}).join('');
updateSummary();
}
function updateLineAmount(index) {
const line = orderLines[index];
const qty = Number(line.quantity || 0);
const price = Number(line.unit_price || 0);
const discount = Number(line.discount_percentage || 0);
const gross = qty * price;
const net = gross * (1 - (discount / 100));
line.amount = Number(net.toFixed(2));
renderLines();
}
function updateSummary() {
const totalAmount = orderLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
document.getElementById('sumLines').textContent = orderLines.length;
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
}
function addManualLine() {
const newLine = {
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: 'manual',
source_id: null,
customer_name: '-',
customer_id: null,
description: 'Ny linje',
quantity: 1,
unit_price: 0,
discount_percentage: 0,
amount: 0,
unit: 'stk',
selected: true,
};
orderLines.push(newLine);
renderLines();
}
function deleteLine(index) {
if (!confirm('Slet denne linje?')) return;
orderLines.splice(index, 1);
renderLines();
}
function normalizeOrderLine(line) {
// Handle e-conomic format (product.description, unitNetPrice, etc.)
if (line.product && line.product.description && !line.description) {
return {
line_key: line.line_key || `imported-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: line.source_type || 'manual',
source_id: line.source_id || null,
customer_name: line.customer_name || '-',
customer_id: line.customer_id || null,
description: line.product.description || '',
quantity: Number(line.quantity || 1),
unit_price: Number(line.unitNetPrice || 0),
discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk',
product_id: line.product.productNumber || null,
selected: line.selected !== false,
export_status: line.export_status || null,
};
}
// Already in our internal format
return {
...line,
quantity: Number(line.quantity || 1),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
amount: Number(line.amount || 0),
};
}
async function loadOrder() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
if (!res.ok) throw new Error('Kunne ikke hente ordre');
orderData = await res.json();
orderLines = Array.isArray(orderData.lines_json)
? orderData.lines_json.map(normalizeOrderLine)
: [];
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = orderData.last_exported_at ? true : false;
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
renderLines();
await loadConfig();
} catch (error) {
console.error(error);
alert(`Fejl: ${error.message}`);
}
}
async function loadConfig() {
try {
const res = await fetch('/api/v1/ordre/config');
if (!res.ok) return;
const cfg = await res.json();
if (cfg.economic_read_only || cfg.economic_dry_run) {
document.getElementById('safetyBanner').classList.remove('d-none');
}
if (!document.getElementById('layoutNumber').value && cfg.default_layout) {
document.getElementById('layoutNumber').value = cfg.default_layout;
}
} catch (err) {
console.error('Config load failed', err);
}
}
async function saveOrder() {
const payload = {
title: document.getElementById('orderTitle').value || 'Ordre',
customer_id: Number(document.getElementById('customerId').value || 0) || null,
lines: orderLines,
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
};
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre');
alert('Ordre gemt');
await loadOrder();
} catch (err) {
alert(`Kunne ikke gemme ordre: ${err.message}`);
}
}
async function exportOrder() {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
alert('Angiv kunde ID før eksport');
return;
}
if (!orderLines.length) {
alert('Ingen linjer at eksportere');
return;
}
const payload = {
customer_id: customerId,
lines: orderLines.map(line => ({
line_key: line.line_key,
source_type: line.source_type,
source_id: line.source_id,
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
};
try {
const res = await fetch('/api/v1/ordre/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Eksport fejlede');
}
alert(data.message || 'Eksport udført');
await loadOrder();
} catch (err) {
console.error(err);
alert(`Eksport fejlede: ${err.message}`);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrder();
});
</script>
{% endblock %}

View File

@ -0,0 +1,224 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.ordre-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.ordre-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.ordre-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
background: var(--accent);
color: white;
}
.order-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-row:hover {
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.05);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-1"><i class="bi bi-receipt me-2"></i>Ordre</h2>
<div class="text-muted">Oversigt over alle ordre</div>
</div>
<div class="d-flex gap-2">
<a href="/ordre/create/new" class="btn btn-success"><i class="bi bi-plus-circle me-1"></i>Opret ny ordre</a>
<button class="btn btn-outline-primary" onclick="loadOrders()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Total ordre</div><div id="sumOrders" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Seneste måned</div><div id="sumRecent" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Eksporteret</div><div id="sumExported" class="ordre-value">0</div></div></div>
<div class="col-md-3"><div class="ordre-card"><div class="ordre-title">Ikke eksporteret</div><div id="sumNotExported" class="ordre-value">0</div></div></div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Ordre #</th>
<th>Titel</th>
<th>Kunde</th>
<th>Linjer</th>
<th>Oprettet</th>
<th>Sidst opdateret</th>
<th>Sidst eksporteret</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="ordersTableBody">
<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let orders = [];
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function renderOrders() {
const tbody = document.getElementById('ordersTableBody');
if (!orders.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen ordre fundet</td></tr>';
updateSummary();
return;
}
tbody.innerHTML = orders.map(order => {
const lines = Array.isArray(order.lines_json) ? order.lines_json : [];
const hasExported = order.last_exported_at ? true : false;
const statusBadge = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
return `
<tr class="order-row" onclick="window.location.href='/ordre/${order.id}'">
<td><strong>#${order.id}</strong></td>
<td>${order.title || '-'}</td>
<td>${order.customer_id ? `Kunde ${order.customer_id}` : '-'}</td>
<td><span class="badge bg-primary">${lines.length} linjer</span></td>
<td>${formatDate(order.created_at)}</td>
<td>${formatDate(order.updated_at)}</td>
<td>${formatDate(order.last_exported_at)}</td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ordre/${order.id}'">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteOrder(${order.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
updateSummary();
}
function updateSummary() {
const now = new Date();
const oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
const recentOrders = orders.filter(order => new Date(order.created_at) >= oneMonthAgo);
const exportedOrders = orders.filter(order => order.last_exported_at);
const notExportedOrders = orders.filter(order => !order.last_exported_at);
document.getElementById('sumOrders').textContent = orders.length;
document.getElementById('sumRecent').textContent = recentOrders.length;
document.getElementById('sumExported').textContent = exportedOrders.length;
document.getElementById('sumNotExported').textContent = notExportedOrders.length;
}
async function loadOrders() {
const tbody = document.getElementById('ordersTableBody');
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Indlæser...</td></tr>';
try {
const res = await fetch('/api/v1/ordre/drafts?limit=100');
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('API Error:', res.status, errorData);
throw new Error(errorData.detail || `HTTP ${res.status}: Kunne ikke hente ordre`);
}
const data = await res.json();
console.log('Fetched orders:', data);
orders = Array.isArray(data) ? data : [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center py-4">Ingen ordre fundet. <a href="/ordre/create/new" class="btn btn-sm btn-success ms-2">Opret første ordre</a></td></tr>';
updateSummary();
return;
}
// Fetch lines_json for each order to get line count
const detailPromises = orders.map(async (order) => {
try {
const detailRes = await fetch(`/api/v1/ordre/drafts/${order.id}`);
if (detailRes.ok) {
const detail = await detailRes.json();
order.lines_json = detail.lines_json || [];
} else {
order.lines_json = [];
}
} catch (e) {
console.error(`Failed to fetch details for order ${order.id}:`, e);
order.lines_json = [];
}
});
await Promise.all(detailPromises);
renderOrders();
} catch (error) {
console.error('Load orders error:', error);
tbody.innerHTML = `<tr><td colspan="9" class="text-danger text-center py-4">${error.message || 'Kunne ikke hente ordre'}</td></tr>`;
orders = [];
updateSummary();
}
}
async function deleteOrder(orderId) {
if (!confirm('Er du sikker på, at du vil slette denne ordre?')) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${orderId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke slette ordre');
}
await loadOrders();
alert('Ordre slettet');
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrders();
});
</script>
{% endblock %}

View File

@ -0,0 +1,625 @@
"""
Reminder API Endpoints for Sag Module
CRUD operations, user preferences, snooze/dismiss functionality
"""
import logging
from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert
from app.services.reminder_notification_service import reminder_notification_service
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# Helper Functions
# ============================================================================
def _get_user_id_from_request(request: Request) -> int:
"""Extract user_id from request query params or raise 401"""
user_id = getattr(request.state, 'user_id', None)
if user_id is not None:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
user_id = request.query_params.get('user_id')
if user_id:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
# ============================================================================
# Pydantic Schemas
# ============================================================================
class UserNotificationPreferences(BaseModel):
"""User notification preferences"""
notify_mattermost: bool = True
notify_email: bool = False
notify_frontend: bool = True
email_override: Optional[str] = None
quiet_hours_enabled: bool = False
quiet_hours_start: Optional[str] = None # HH:MM format
quiet_hours_end: Optional[str] = None # HH:MM format
class ReminderCreate(BaseModel):
"""Create reminder request"""
title: str = Field(..., min_length=3, max_length=255)
message: Optional[str] = None
priority: str = Field(default="normal", pattern="^(low|normal|high|urgent)$")
event_type: str = Field(default="reminder", pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
trigger_type: str = Field(pattern="^(status_change|deadline_approaching|time_based)$")
trigger_config: dict # JSON config for trigger
recipient_user_ids: List[int] = []
recipient_emails: List[str] = []
notify_mattermost: Optional[bool] = None
notify_email: Optional[bool] = None
notify_frontend: Optional[bool] = None
override_user_preferences: bool = False
recurrence_type: str = Field(default="once", pattern="^(once|daily|weekly|monthly)$")
recurrence_day_of_week: Optional[int] = None # 0-6 for weekly
recurrence_day_of_month: Optional[int] = None # 1-31 for monthly
scheduled_at: Optional[datetime] = None
class ReminderUpdate(BaseModel):
"""Update reminder request"""
title: Optional[str] = None
message: Optional[str] = None
priority: Optional[str] = None
event_type: Optional[str] = Field(default=None, pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
notify_mattermost: Optional[bool] = None
notify_email: Optional[bool] = None
notify_frontend: Optional[bool] = None
override_user_preferences: Optional[bool] = None
is_active: Optional[bool] = None
class ReminderResponse(BaseModel):
"""Reminder response"""
id: int
sag_id: int
title: str
message: Optional[str]
priority: str
event_type: Optional[str]
trigger_type: str
recurrence_type: str
is_active: bool
next_check_at: Optional[datetime]
last_sent_at: Optional[datetime]
created_at: datetime
class ReminderProfileResponse(BaseModel):
"""Reminder response for profile list"""
id: int
sag_id: int
title: str
message: Optional[str]
priority: str
event_type: Optional[str]
trigger_type: str
recurrence_type: str
is_active: bool
next_check_at: Optional[datetime]
last_sent_at: Optional[datetime]
created_at: datetime
case_title: Optional[str]
customer_name: Optional[str]
class ReminderLogResponse(BaseModel):
"""Reminder execution log"""
id: int
reminder_id: Optional[int]
sag_id: int
status: str
triggered_at: datetime
channels_used: List[str]
class SnoozeRequest(BaseModel):
"""Snooze reminder request"""
duration_minutes: int = Field(..., ge=15, le=1440) # 15 min to 24 hours
class DismissRequest(BaseModel):
"""Dismiss reminder request"""
reason: Optional[str] = None
# ============================================================================
# User Preferences Endpoints
# ============================================================================
@router.get("/api/v1/users/me/notification-preferences", response_model=UserNotificationPreferences)
async def get_user_notification_preferences(request: Request):
"""Get current user's notification preferences"""
user_id = _get_user_id_from_request(request)
query = """
SELECT
notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
FROM user_notification_preferences
WHERE user_id = %s
"""
result = execute_query(query, (user_id,))
if result:
r = result[0]
return UserNotificationPreferences(
notify_mattermost=r.get('notify_mattermost', True),
notify_email=r.get('notify_email', False),
notify_frontend=r.get('notify_frontend', True),
email_override=r.get('email_override'),
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
quiet_hours_start=r.get('quiet_hours_start'),
quiet_hours_end=r.get('quiet_hours_end')
)
# Return defaults
return UserNotificationPreferences()
@router.patch("/api/v1/users/me/notification-preferences")
async def update_user_notification_preferences(
request: Request,
preferences: UserNotificationPreferences
):
"""Update user notification preferences"""
user_id = _get_user_id_from_request(request)
# Check if preferences exist
check_query = "SELECT id FROM user_notification_preferences WHERE user_id = %s"
exists = execute_query(check_query, (user_id,))
try:
if exists:
# Update existing
query = """
UPDATE user_notification_preferences
SET notify_mattermost = %s,
notify_email = %s,
notify_frontend = %s,
email_override = %s,
quiet_hours_enabled = %s,
quiet_hours_start = %s,
quiet_hours_end = %s,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = %s
RETURNING id
"""
execute_insert(query, (
preferences.notify_mattermost,
preferences.notify_email,
preferences.notify_frontend,
preferences.email_override,
preferences.quiet_hours_enabled,
preferences.quiet_hours_start,
preferences.quiet_hours_end,
user_id
))
else:
# Create new
query = """
INSERT INTO user_notification_preferences (
user_id, notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
execute_insert(query, (
user_id,
preferences.notify_mattermost,
preferences.notify_email,
preferences.notify_frontend,
preferences.email_override,
preferences.quiet_hours_enabled,
preferences.quiet_hours_start,
preferences.quiet_hours_end
))
logger.info(f"✅ Updated notification preferences for user {user_id}")
return {"success": True, "message": "Preferences updated"}
except Exception as e:
logger.error(f"❌ Error updating preferences: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Reminder CRUD Endpoints
# ============================================================================
@router.get("/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse])
async def list_sag_reminders(sag_id: int):
"""List all reminders for a case"""
query = """
SELECT id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at
FROM sag_reminders
WHERE sag_id = %s AND deleted_at IS NULL
ORDER BY created_at DESC
"""
results = execute_query(query, (sag_id,))
return [
ReminderResponse(
id=r['id'],
sag_id=r['sag_id'],
title=r['title'],
message=r['message'],
priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'],
is_active=r['is_active'],
next_check_at=r['next_check_at'],
last_sent_at=r['last_sent_at'],
created_at=r['created_at']
)
for r in results
]
@router.get("/api/v1/reminders/my", response_model=List[ReminderProfileResponse])
async def list_my_reminders(request: Request):
"""List reminders for the authenticated user"""
user_id = _get_user_id_from_request(request)
query = """
SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.event_type, r.trigger_type,
r.recurrence_type, r.is_active, r.next_check_at, r.last_sent_at, r.created_at,
s.titel as case_title, c.name as customer_name
FROM sag_reminders r
LEFT JOIN sag_sager s ON s.id = r.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE r.deleted_at IS NULL
AND %s = ANY(r.recipient_user_ids)
ORDER BY r.created_at DESC
"""
results = execute_query(query, (user_id,))
return [
ReminderProfileResponse(
id=r['id'],
sag_id=r['sag_id'],
title=r['title'],
message=r['message'],
priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'],
is_active=r['is_active'],
next_check_at=r['next_check_at'],
last_sent_at=r['last_sent_at'],
created_at=r['created_at'],
case_title=r.get('case_title'),
customer_name=r.get('customer_name')
)
for r in results
]
@router.post("/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse)
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
"""Create a new reminder for a case"""
user_id = _get_user_id_from_request(request)
# Verify case exists
case_query = "SELECT id FROM sag_sager WHERE id = %s"
case = execute_query(case_query, (sag_id,))
if not case:
raise HTTPException(status_code=404, detail=f"Case #{sag_id} not found")
# Calculate next_check_at based on trigger type
next_check_at = None
if reminder.trigger_type == 'time_based' and reminder.scheduled_at:
next_check_at = reminder.scheduled_at
elif reminder.trigger_type == 'deadline_approaching':
next_check_at = datetime.now() + timedelta(days=1) # Check daily
try:
import json
query = """
INSERT INTO sag_reminders (
sag_id, title, message, priority, event_type, trigger_type, trigger_config,
recipient_user_ids, recipient_emails,
notify_mattermost, notify_email, notify_frontend,
override_user_preferences,
recurrence_type, recurrence_day_of_week, recurrence_day_of_month,
scheduled_at, next_check_at,
is_active, created_by_user_id
)
VALUES (
%s, %s, %s, %s, %s, %s, %s,
%s, %s,
%s, %s, %s,
%s,
%s, %s, %s,
%s, %s,
true, %s
)
RETURNING id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at
"""
result = execute_insert(query, (
sag_id, reminder.title, reminder.message, reminder.priority, reminder.event_type,
reminder.trigger_type, json.dumps(reminder.trigger_config),
reminder.recipient_user_ids, reminder.recipient_emails,
reminder.notify_mattermost, reminder.notify_email, reminder.notify_frontend,
reminder.override_user_preferences,
reminder.recurrence_type, reminder.recurrence_day_of_week, reminder.recurrence_day_of_month,
reminder.scheduled_at, next_check_at,
user_id
))
if not result:
raise HTTPException(status_code=500, detail="Failed to create reminder")
raw_row = result[0] if isinstance(result, list) else result
if isinstance(raw_row, dict):
r = raw_row
else:
reminder_id = int(raw_row)
fetch_query = """
SELECT id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at
FROM sag_reminders
WHERE id = %s
"""
fetched = execute_query(fetch_query, (reminder_id,))
if not fetched:
raise HTTPException(status_code=500, detail="Failed to fetch reminder after creation")
r = fetched[0]
logger.info(f"✅ Reminder created for case #{sag_id} by user {user_id}")
return ReminderResponse(
id=r['id'],
sag_id=r['sag_id'],
title=r['title'],
message=r['message'],
priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'],
is_active=r['is_active'],
next_check_at=r['next_check_at'],
last_sent_at=r['last_sent_at'],
created_at=r['created_at']
)
except Exception as e:
logger.error(f"❌ Error creating reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/api/v1/sag/reminders/{reminder_id}")
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
"""Update a reminder"""
# Build update query dynamically
updates = []
params = []
if update.title is not None:
updates.append("title = %s")
params.append(update.title)
if update.message is not None:
updates.append("message = %s")
params.append(update.message)
if update.priority is not None:
updates.append("priority = %s")
params.append(update.priority)
if update.event_type is not None:
updates.append("event_type = %s")
params.append(update.event_type)
if update.notify_mattermost is not None:
updates.append("notify_mattermost = %s")
params.append(update.notify_mattermost)
if update.notify_email is not None:
updates.append("notify_email = %s")
params.append(update.notify_email)
if update.notify_frontend is not None:
updates.append("notify_frontend = %s")
params.append(update.notify_frontend)
if update.override_user_preferences is not None:
updates.append("override_user_preferences = %s")
params.append(update.override_user_preferences)
if update.is_active is not None:
updates.append("is_active = %s")
params.append(update.is_active)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = CURRENT_TIMESTAMP")
params.append(reminder_id)
try:
query = f"""
UPDATE sag_reminders
SET {', '.join(updates)}
WHERE id = %s
RETURNING id
"""
result = execute_insert(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} updated")
return {"success": True, "message": "Reminder updated"}
except Exception as e:
logger.error(f"❌ Error updating reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/sag/reminders/{reminder_id}")
async def delete_sag_reminder(reminder_id: int):
"""Soft-delete a reminder"""
try:
query = """
UPDATE sag_reminders
SET deleted_at = CURRENT_TIMESTAMP, is_active = false
WHERE id = %s
RETURNING id
"""
result = execute_insert(query, (reminder_id,))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} deleted")
return {"success": True, "message": "Reminder deleted"}
except Exception as e:
logger.error(f"❌ Error deleting reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Reminder Interaction Endpoints
# ============================================================================
@router.post("/api/v1/sag/reminders/{reminder_id}/snooze")
async def snooze_reminder(reminder_id: int, request: Request, snooze_request: SnoozeRequest):
"""Snooze a reminder for specified minutes"""
user_id = _get_user_id_from_request(request)
snooze_until = datetime.now() + timedelta(minutes=snooze_request.duration_minutes)
try:
query = """
INSERT INTO sag_reminder_logs (
reminder_id, sag_id, user_id, status, snoozed_until, snoozed_by_user_id, triggered_at
)
SELECT id, sag_id, %s, 'snoozed', %s, %s, CURRENT_TIMESTAMP
FROM sag_reminders
WHERE id = %s
RETURNING id
"""
result = execute_insert(query, (user_id, snooze_until, user_id, reminder_id))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} snoozed until {snooze_until}")
return {
"success": True,
"message": f"Reminder snoozed for {snooze_request.duration_minutes} minutes",
"snoozed_until": snooze_until
}
except Exception as e:
logger.error(f"❌ Error snoozing reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/sag/reminders/{reminder_id}/dismiss")
async def dismiss_reminder(reminder_id: int, request: Request, dismiss_request: DismissRequest):
"""Dismiss a reminder"""
user_id = _get_user_id_from_request(request)
try:
query = """
INSERT INTO sag_reminder_logs (
reminder_id, sag_id, user_id, status, dismissed_at, dismissed_by_user_id, triggered_at
)
SELECT id, sag_id, %s, 'dismissed', CURRENT_TIMESTAMP, %s, CURRENT_TIMESTAMP
FROM sag_reminders
WHERE id = %s
RETURNING id
"""
result = execute_insert(query, (user_id, user_id, reminder_id))
if not result:
raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} dismissed by user {user_id}")
return {"success": True, "message": "Reminder dismissed"}
except Exception as e:
logger.error(f"❌ Error dismissing reminder: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/reminders/pending/me")
async def get_pending_reminders(request: Request):
"""Get pending reminders for current user (for frontend polling)"""
user_id = _get_user_id_from_request(request)
query = """
SELECT
r.id, r.sag_id, r.title, r.message, r.priority,
s.titel as case_title, c.name as customer_name,
l.id as log_id, l.snoozed_until, l.status as log_status
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 r.priority DESC, r.next_check_at ASC
LIMIT 5
"""
try:
results = execute_query(query, (user_id, user_id))
return [{
'id': r['id'],
'sag_id': r['sag_id'],
'title': r['title'],
'message': r['message'],
'priority': r['priority'],
'case_title': r['case_title'],
'customer_name': r['customer_name']
} for r in results]
except Exception as e:
logger.error(f"❌ Error fetching pending reminders: {e}")
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
import logging
from fastapi import APIRouter, HTTPException, Depends
from typing import Optional
from app.core.database import execute_query
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int):
"""Get the solution associated with a case."""
try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
if not result:
return None
return result[0]
except Exception as e:
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
@router.post("/sag/{sag_id}/solution", response_model=Solution)
async def create_solution(sag_id: int, solution: SolutionCreate):
"""Create a solution for a case."""
try:
# Check if case exists
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
if not case_check:
raise HTTPException(status_code=404, detail="Case not found")
# Check if solution already exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if check:
raise HTTPException(status_code=400, detail="Solution already exists for this case")
query = """
INSERT INTO sag_solutions
(sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
sag_id,
solution.title,
solution.description,
solution.solution_type,
solution.result,
solution.created_by_user_id
)
result = execute_query(query, params)
if result:
logger.info("✅ Solution created for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to create solution")
@router.patch("/sag/{sag_id}/solution", response_model=Solution)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:
# Check if solution exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Solution not found")
# Build dynamic update query
set_clauses = []
params = []
# Helper to check and add params
if updates.title is not None:
set_clauses.append("title = %s")
params.append(updates.title)
if updates.description is not None:
set_clauses.append("description = %s")
params.append(updates.description)
if updates.solution_type is not None:
set_clauses.append("solution_type = %s")
params.append(updates.solution_type)
if updates.result is not None:
set_clauses.append("result = %s")
params.append(updates.result)
if not set_clauses:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses.append("updated_at = NOW()")
params.append(sag_id)
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
logger.info("✅ Solution updated for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to update solution")

View File

@ -1,5 +1,7 @@
import logging
from fastapi import APIRouter, HTTPException, Query
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
@ -8,32 +10,149 @@ from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
def _is_deadline_overdue(deadline_value) -> bool:
if not deadline_value:
return False
if isinstance(deadline_value, datetime):
return deadline_value.date() < date.today()
if isinstance(deadline_value, date):
return deadline_value < date.today()
return False
# Setup template directory
template_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(template_dir))
templates = Jinja2Templates(directory="app")
def _fetch_assignment_users():
return execute_query(
"""
SELECT user_id, COALESCE(full_name, username) AS display_name
FROM users
ORDER BY display_name
""",
()
) or []
def _fetch_assignment_groups():
return execute_query(
"""
SELECT id, name
FROM groups
ORDER BY name
""",
()
) or []
def _coerce_optional_int(value: Optional[str]) -> Optional[int]:
"""Convert empty strings and None to None, otherwise parse as int."""
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
@router.get("/sag", response_class=HTMLResponse)
async def sager_liste(
request,
request: Request,
status: str = Query(None),
tag: str = Query(None),
customer_id: int = Query(None),
customer_id: str = Query(None),
ansvarlig_bruger_id: str = Query(None),
assigned_group_id: str = Query(None),
include_deferred: bool = Query(False),
):
"""Display list of all cases."""
try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
# Coerce string params to optional ints
customer_id_int = _coerce_optional_int(customer_id)
ansvarlig_bruger_id_int = _coerce_optional_int(ansvarlig_bruger_id)
assigned_group_id_int = _coerce_optional_int(assigned_group_id)
query = """
SELECT s.*,
c.name as customer_name,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL
"""
params = []
if status:
query += " AND status = %s"
params.append(status)
if customer_id:
query += " AND customer_id = %s"
params.append(customer_id)
if not include_deferred:
query += " AND ("
query += "s.deferred_until IS NULL"
query += " OR s.deferred_until <= NOW()"
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
query += ")"
query += " ORDER BY created_at DESC"
if status:
query += " AND s.status = %s"
params.append(status)
if customer_id_int:
query += " AND s.customer_id = %s"
params.append(customer_id_int)
if ansvarlig_bruger_id_int:
query += " AND s.ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id_int)
if assigned_group_id_int:
query += " AND s.assigned_group_id = %s"
params.append(assigned_group_id_int)
query += " ORDER BY s.created_at DESC"
sager = execute_query(query, tuple(params))
# Fetch relations for all cases
relations_query = """
SELECT
sr.kilde_sag_id,
sr.målsag_id,
sr.relationstype,
sr.id as relation_id
FROM sag_relationer sr
WHERE sr.deleted_at IS NULL
"""
all_relations = execute_query(relations_query, ())
child_ids = set()
# Build relations map: {sag_id: [list of related sag_ids]}
relations_map = {}
for rel in all_relations or []:
if rel.get('målsag_id') is not None:
child_ids.add(rel['målsag_id'])
# Add forward relation
if rel['kilde_sag_id'] not in relations_map:
relations_map[rel['kilde_sag_id']] = []
relations_map[rel['kilde_sag_id']].append({
'target_id': rel['målsag_id'],
'type': rel['relationstype'],
'direction': 'forward'
})
# Add backward relation
if rel['målsag_id'] not in relations_map:
relations_map[rel['målsag_id']] = []
relations_map[rel['målsag_id']].append({
'target_id': rel['kilde_sag_id'],
'type': rel['relationstype'],
'direction': 'backward'
})
# Filter by tag if provided
if tag and sager:
sag_ids = [s['id'] for s in sager]
@ -46,24 +165,60 @@ async def sager_liste(
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ())
return templates.TemplateResponse("index.html", {
toggle_include_deferred_url = str(
request.url.include_query_params(include_deferred="0" if include_deferred else "1")
)
return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request,
"sager": sager,
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": [s['status'] for s in statuses],
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_tag": tag,
"include_deferred": include_deferred,
"toggle_include_deferred_url": toggle_include_deferred_url,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
})
except Exception as e:
logger.error("❌ Error displaying case list: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case list")
@router.get("/sag/new", response_class=HTMLResponse)
async def opret_sag_side(request: Request):
"""Show create case form."""
return templates.TemplateResponse("modules/sag/templates/create.html", {
"request": request,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
@router.get("/sag/varekob-salg", response_class=HTMLResponse)
async def sag_varekob_salg(request: Request):
"""Display orders overview for all purchases and sales."""
return templates.TemplateResponse("modules/sag/templates/varekob_salg.html", {
"request": request,
})
@router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def sag_detaljer(request, sag_id: int):
async def sag_detaljer(request: Request, sag_id: int):
"""Display case details."""
try:
# Fetch main case
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
sag_query = """
SELECT s.*,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.id = %s AND s.deleted_at IS NULL
"""
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
@ -71,10 +226,21 @@ async def sag_detaljer(request, sag_id: int):
sag = sag_result[0]
# Fetch tags
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
# Fetch tags (Support both Legacy sag_tags and New entity_tags)
# First try the new system (entity_tags) which the valid frontend uses
tags_query = """
SELECT t.name as tag_navn
FROM tags t
JOIN entity_tags et ON t.id = et.tag_id
WHERE et.entity_type = 'case' AND et.entity_id = %s
"""
tags = execute_query(tags_query, (sag_id,))
# If empty, try legacy table fallback
if not tags:
tags_query_legacy = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
tags = execute_query(tags_query_legacy, (sag_id,))
# Fetch relations
relationer_query = """
SELECT sr.*,
@ -89,23 +255,256 @@ async def sag_detaljer(request, sag_id: int):
"""
relationer = execute_query(relationer_query, (sag_id, sag_id))
# --- Relation Tree Construction ---
relation_tree = []
try:
from app.modules.sag.services.relation_service import RelationService
relation_tree = RelationService.get_relation_tree(sag_id)
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
# Fetch customer info if customer_id exists
customer = None
hovedkontakt = None
if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result:
customer = customer_result[0]
return templates.TemplateResponse("detail.html", {
# Fetch hovedkontakt (primary contact) for case via sag_kontakter
kontakt_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL AND sk.is_primary = TRUE
LIMIT 1
"""
kontakt_result = execute_query(kontakt_query, (sag_id,))
if kontakt_result:
hovedkontakt = kontakt_result[0]
else:
fallback_query = """
SELECT c.*
FROM contacts c
JOIN sag_kontakter sk ON c.id = sk.contact_id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
ORDER BY sk.created_at ASC
LIMIT 1
"""
fallback_result = execute_query(fallback_query, (sag_id,))
if fallback_result:
hovedkontakt = fallback_result[0]
# Fetch prepaid cards for customer
# Cast remaining_hours to float to avoid Jinja formatting issues with Decimal
# DEBUG: Logging customer ID
prepaid_cards = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """
SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s
AND status = 'active'
AND remaining_hours > 0
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
# Fetch fixed-price agreements for customer
fixed_price_agreements = []
if sag.get('customer_id'):
cid = sag.get('customer_id')
logger.info(f"🔎 Looking up fixed-price agreements for Sag {sag_id}, Customer ID: {cid}")
fpa_query = """
SELECT
a.id,
a.agreement_number,
a.monthly_hours,
COALESCE(bp.remaining_hours, a.monthly_hours) as remaining_hours_this_month
FROM customer_fixed_price_agreements a
LEFT JOIN fixed_price_billing_periods bp ON (
a.id = bp.agreement_id
AND bp.period_start <= CURRENT_DATE
AND bp.period_end >= CURRENT_DATE
)
WHERE a.customer_id = %s
AND a.status = 'active'
AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
ORDER BY a.created_at DESC
"""
fixed_price_agreements = execute_query(fpa_query, (cid,))
logger.info(f"📋 Found {len(fixed_price_agreements)} fixed-price agreements for customer {cid}")
# Fetch Nextcloud Instance for this customer
nextcloud_instance = None
if customer:
nc_query = "SELECT * FROM nextcloud_instances WHERE customer_id = %s AND deleted_at IS NULL"
nc_result = execute_query(nc_query, (customer['id'],))
if nc_result:
nextcloud_instance = nc_result[0]
# Fetch linked contacts
contacts_query = """
SELECT
sk.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
c.phone,
c.mobile,
c.title,
company.customer_name
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC, cu.name
LIMIT 1
) company ON TRUE
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
contacts = execute_query(contacts_query, (sag_id,))
# Fetch linked customers
customers_query = """
SELECT sk.*, c.name as customer_name, c.email as customer_email
FROM sag_kunder sk
JOIN customers c ON sk.customer_id = c.id
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL
"""
customers = execute_query(customers_query, (sag_id,))
# Fetch comments
comments_query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at ASC"
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
# Fetch Time Entries
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
time_entries = execute_query(time_query, (sag_id,))
# Fetch linked telephony call history
call_history_query = """
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer,
t.started_at,
t.ended_at,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
WHERE t.sag_id = %s
ORDER BY t.started_at DESC
LIMIT 200
"""
call_history = execute_query(call_history_query, (sag_id,))
# Check for nextcloud integration (case-insensitive, insensitive to whitespace)
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
logger.info(f"is_nextcloud result: {is_nextcloud}")
related_case_options = []
try:
related_ids = set()
for rel in relationer or []:
related_ids.add(rel["kilde_sag_id"])
related_ids.add(rel["målsag_id"])
related_ids.discard(sag_id)
if related_ids:
placeholders = ",".join(["%s"] * len(related_ids))
related_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders}) AND deleted_at IS NULL"
related_case_options = execute_query(related_query, tuple(related_ids))
except Exception as e:
logger.error("❌ Error building related case options: %s", e)
related_case_options = []
pipeline_stages = []
try:
pipeline_stages = execute_query(
"SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
(),
)
except Exception as e:
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail.html", {
"request": request,
"sag": sag,
"case": sag,
"customer": customer,
"hovedkontakt": hovedkontakt,
"contacts": contacts,
"customers": customers,
"prepaid_cards": prepaid_cards,
"fixed_price_agreements": fixed_price_agreements,
"tags": tags,
"relationer": relationer,
"relation_tree": relation_tree,
"comments": comments,
"solution": solution,
"time_entries": time_entries,
"call_history": call_history,
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses],
"is_deadline_overdue": is_deadline_overdue,
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error displaying case details: %s", e)
raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form."""
try:
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
sag_result = execute_query(sag_query, (sag_id,))
if not sag_result:
raise HTTPException(status_code=404, detail="Case not found")
return templates.TemplateResponse("modules/sag/templates/edit.html", {
"request": request,
"case": sag_result[0],
"assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(),
})
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error loading edit case page: %s", e)
raise HTTPException(status_code=500, detail="Failed to load edit case page")

View File

@ -0,0 +1,36 @@
-- Sag Module: Varekøb & Salg (case-linked sales items)
CREATE TABLE IF NOT EXISTS sag_salgsvarer (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL DEFAULT 'sale', -- sale | purchase
description TEXT NOT NULL,
quantity NUMERIC(12, 2),
unit VARCHAR(50),
unit_price NUMERIC(12, 2),
amount NUMERIC(12, 2) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | confirmed | cancelled
line_date DATE,
external_ref VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_sag_id ON sag_salgsvarer(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_type ON sag_salgsvarer(type);
CREATE INDEX IF NOT EXISTS idx_sag_salgsvarer_status ON sag_salgsvarer(status);
CREATE OR REPLACE FUNCTION update_sag_salgsvarer_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_sag_salgsvarer_updated_at ON sag_salgsvarer;
CREATE TRIGGER trigger_sag_salgsvarer_updated_at
BEFORE UPDATE ON sag_salgsvarer
FOR EACH ROW
EXECUTE FUNCTION update_sag_salgsvarer_updated_at();

View File

@ -0,0 +1,197 @@
from typing import List, Dict, Optional, Set
from app.core.database import execute_query
class RelationService:
"""Service for handling case relations (Sager)"""
@staticmethod
def get_relation_tree(root_id: int) -> List[Dict]:
"""
Builds a hierarchical tree of relations for a specific case.
Handles cycles and deduplication.
"""
# 1. Fetch all connected cases (Recursive CTE)
# We fetch a network around the case, but limit depth/count to avoid explosion
tree_ids_query = """
WITH RECURSIVE CaseTree AS (
SELECT id, 0 as depth FROM sag_sager WHERE id = %s
UNION
SELECT
CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END,
ct.depth + 1
FROM sag_relationer sr
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
WHERE sr.deleted_at IS NULL AND ct.depth < 5
)
SELECT DISTINCT id FROM CaseTree LIMIT 100;
"""
tree_ids_rows = execute_query(tree_ids_query, (root_id,))
tree_ids = [r['id'] for r in tree_ids_rows]
if not tree_ids:
return []
# 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases
tree_edges_query = f"""
SELECT id, kilde_sag_id, målsag_id, relationstype
FROM sag_relationer
WHERE deleted_at IS NULL
AND kilde_sag_id IN ({placeholders})
AND målsag_id IN ({placeholders})
"""
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
# 4. Build Graph (Adjacency List)
children_map: Dict[int, List[Dict]] = {cid: [] for cid in tree_ids}
parents_map: Dict[int, List[int]] = {cid: [] for cid in tree_ids}
# Helper to normalize relation types
# Now that we cleaned DB, we expect standard Danish terms, but good to be safe
def get_direction(k, m, rtype):
rtype_lower = rtype.lower()
if rtype_lower in ['afledt af', 'derived from']:
return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']:
return k, m # k is parent of m
# Default: k is "related" to m, treat as child for visualization if k is current root context
# But here we build a directed graph.
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
return k, m
processed_edges = set()
for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype']
# Dedup edges (bi-directional check)
edge_key = tuple(sorted((k,m))) + (rtype,)
# Actually, standardizing direction is key.
# If we have (k, m, 'Relateret til'), we add it once.
parent, child = get_direction(k, m, rtype)
# Avoid self-loops
if parent == child:
continue
# Add to maps
children_map[parent].append({
'id': child,
'type': rtype,
'rel_id': edge['id']
})
parents_map[child].append(parent)
# 5. Build Tree
# We want the `root_id` to be the visual root if possible.
# But if `root_id` is a child of something else in this graph, we might want to show that parent?
# The current design shows the requested case as root, OR finds the "true" root?
# The original code acted a bit vaguely on "roots".
# Let's try to center the view on `root_id`.
# Global visited set to prevent any node from appearing more than once in the entire tree
# This prevents the "duplicate entries" issue where a shared child appears under multiple parents
# However, it makes it hard to see shared dependencies.
# Standard approach: Show duplicate but mark it as "reference" or stop expansion.
global_visited = set()
def build_node(cid: int, path_visited: Set[int], current_rel_type: str = None, current_rel_id: int = None):
if cid not in tree_cases:
return None
# Cycle detection in current path
if cid in path_visited:
return {
'case': tree_cases[cid],
'relation_type': current_rel_type,
'relation_id': current_rel_id,
'is_current': cid == root_id,
'is_cycle': True,
'children': []
}
path_visited.add(cid)
# Sort children for consistent display
children_data = sorted(children_map.get(cid, []), key=lambda x: x['type'])
children_nodes = []
for child_info in children_data:
child_id = child_info['id']
# Check if we've seen this node globally to prevent tree duplication explosion
# If we want a strict tree where every node appears once:
# if child_id in global_visited: continue
# But users usually want to see the context.
# Let's check if the user wanted "Duplicate entries" removed.
# Yes. So let's use global_visited, OR just show a "Link" node.
# Using path_visited.copy() allows Multi-Parent display (A->C, B->C)
# creating visual duplicates.
# If we use global_visited, C only appears under A (if A processed first).
# Compromise: We only expand children if NOT globally visited.
# If globally visited, we show the node but no children (Leaf ref).
is_repeated = child_id in global_visited
global_visited.add(child_id)
child_node = build_node(child_id, path_visited.copy(), child_info['type'], child_info['rel_id'])
if child_node:
if is_repeated:
child_node['children'] = []
child_node['is_repeated'] = True
children_nodes.append(child_node)
return {
'case': tree_cases[cid],
'relation_type': current_rel_type,
'relation_id': current_rel_id,
'is_current': cid == root_id,
'children': children_nodes
}
# Determine Roots:
# If we just want to show the tree FROM the current case downwards (and upwards?),
# the original view mixed everything.
# Let's try to find the "top-most" parents of the current case, to show the full context.
# Traverse up from root_id to find a root
curr = root_id
while parents_map[curr]:
# Pick first parent (naive) - creates a single primary ancestry path
curr = parents_map[curr][0]
if curr == root_id: break # Cycle
effective_root = curr
# Build tree starting from effective root
global_visited.add(effective_root)
full_tree = build_node(effective_root, set())
if not full_tree:
return []
return [full_tree]
@staticmethod
def add_relation(source_id: int, target_id: int, type: str):
"""Creates a relation between two cases."""
query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, %s)
RETURNING id
"""
return execute_query(query, (source_id, target_id, type))
@staticmethod
def remove_relation(relation_id: int):
"""Soft deletes a relation."""
query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s"
execute_query(query, (relation_id,))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More